@trillboards/edge-sdk 0.2.24 → 0.2.25

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.
@@ -0,0 +1,81 @@
1
+ import { type HttpPoster } from './AudienceAnalyzeClient';
2
+ /**
3
+ * EnrollClient — UNATTENDED first-boot provisioning.
4
+ *
5
+ * Exchanges a FLEET enrollment credential (issued once in the portal, shipped
6
+ * inside the MDM/Intune/SCCM package) for THIS MACHINE's own connector
7
+ * credential, via `POST {apiBaseUrl}/v2/earner/connectors/enroll`.
8
+ *
9
+ * WHY THIS EXISTS. Every other path to a working connector needs a human at the
10
+ * machine: `pair --code` needs an 8-character claim code typed in, and `setup`
11
+ * needs a code read off this screen and typed into a browser on another. Neither
12
+ * can be automated, so before this there was no way to deploy to N machines.
13
+ *
14
+ * WHAT COMES BACK, AND WHAT DOES NOT. The response carries a `connectorKey`
15
+ * scoped to camera registration for one account — NOT the fleet credential, and
16
+ * NOT any device token. Device tokens are still minted per camera feed, later,
17
+ * when the cameras this machine finds are registered. That split is deliberate:
18
+ * the credential that ends up sitting on 200 store PCs must not be able to
19
+ * enroll a 201st machine.
20
+ *
21
+ * THE FLEET CREDENTIAL IS CONSUMED, NOT KEPT. The caller writes `connectorKey`
22
+ * into the config and DELETES the enrollment file — see `runEnroll` in cli.ts.
23
+ * A bootstrap credential that outlives its bootstrap is just a credential.
24
+ *
25
+ * Auth is `Authorization: Bearer <enrollment token>` — the same platform-key
26
+ * header the Partner API and the CTV SDK use. Unlike `PairClient`, this request
27
+ * IS authenticated; unlike `AudienceAnalyzeClient`, it carries no device token,
28
+ * because at first boot this machine has no device identity yet. That is the
29
+ * whole point of the exchange.
30
+ *
31
+ * Uses the same built-in-http poster as its siblings (no extra dep → survives
32
+ * bundling; trivially mockable in tests).
33
+ */
34
+ export interface EnrollClientOptions {
35
+ /** Base URL, e.g. https://api.trillboards.com (no trailing slash needed). */
36
+ apiBaseUrl: string;
37
+ /** Request timeout in ms. Default: 30000. */
38
+ timeoutMs?: number;
39
+ }
40
+ export interface EnrollRequest {
41
+ /** The fleet enrollment credential from the MDM package. */
42
+ enrollmentToken: string;
43
+ /** This machine's stable fingerprint — `hostFingerprint()` in cli.ts. */
44
+ machineId: string;
45
+ /** Free-text install label, e.g. "Store 0417". Optional. */
46
+ siteLabel?: string;
47
+ hostname?: string;
48
+ platform?: string;
49
+ connectorVersion?: string;
50
+ }
51
+ export interface EnrollResult {
52
+ ok: boolean;
53
+ /** Present only when ok. */
54
+ connectorId?: string;
55
+ connectorKey?: string;
56
+ siteLabel?: string | null;
57
+ reEnrollment?: boolean;
58
+ remainingEnrollments?: number | null;
59
+ error?: string;
60
+ /**
61
+ * Classification, so the installer wrapper can tell an operator-fixable
62
+ * problem from a wait-and-retry one WITHOUT parsing prose:
63
+ *
64
+ * 'auth' the credential is wrong, expired, revoked or exhausted. A human
65
+ * must issue a new one — retrying forever cannot help, and a
66
+ * connector that retries a dead credential every 60s across 200
67
+ * stores is a self-inflicted DDoS.
68
+ * 'network' unreachable / timed out. Retry.
69
+ * 'server' 5xx or an unparseable body. Retry.
70
+ * 'invalid' this machine sent something malformed. A bug, not a retry.
71
+ */
72
+ kind?: 'auth' | 'network' | 'server' | 'invalid';
73
+ }
74
+ export declare class EnrollClient {
75
+ private readonly endpoint;
76
+ private readonly timeoutMs;
77
+ private readonly poster;
78
+ constructor(options: EnrollClientOptions, poster?: HttpPoster);
79
+ enroll(req: EnrollRequest): Promise<EnrollResult>;
80
+ }
81
+ //# sourceMappingURL=EnrollClient.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EnrollClient.d.ts","sourceRoot":"","sources":["../src/EnrollClient.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAE7E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,MAAM,WAAW,mBAAmB;IAClC,6EAA6E;IAC7E,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,4DAA4D;IAC5D,eAAe,EAAE,MAAM,CAAC;IACxB,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAC;IAClB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,OAAO,CAAC;IACZ,4BAA4B;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;OAWG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;CAClD;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;gBAExB,OAAO,EAAE,mBAAmB,EAAE,MAAM,GAAE,UAA8B;IAU1E,MAAM,CAAC,GAAG,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC;CAqFxD"}
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EnrollClient = void 0;
4
+ const AudienceAnalyzeClient_1 = require("./AudienceAnalyzeClient");
5
+ class EnrollClient {
6
+ constructor(options, poster = AudienceAnalyzeClient_1.defaultHttpPoster) {
7
+ if (!options?.apiBaseUrl) {
8
+ throw new Error('EnrollClient requires an apiBaseUrl');
9
+ }
10
+ const base = options.apiBaseUrl.replace(/\/+$/, '');
11
+ this.endpoint = `${base}/v2/earner/connectors/enroll`;
12
+ this.timeoutMs = options.timeoutMs ?? 30000;
13
+ this.poster = poster;
14
+ }
15
+ async enroll(req) {
16
+ const token = (req?.enrollmentToken ?? '').trim();
17
+ if (!token) {
18
+ return { ok: false, error: 'enroll() requires a non-empty enrollmentToken', kind: 'invalid' };
19
+ }
20
+ if (!req?.machineId) {
21
+ return { ok: false, error: 'enroll() requires a machineId', kind: 'invalid' };
22
+ }
23
+ const body = { machineId: req.machineId };
24
+ if (req.siteLabel)
25
+ body.siteLabel = req.siteLabel;
26
+ if (req.hostname)
27
+ body.hostname = req.hostname;
28
+ if (req.platform)
29
+ body.platform = req.platform;
30
+ if (req.connectorVersion)
31
+ body.connectorVersion = req.connectorVersion;
32
+ let response;
33
+ try {
34
+ response = await this.poster(this.endpoint, {
35
+ method: 'POST',
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ Accept: 'application/json',
39
+ Authorization: `Bearer ${token}`,
40
+ },
41
+ timeoutMs: this.timeoutMs,
42
+ }, JSON.stringify(body));
43
+ }
44
+ catch (err) {
45
+ return {
46
+ ok: false,
47
+ error: `enrollment request failed: ${err?.message ?? 'unknown error'}`,
48
+ kind: 'network',
49
+ };
50
+ }
51
+ let parsed = null;
52
+ try {
53
+ parsed = JSON.parse(response.body);
54
+ }
55
+ catch {
56
+ parsed = null;
57
+ }
58
+ const code = response.statusCode;
59
+ if (code < 200 || code >= 300) {
60
+ // 401/403 is terminal: the credential is wrong, spent, expired or revoked.
61
+ // The server deliberately does not say WHICH — see consumeKeyUse — so the
62
+ // connector cannot tell either, and must not retry as if it could.
63
+ const kind = code === 401 || code === 403
64
+ ? 'auth'
65
+ : code === 400
66
+ ? 'invalid'
67
+ : 'server';
68
+ const message = parsed?.error
69
+ || parsed?.message
70
+ || `enrollment failed (HTTP ${code})`;
71
+ return { ok: false, error: message, kind };
72
+ }
73
+ const connectorKey = typeof parsed?.connectorKey === 'string' ? parsed.connectorKey : '';
74
+ const connectorId = typeof parsed?.connectorId === 'string' ? parsed.connectorId : '';
75
+ if (!connectorKey || !connectorId) {
76
+ // A 2xx that carries no credential is the ingestion-endpoint trap in
77
+ // reverse: treating it as success would leave a "provisioned" machine with
78
+ // nothing to authenticate with, and the failure would only surface much
79
+ // later as a camera that never registers.
80
+ return {
81
+ ok: false,
82
+ error: 'enrollment response did not contain a connector credential',
83
+ kind: 'server',
84
+ };
85
+ }
86
+ return {
87
+ ok: true,
88
+ connectorId,
89
+ connectorKey,
90
+ siteLabel: parsed?.siteLabel ?? null,
91
+ reEnrollment: parsed?.reEnrollment === true,
92
+ remainingEnrollments: typeof parsed?.remainingEnrollments === 'number' ? parsed.remainingEnrollments : null,
93
+ };
94
+ }
95
+ }
96
+ exports.EnrollClient = EnrollClient;
97
+ //# sourceMappingURL=EnrollClient.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EnrollClient.js","sourceRoot":"","sources":["../src/EnrollClient.ts"],"names":[],"mappings":"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"}
package/dist/cli.d.ts CHANGED
@@ -26,4 +26,53 @@ export declare function configWritePermissionMessage(resolvedConfigPath: string)
26
26
  export declare function writePairedConfig(resolvedConfigPath: string, serialized: string, writeFile?: (p: string, data: string, opts: {
27
27
  mode: number;
28
28
  }) => void): void;
29
+ /** Show a credential's SHAPE without showing the credential. */
30
+ export declare function maskConnectorKey(key: string | undefined | null): string;
31
+ /**
32
+ * Read the enrollment token for an unattended install, in precedence order.
33
+ *
34
+ * 1. `--enrollment-token <value>` — for a scripted/manual run.
35
+ * 2. `TRILLBOARDS_ENROLLMENT_TOKEN` — the Docker/systemd path.
36
+ * 3. `--enrollment-file <path>` / the default bootstrap file next to the config
37
+ * — what the Windows installer writes from `/ENROLLMENT_TOKEN=`.
38
+ *
39
+ * The FILE is the preferred form for MDM and the reason this function exists:
40
+ * an argument shows up in the process table and an env var is inherited by every
41
+ * child process, while a file can be read once and DELETED. That deletion is not
42
+ * hygiene, it is the design — the fleet credential must not outlive the
43
+ * bootstrap, or every store PC keeps something that can enroll more machines.
44
+ */
45
+ export declare function readEnrollmentToken(args: string[], configPath: string, env?: NodeJS.ProcessEnv, readFile?: (p: string) => string, exists?: (p: string) => boolean): {
46
+ token: string | null;
47
+ source: 'flag' | 'env' | 'file' | null;
48
+ filePath: string | null;
49
+ };
50
+ /**
51
+ * UNATTENDED first-boot provisioning — the MDM/Intune/SCCM path.
52
+ *
53
+ * trillboards-edge enroll [--enrollment-token T | --enrollment-file P]
54
+ * [--site "Store 0417"] [--config PATH] [--api-base URL]
55
+ *
56
+ * Exchanges the FLEET enrollment credential shipped in the deployment package
57
+ * for THIS MACHINE's own connector credential and writes it to the config. No
58
+ * human touches the machine, which is the entire point: `pair` needs a code
59
+ * typed in and `setup` needs a code read off the screen, so neither can be
60
+ * automated across N stores.
61
+ *
62
+ * IDEMPOTENT BY DESIGN. Re-running on an already-enrolled machine is safe and
63
+ * FREE — the server keys on `machineId` and does not spend another use of the
64
+ * bounded fleet credential. MDM re-pushes packages; if a re-push cost a use, a
65
+ * 200-use credential would die partway through a 200-store rollout.
66
+ *
67
+ * ON SUCCESS THE ENROLLMENT FILE IS DELETED. See readEnrollmentToken.
68
+ *
69
+ * Exit codes (the install wrappers branch on these — keep stable, and note they
70
+ * mirror `pair`'s so a wrapper can treat the two paths alike):
71
+ * 0 enrolled (or already enrolled), config written
72
+ * 1 the credential was rejected — wrong, expired, revoked or exhausted. A
73
+ * human must issue a new one; retrying cannot help.
74
+ * 4 no enrollment token was supplied at all (not an unattended install)
75
+ * 6 network/server failure — the credential is UNSPENT. Retry later.
76
+ */
77
+ export declare function runEnroll(args: string[]): Promise<void>;
29
78
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAkcA;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAKvD;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,kBAAkB,EAAE,MAAM,GAAG,MAAM,CAW/E;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,kBAAkB,EAAE,MAAM,EAC1B,UAAU,EAAE,MAAM,EAClB,SAAS,GAAE,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,KAAK,IAC/B,GAChC,IAAI,CAWN"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAscA;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAKvD;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAAC,kBAAkB,EAAE,MAAM,GAAG,MAAM,CAW/E;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,kBAAkB,EAAE,MAAM,EAC1B,UAAU,EAAE,MAAM,EAClB,SAAS,GAAE,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,KAAK,IAC/B,GAChC,IAAI,CAWN;AAUD,gEAAgE;AAChE,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CASvE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EAAE,EACd,UAAU,EAAE,MAAM,EAClB,GAAG,GAAE,MAAM,CAAC,UAAwB,EACpC,QAAQ,GAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAA2C,EACpE,MAAM,GAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAiC,GACvD;IAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAyB3F;AAgGD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CA+K7D"}
package/dist/cli.js CHANGED
@@ -37,6 +37,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.isPermissionError = isPermissionError;
38
38
  exports.configWritePermissionMessage = configWritePermissionMessage;
39
39
  exports.writePairedConfig = writePairedConfig;
40
+ exports.maskConnectorKey = maskConnectorKey;
41
+ exports.readEnrollmentToken = readEnrollmentToken;
42
+ exports.runEnroll = runEnroll;
40
43
  const config_1 = require("./config");
41
44
  const autoSense_1 = require("./autoSense");
42
45
  const sensingRunMode_1 = require("./sensingRunMode");
@@ -180,6 +183,9 @@ async function main() {
180
183
  case 'pair':
181
184
  await runPair(args.slice(1));
182
185
  break;
186
+ case 'enroll':
187
+ await runEnroll(args.slice(1));
188
+ break;
183
189
  case 'setup': {
184
190
  // Validate-first onboarding (direction-inverted pairing): WE display a
185
191
  // short code, the owner claims it in the portal, the portal drives
@@ -490,6 +496,318 @@ function writePairedConfig(resolvedConfigPath, serialized, writeFile = (p, data,
490
496
  throw err;
491
497
  }
492
498
  }
499
+ /** Show a credential's SHAPE without showing the credential. */
500
+ function maskConnectorKey(key) {
501
+ if (!key)
502
+ return '(none)';
503
+ const s = String(key);
504
+ // Prefix identifies the key family; last 4 lets an admin match it to the
505
+ // portal row. Everything between is what must never reach a log, a support
506
+ // bundle, or a screenshot of this terminal.
507
+ const head = s.slice(0, 7);
508
+ const tail = s.length > 4 ? s.slice(-4) : '';
509
+ return `${head}…${tail}`;
510
+ }
511
+ /**
512
+ * Read the enrollment token for an unattended install, in precedence order.
513
+ *
514
+ * 1. `--enrollment-token <value>` — for a scripted/manual run.
515
+ * 2. `TRILLBOARDS_ENROLLMENT_TOKEN` — the Docker/systemd path.
516
+ * 3. `--enrollment-file <path>` / the default bootstrap file next to the config
517
+ * — what the Windows installer writes from `/ENROLLMENT_TOKEN=`.
518
+ *
519
+ * The FILE is the preferred form for MDM and the reason this function exists:
520
+ * an argument shows up in the process table and an env var is inherited by every
521
+ * child process, while a file can be read once and DELETED. That deletion is not
522
+ * hygiene, it is the design — the fleet credential must not outlive the
523
+ * bootstrap, or every store PC keeps something that can enroll more machines.
524
+ */
525
+ function readEnrollmentToken(args, configPath, env = process.env, readFile = (p) => fs.readFileSync(p, 'utf-8'), exists = (p) => fs.existsSync(p)) {
526
+ const fromFlag = getArg(args, '--enrollment-token');
527
+ if (fromFlag && fromFlag.trim())
528
+ return { token: fromFlag.trim(), source: 'flag', filePath: null };
529
+ const fromEnv = env.TRILLBOARDS_ENROLLMENT_TOKEN;
530
+ if (fromEnv && fromEnv.trim())
531
+ return { token: fromEnv.trim(), source: 'env', filePath: null };
532
+ const filePath = path.resolve(getArg(args, '--enrollment-file')
533
+ || path.join(path.dirname(path.resolve(configPath)), 'trillboards.enrollment'));
534
+ if (exists(filePath)) {
535
+ try {
536
+ // The installer writes the bare token, but tolerate a trailing newline and
537
+ // a `TOKEN=` prefix — an admin editing this file by hand should not have
538
+ // their rollout fail on a shell habit.
539
+ const raw = readFile(filePath).trim();
540
+ const token = raw.replace(/^TRILLBOARDS_ENROLLMENT_TOKEN\s*=\s*/i, '').replace(/^["']|["']$/g, '').trim();
541
+ if (token)
542
+ return { token, source: 'file', filePath };
543
+ }
544
+ catch {
545
+ // Unreadable is the same as absent for the caller; the missing-token
546
+ // message below is the right thing to print either way.
547
+ }
548
+ }
549
+ return { token: null, source: null, filePath };
550
+ }
551
+ /**
552
+ * Find the feeds on this machine's LAN and hand them to the server, returning
553
+ * the `cameras[]` this connector will run on.
554
+ *
555
+ * WHY CREDENTIALS ARE REQUIRED HERE, and why "no credentials" is not an error:
556
+ * this product targets cheap analog DVRs whose channels are behind a recorder
557
+ * password. Registering a channel we cannot open would mint a screen that never
558
+ * sees a frame — a camera board full of feeds that are permanently dark, which
559
+ * is worse than an empty one. So with no credentials we register NOTHING and say
560
+ * so plainly; the operator finishes in the portal exactly as before.
561
+ *
562
+ * The recorder credentials never leave this machine: only a stable `cameraKey`
563
+ * and a display name go to the server, and the credentialed URLs are matched
564
+ * back to the returned device tokens locally.
565
+ */
566
+ async function discoverAndRegisterFeeds(opts) {
567
+ if (!opts.nvrUser || !opts.nvrPass) {
568
+ return {
569
+ feeds: [],
570
+ discovered: 0,
571
+ reason: 'no recorder credentials were supplied (--nvr-user / --nvr-pass), so no feeds were registered',
572
+ };
573
+ }
574
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
575
+ const discovery = require('./discovery');
576
+ const recorders = await discovery.sweepLanForRecorders({});
577
+ if (!recorders.length) {
578
+ return { feeds: [], discovered: 0, reason: 'no recorders answered on this network' };
579
+ }
580
+ // The XM/DVRIP handshake makes a recorder state its OWN channel count, which
581
+ // is the difference between "a socket accepted a connection" and "a 16-channel
582
+ // recorder, expand it". Hosts that do not answer keep their port-open shape and
583
+ // fall back to a single channel below.
584
+ const annotated = await discovery.annotateXmRecorders(recorders);
585
+ const brand = (opts.brand || 'generic');
586
+ const discovered = [];
587
+ for (const rec of annotated) {
588
+ const xm = rec.xm;
589
+ const channels = Number(xm?.channelNum) > 0 ? Number(xm.channelNum) : 1;
590
+ const expanded = discovery.expandChannelRange({
591
+ brand,
592
+ host: rec.host,
593
+ count: channels,
594
+ username: opts.nvrUser,
595
+ password: opts.nvrPass,
596
+ namePrefix: opts.siteLabel ? `${opts.siteLabel} channel` : 'Channel',
597
+ });
598
+ for (const cam of expanded)
599
+ discovered.push(cam);
600
+ }
601
+ if (!discovered.length) {
602
+ return { feeds: [], discovered: 0, reason: 'recorders answered but no channels could be expanded' };
603
+ }
604
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
605
+ const { ConnectorCameraClient, buildCameraFeeds, MAX_CAMERAS_PER_CALL } = require('./ConnectorCameraClient');
606
+ const client = new ConnectorCameraClient({ apiBaseUrl: opts.apiBaseUrl, connectorKey: opts.connectorKey });
607
+ // The server caps one call; a 64-channel recorder is a real configuration, so
608
+ // page rather than silently truncating (the claim-code path's `.slice(0, 64)`
609
+ // is exactly the bug this avoids).
610
+ const feeds = [];
611
+ for (let i = 0; i < discovered.length; i += MAX_CAMERAS_PER_CALL) {
612
+ const page = discovered.slice(i, i + MAX_CAMERAS_PER_CALL);
613
+ // eslint-disable-next-line no-await-in-loop
614
+ const result = await client.registerCameras(page.map((c) => ({ cameraKey: c.cameraKey, name: c.name })));
615
+ if (!result.ok) {
616
+ // Partial success is kept: the feeds already registered are real and their
617
+ // tokens work. Reporting zero would throw away provisioning that happened.
618
+ return {
619
+ feeds,
620
+ discovered: discovered.length,
621
+ reason: `camera registration failed after ${feeds.length} feed(s): ${result.error}`,
622
+ };
623
+ }
624
+ for (const feed of buildCameraFeeds(result.cameras, discovered))
625
+ feeds.push(feed);
626
+ }
627
+ return { feeds, discovered: discovered.length };
628
+ }
629
+ /**
630
+ * UNATTENDED first-boot provisioning — the MDM/Intune/SCCM path.
631
+ *
632
+ * trillboards-edge enroll [--enrollment-token T | --enrollment-file P]
633
+ * [--site "Store 0417"] [--config PATH] [--api-base URL]
634
+ *
635
+ * Exchanges the FLEET enrollment credential shipped in the deployment package
636
+ * for THIS MACHINE's own connector credential and writes it to the config. No
637
+ * human touches the machine, which is the entire point: `pair` needs a code
638
+ * typed in and `setup` needs a code read off the screen, so neither can be
639
+ * automated across N stores.
640
+ *
641
+ * IDEMPOTENT BY DESIGN. Re-running on an already-enrolled machine is safe and
642
+ * FREE — the server keys on `machineId` and does not spend another use of the
643
+ * bounded fleet credential. MDM re-pushes packages; if a re-push cost a use, a
644
+ * 200-use credential would die partway through a 200-store rollout.
645
+ *
646
+ * ON SUCCESS THE ENROLLMENT FILE IS DELETED. See readEnrollmentToken.
647
+ *
648
+ * Exit codes (the install wrappers branch on these — keep stable, and note they
649
+ * mirror `pair`'s so a wrapper can treat the two paths alike):
650
+ * 0 enrolled (or already enrolled), config written
651
+ * 1 the credential was rejected — wrong, expired, revoked or exhausted. A
652
+ * human must issue a new one; retrying cannot help.
653
+ * 4 no enrollment token was supplied at all (not an unattended install)
654
+ * 6 network/server failure — the credential is UNSPENT. Retry later.
655
+ */
656
+ async function runEnroll(args) {
657
+ const configPath = getArg(args, '--config') || 'trillboards.config.json';
658
+ const resolvedConfigPath = path.resolve(configPath);
659
+ const apiBaseUrl = getArg(args, '--api-base')
660
+ || process.env.TRILLBOARDS_API_BASE
661
+ || 'https://api.trillboards.com';
662
+ const siteLabel = getArg(args, '--site') || process.env.TRILLBOARDS_SITE || undefined;
663
+ const { token, source, filePath } = readEnrollmentToken(args, resolvedConfigPath);
664
+ if (!token) {
665
+ console.error('No enrollment credential found.\n' +
666
+ ' Pass --enrollment-token <token>, set TRILLBOARDS_ENROLLMENT_TOKEN,\n' +
667
+ ` or place the token in ${filePath ?? 'trillboards.enrollment'}.\n` +
668
+ ' Create one in the portal under Cameras → Deploy connectors.');
669
+ process.exit(4);
670
+ return;
671
+ }
672
+ const machineId = hostFingerprint();
673
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
674
+ const { EnrollClient } = require('./EnrollClient');
675
+ const client = new EnrollClient({ apiBaseUrl });
676
+ const result = await client.enroll({
677
+ enrollmentToken: token,
678
+ machineId,
679
+ siteLabel,
680
+ hostname: os.hostname(),
681
+ platform: process.platform,
682
+ connectorVersion: VERSION,
683
+ });
684
+ if (!result.ok) {
685
+ console.error(`Enrollment failed: ${result.error}`);
686
+ if (result.kind === 'auth') {
687
+ console.error('The enrollment credential was rejected. It may be expired, revoked, or it may\n' +
688
+ 'have already enrolled every machine it was issued for. Issue a new one in the\n' +
689
+ 'portal — retrying with this one cannot succeed.');
690
+ process.exit(1);
691
+ return;
692
+ }
693
+ if (result.kind === 'invalid') {
694
+ process.exit(1);
695
+ return;
696
+ }
697
+ console.error('This is a network or server failure — the credential was NOT spent. Retry later.');
698
+ process.exit(6);
699
+ return;
700
+ }
701
+ // Merge into any existing config rather than replacing it. A machine may be
702
+ // re-enrolled after cameras are already registered, and clobbering
703
+ // `cameras[]` would drop every device token on the box.
704
+ let existing = {};
705
+ if (fs.existsSync(resolvedConfigPath)) {
706
+ try {
707
+ existing = JSON.parse(fs.readFileSync(resolvedConfigPath, 'utf-8'));
708
+ }
709
+ catch {
710
+ console.warn(`Existing config at ${resolvedConfigPath} is not valid JSON — writing a fresh one.`);
711
+ existing = {};
712
+ }
713
+ }
714
+ // Now that this machine has a credential, find its feeds and register them.
715
+ // Enrollment without this leaves the box with an identity and no cameras, and
716
+ // `auto-sense` exits through its no-feeds path — an install that reports
717
+ // success while a human still has to run the interactive camera setup.
718
+ let registration = {
719
+ feeds: [],
720
+ discovered: 0,
721
+ reason: 'camera registration was skipped',
722
+ };
723
+ try {
724
+ registration = await discoverAndRegisterFeeds({
725
+ apiBaseUrl,
726
+ connectorKey: result.connectorKey,
727
+ nvrUser: getArg(args, '--nvr-user') || process.env.TRILLBOARDS_NVR_USER,
728
+ nvrPass: getArg(args, '--nvr-pass') || process.env.TRILLBOARDS_NVR_PASS,
729
+ brand: getArg(args, '--brand'),
730
+ siteLabel: result.siteLabel || siteLabel,
731
+ });
732
+ }
733
+ catch (err) {
734
+ // A discovery failure must never lose the credential we just obtained: the
735
+ // enrollment itself SUCCEEDED and its use is spent, so write the config and
736
+ // let the operator (or the next-logon retry) finish the camera half.
737
+ registration = {
738
+ feeds: [],
739
+ discovered: 0,
740
+ reason: `camera discovery failed: ${err?.message ?? 'unknown error'}`,
741
+ };
742
+ }
743
+ const existingCameras = Array.isArray(existing.cameras)
744
+ ? (existing.cameras)
745
+ : [];
746
+ // Merge by screenId so a re-run refreshes a feed's token rather than
747
+ // duplicating it, and never drops a feed the operator added by hand.
748
+ const mergedCameras = registration.feeds.length > 0
749
+ ? [
750
+ ...registration.feeds,
751
+ ...existingCameras.filter((c) => !registration.feeds.some((f) => f.screenId && f.screenId === c.screenId)),
752
+ ]
753
+ : existingCameras;
754
+ const merged = {
755
+ ...existing,
756
+ apiBaseUrl,
757
+ ...(mergedCameras.length > 0 ? { cameras: mergedCameras } : {}),
758
+ connector: {
759
+ id: result.connectorId,
760
+ key: result.connectorKey,
761
+ machineId,
762
+ ...(result.siteLabel ? { siteLabel: result.siteLabel } : {}),
763
+ enrolledAt: new Date().toISOString(),
764
+ },
765
+ };
766
+ fs.mkdirSync(path.dirname(resolvedConfigPath), { recursive: true });
767
+ writePairedConfig(resolvedConfigPath, JSON.stringify(merged, null, 2) + '\n');
768
+ // `mode` on writeFileSync only applies on CREATE, so an existing 0644 config —
769
+ // exactly what `trillboards-edge init` leaves behind — would keep its
770
+ // permissions and this credential would land world-readable. Tighten
771
+ // explicitly, as setupMode.ts does for the same reason.
772
+ try {
773
+ fs.chmodSync(resolvedConfigPath, 0o600);
774
+ }
775
+ catch {
776
+ // Windows has no POSIX modes; %LOCALAPPDATA% is the per-user boundary there.
777
+ }
778
+ // The fleet credential has done its one job. Anything left on disk that can
779
+ // enroll another machine is a skeleton key sitting in a store.
780
+ if (source === 'file' && filePath) {
781
+ try {
782
+ fs.unlinkSync(filePath);
783
+ }
784
+ catch (err) {
785
+ console.warn(`WARNING: enrolled successfully but could not delete the enrollment file at ${filePath}: ` +
786
+ `${err?.message}. Delete it manually — it can enroll further machines.`);
787
+ }
788
+ }
789
+ console.log(`${result.reEnrollment ? 'Re-enrolled' : 'Enrolled'} connector ${result.connectorId}` +
790
+ (result.siteLabel ? ` (${result.siteLabel})` : '') +
791
+ `\n machine: ${machineId}` +
792
+ `\n credential: ${maskConnectorKey(result.connectorKey)}` +
793
+ (result.remainingEnrollments != null
794
+ ? `\n remaining enrollments on this credential: ${result.remainingEnrollments}`
795
+ : '') +
796
+ `\n cameras: ${mergedCameras.length} registered` +
797
+ (registration.reason ? ` (${registration.reason})` : ''));
798
+ // Exit 5 = provisioned but NOT sensing. A distinct code, because "enrolled"
799
+ // and "enrolled and actually watching something" are different outcomes and
800
+ // collapsing them into 0 is how an installer reports success on a machine that
801
+ // still needs a human.
802
+ if (mergedCameras.length === 0) {
803
+ console.log('This connector is provisioned but has no camera feeds yet. Supply recorder\n' +
804
+ 'credentials (--nvr-user / --nvr-pass, or /NVR_USER= /NVR_PASS= at install)\n' +
805
+ 'and re-run, or finish in the portal.');
806
+ process.exit(5);
807
+ return;
808
+ }
809
+ process.exit(0);
810
+ }
493
811
  /**
494
812
  * First-run pairing: exchange a portal claim code for the connector config and
495
813
  * write trillboards.config.json. The device token is delivered ONLY here (never
@@ -749,6 +1067,20 @@ Usage:
749
1067
  trillboards-edge setup [--config <path>] Validate-first onboarding: opens the portal and keeps a code fallback
750
1068
  trillboards-edge pair --code <XXXX-XXXX> Pair with a portal claim code (writes config)
751
1069
  trillboards-edge pair --qr <image.png> Pair by scanning a screenshot/photo of the portal QR
1070
+ trillboards-edge enroll [--site "Store 0417"] [--nvr-user U --nvr-pass P]
1071
+ Unattended provisioning for MDM/Intune/SCCM — no
1072
+ human at the machine. Reads the fleet enrollment
1073
+ credential from --enrollment-token, the
1074
+ TRILLBOARDS_ENROLLMENT_TOKEN env var, or the
1075
+ trillboards.enrollment file, exchanges it for THIS
1076
+ machine's own credential, deletes the file, then
1077
+ discovers recorders on the LAN and registers their
1078
+ channels (recorder credentials required for that
1079
+ half — without them the machine provisions its
1080
+ identity and registers no feeds).
1081
+ Safe to re-run: a re-push does not consume another
1082
+ enrollment. (exit 0=ok 1=credential rejected
1083
+ 4=no token 5=enrolled but no feeds 6=network, retry)
752
1084
  trillboards-edge verify-connection [--config p] Prove the saved pairing works (exit 0=ok 2=network 3=auth 4=no config)
753
1085
  trillboards-edge discover Find cameras on your network (ONVIF) or expand an NVR's channels
754
1086
  trillboards-edge status [--host h] [--port p] Show agent status dashboard