nervepay 1.3.6 → 1.3.9

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.
Files changed (2) hide show
  1. package/bin/nervepay-cli.js +145 -183
  2. package/package.json +1 -1
@@ -380,7 +380,7 @@ program
380
380
  .option('--code <code>', 'Pairing code from dashboard (legacy flow)')
381
381
  .option('--api-url <url>', 'NervePay API URL', DEFAULT_API_URL)
382
382
  .option('--gateway-url <url>', 'Gateway URL')
383
- .option('--gateway-token <token>', 'Gateway token (only for --code flow)')
383
+ .option('--gateway-token <token>', 'Gateway auth token')
384
384
  .option('--name <name>', 'Gateway display name', 'OpenClaw Gateway')
385
385
  .option('--timeout <seconds>', 'Approval timeout in seconds', '300')
386
386
  .action(async (options) => {
@@ -392,6 +392,80 @@ program
392
392
  }
393
393
  });
394
394
 
395
+ // ---------------------------------------------------------------------------
396
+ // WebSocket connect helper (used for initial connect + reconnect polling)
397
+ // ---------------------------------------------------------------------------
398
+
399
+ function attemptConnect(WebSocket, wsUrl, ctx) {
400
+ return new Promise((resolve, reject) => {
401
+ let settled = false;
402
+ const ws = new WebSocket(wsUrl, { rejectUnauthorized: false });
403
+
404
+ function settle(fn, value) {
405
+ if (settled) return;
406
+ settled = true;
407
+ try { ws.close(); } catch {}
408
+ fn(value);
409
+ }
410
+
411
+ const timer = setTimeout(() => settle(reject, new Error('Connect timeout')), 15000);
412
+
413
+ ws.on('error', (e) => settle(reject, e));
414
+ ws.on('close', () => setTimeout(() => settle(reject, new Error('Connection closed')), 150));
415
+
416
+ ws.on('message', async (data) => {
417
+ try {
418
+ const frame = JSON.parse(data.toString());
419
+
420
+ // Challenge → sign and connect
421
+ if (frame.event === 'connect.challenge') {
422
+ const nonce = frame.payload?.nonce || '';
423
+ const signedAtMs = Date.now();
424
+ const role = 'operator';
425
+ const scopes = ['operator.read', 'operator.write'];
426
+
427
+ const payload = [
428
+ 'v2', ctx.deviceId, 'node-host', 'node', role,
429
+ scopes.join(','), String(signedAtMs), ctx.gatewayToken, nonce,
430
+ ].join('|');
431
+
432
+ const payloadBytes = new TextEncoder().encode(payload);
433
+ const signatureB64 = await ctx.signRawBytes(ctx.privateKey, payloadBytes);
434
+ const signatureB64Url = signatureB64
435
+ .replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
436
+
437
+ const connectId = crypto.randomUUID();
438
+ ws.send(JSON.stringify({
439
+ type: 'req', id: connectId, method: 'connect',
440
+ params: {
441
+ role, minProtocol: 3, maxProtocol: 3, scopes,
442
+ client: { id: 'node-host', version: ctx.VERSION, platform: process.platform, mode: 'node' },
443
+ device: { id: ctx.deviceId, publicKey: ctx.pubKeyB64Url, signature: signatureB64Url, signedAt: signedAtMs, nonce },
444
+ auth: { token: ctx.gatewayToken },
445
+ },
446
+ }));
447
+
448
+ // Wait for response
449
+ ws.once('message', (resData) => {
450
+ clearTimeout(timer);
451
+ try {
452
+ const res = JSON.parse(resData.toString());
453
+ if (res.ok) {
454
+ const deviceToken = res.payload?.auth?.deviceToken || '';
455
+ settle(resolve, { token: deviceToken, payload: res.payload });
456
+ } else if (res.error?.code === 'NOT_PAIRED') {
457
+ settle(reject, new Error('NOT_PAIRED'));
458
+ } else {
459
+ settle(reject, new Error(res.error?.message || 'Connect failed'));
460
+ }
461
+ } catch { settle(reject, new Error('Invalid response')); }
462
+ });
463
+ }
464
+ } catch {}
465
+ });
466
+ });
467
+ }
468
+
395
469
  // ---------------------------------------------------------------------------
396
470
  // Device node pairing
397
471
  // ---------------------------------------------------------------------------
@@ -412,13 +486,17 @@ async function deviceNodePairing(options) {
412
486
  logOk('Agent loaded');
413
487
  field(' DID', agentDid);
414
488
 
415
- // Resolve gateway URL
489
+ // Resolve gateway URL and token
416
490
  let gatewayUrl = options.gatewayUrl;
417
- if (!gatewayUrl) {
491
+ let gatewayToken = options.gatewayToken;
492
+ if (!gatewayUrl || !gatewayToken) {
418
493
  const oc = await readOpenClawConfig();
419
- gatewayUrl = extractGatewayConfig(oc).url;
494
+ const gwConf = extractGatewayConfig(oc);
495
+ gatewayUrl = gatewayUrl || gwConf.url;
496
+ gatewayToken = gatewayToken || gwConf.token;
420
497
  }
421
498
  if (!gatewayUrl) die('No gateway URL found', 'Use: nervepay pair --gateway-url <url>');
499
+ if (!gatewayToken) die('No gateway token found', 'Use: nervepay pair --gateway-token <token>');
422
500
 
423
501
  const wsUrl = httpToWs(gatewayUrl);
424
502
  field(' Gateway', wsUrl);
@@ -432,200 +510,84 @@ async function deviceNodePairing(options) {
432
510
  logInfo('Connecting...');
433
511
 
434
512
  const WebSocket = (await import('ws')).default;
435
- const ws = new WebSocket(wsUrl, { rejectUnauthorized: false });
436
- const timeoutMs = parseInt(options.timeout, 10) * 1000;
437
-
438
- // Pre-compute crypto outside WS handler to avoid async race conditions
439
513
  const pubKeyBytes = decodeBase58(publicKeyBase58);
440
- // OpenClaw expects base64url encoding (no padding) for public key
441
514
  const pubKeyB64Url = Buffer.from(pubKeyBytes).toString('base64')
442
515
  .replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
443
516
 
444
- const result = await new Promise((resolve, reject) => {
445
- let settled = false;
446
- let connectId = null;
447
- let pairId = null;
448
- let requestId = null;
449
- let lastError = null;
450
-
451
- function settle(fn, value) {
452
- if (settled) return;
453
- settled = true;
454
- clearTimeout(timer);
455
- fn(value);
456
- }
457
-
458
- const timer = setTimeout(() => {
459
- ws.close();
460
- settle(reject, new Error(`Approval timed out after ${options.timeout}s`));
461
- }, timeoutMs + 15000);
462
-
463
- ws.on('error', (e) => settle(reject, new Error(`Connection failed: ${e.message}`)));
517
+ const ctx = { deviceId, pubKeyB64Url, privateKey, gatewayToken, signRawBytes, VERSION };
518
+ const timeoutMs = parseInt(options.timeout, 10) * 1000;
464
519
 
465
- ws.on('close', (code, reason) => {
466
- setTimeout(() => {
467
- const detail = lastError || (reason?.toString() ? `${reason}` : `code ${code}`);
468
- settle(reject, new Error(detail));
469
- }, 150);
470
- });
520
+ // First attempt
521
+ let deviceToken = '';
522
+ try {
523
+ const result = await attemptConnect(WebSocket, wsUrl, ctx);
524
+ deviceToken = result.token;
525
+ logOk('Connected (device already paired)');
526
+ } catch (e) {
527
+ if (e.message !== 'NOT_PAIRED') throw e;
471
528
 
472
- ws.on('message', async (data) => {
473
- try {
474
- const frame = JSON.parse(data.toString());
475
- logDebug('<<', JSON.stringify(frame).slice(0, 300));
476
-
477
- // Challenge → build auth payload, sign, and connect
478
- if (frame.event === 'connect.challenge') {
479
- const nonce = frame.payload?.nonce || '';
480
- logOk('Challenge received');
481
-
482
- const signedAtMs = Date.now();
483
- const role = 'operator';
484
- const scopes = ['operator.read', 'operator.write'];
485
-
486
- // Build structured payload per OpenClaw device-auth protocol (v2)
487
- // Format: v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
488
- const payload = [
489
- 'v2',
490
- deviceId,
491
- 'node-host',
492
- 'node',
493
- role,
494
- scopes.join(','),
495
- String(signedAtMs),
496
- '', // token (empty for initial connect)
497
- nonce,
498
- ].join('|');
499
-
500
- logDebug('Auth payload:', payload);
501
-
502
- const payloadBytes = new TextEncoder().encode(payload);
503
- const signatureB64 = await signRawBytes(privateKey, payloadBytes);
504
- // Convert to base64url (no padding)
505
- const signatureB64Url = signatureB64
506
- .replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
507
-
508
- connectId = crypto.randomUUID();
509
- ws.send(JSON.stringify({
510
- type: 'req',
511
- id: connectId,
512
- method: 'connect',
513
- params: {
514
- role,
515
- minProtocol: 3,
516
- maxProtocol: 3,
517
- scopes,
518
- client: {
519
- id: 'node-host',
520
- version: VERSION,
521
- platform: process.platform,
522
- mode: 'node',
523
- },
524
- device: {
525
- id: deviceId,
526
- publicKey: pubKeyB64Url,
527
- signature: signatureB64Url,
528
- signedAt: signedAtMs,
529
- nonce,
530
- },
531
- },
532
- }));
533
- return;
534
- }
529
+ // Device needs pairing approval — poll reconnect until approved
530
+ logOk('Pairing request created');
531
+ console.log();
532
+ logInfo('Waiting for gateway owner approval...');
533
+ log(dim(' Approve on gateway: openclaw devices approve'));
534
+ log(dim(` Timeout: ${options.timeout}s`));
535
+ console.log();
535
536
 
536
- // Connect response
537
- if (frame.type === 'res' && frame.id === connectId) {
538
- if (frame.ok) {
539
- logOk('Connected to gateway');
540
-
541
- pairId = crypto.randomUUID();
542
- ws.send(JSON.stringify({
543
- type: 'req',
544
- id: pairId,
545
- method: 'node.pair.request',
546
- params: {
547
- displayName: `NervePay (${agentDid.split(':').pop().slice(0, 8)})`,
548
- platform: process.platform,
549
- },
550
- }));
551
- } else {
552
- lastError = formatFrameError('Connect rejected', frame);
553
- logErr(lastError);
554
- ws.close();
555
- settle(reject, new Error(lastError));
556
- }
557
- return;
558
- }
537
+ const pollInterval = 5000;
538
+ const maxAttempts = Math.ceil(timeoutMs / pollInterval);
539
+ let approved = false;
559
540
 
560
- // Pair response
561
- if (frame.type === 'res' && frame.id === pairId) {
562
- if (frame.ok) {
563
- requestId = frame.payload?.requestId || '';
564
- logOk('Pairing request submitted');
565
- console.log();
566
- logInfo('Waiting for gateway owner approval...');
567
- log(dim(`Approve: openclaw devices approve ${requestId}`));
568
- log(dim(`Timeout: ${options.timeout}s`));
569
- console.log();
570
- } else {
571
- lastError = formatFrameError('Pair rejected', frame);
572
- logErr(lastError);
573
- ws.close();
574
- settle(reject, new Error(lastError));
575
- }
576
- return;
577
- }
541
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
542
+ await new Promise(r => setTimeout(r, pollInterval));
543
+ process.stdout.write(dim('.'));
578
544
 
579
- // Pair resolved
580
- if (frame.event === 'node.pair.resolved') {
581
- const rid = frame.payload?.requestId;
582
- if (rid && rid !== requestId) return;
583
-
584
- const decision = frame.payload?.decision;
585
- if (decision === 'approved') {
586
- ws.close();
587
- settle(resolve, {
588
- token: frame.payload?.token || '',
589
- nodeId: frame.payload?.nodeId || '',
590
- requestId,
591
- });
592
- } else {
593
- ws.close();
594
- settle(reject, new Error(decision === 'rejected'
595
- ? 'Pairing rejected by gateway owner'
596
- : `Unexpected decision: ${decision}`));
597
- }
598
- return;
545
+ try {
546
+ const result = await attemptConnect(WebSocket, wsUrl, ctx);
547
+ deviceToken = result.token;
548
+ approved = true;
549
+ console.log();
550
+ break;
551
+ } catch (retryErr) {
552
+ if (retryErr.message !== 'NOT_PAIRED') {
553
+ console.log();
554
+ throw retryErr;
599
555
  }
556
+ }
557
+ }
558
+ if (!approved) die(`Approval timed out after ${options.timeout}s`);
559
+ }
600
560
 
601
- // Unhandled
602
- logDebug('Unhandled frame:', JSON.stringify(frame).slice(0, 200));
603
- } catch { /* non-JSON */ }
604
- });
605
- });
606
-
607
- logOk(bold('Approved!'));
561
+ logOk(bold('Paired!'));
608
562
  console.log();
609
- logInfo('Saving to NervePay...');
610
563
 
611
- const client = new NervePayClient({
612
- apiUrl: options.apiUrl || apiUrl,
613
- agentDid,
614
- privateKey,
615
- });
564
+ if (deviceToken) {
565
+ logInfo('Saving to NervePay...');
566
+ const client = new NervePayClient({
567
+ apiUrl: options.apiUrl || apiUrl,
568
+ agentDid,
569
+ privateKey,
570
+ });
616
571
 
617
- const apiResult = await gateway.completeDevicePairing(client, {
618
- device_token: result.token,
619
- device_id: deviceId,
620
- gateway_url: gatewayUrl,
621
- gateway_name: options.name,
622
- });
572
+ const apiResult = await gateway.completeDevicePairing(client, {
573
+ device_token: deviceToken,
574
+ device_id: deviceId,
575
+ gateway_url: gatewayUrl,
576
+ gateway_name: options.name,
577
+ });
623
578
 
624
- divider();
625
- logOk(bold('Gateway paired'));
626
- field(' Gateway ID', apiResult.gateway_id);
627
- field(' Device ID', deviceId.slice(0, 16) + '...');
628
- field(' Agent DID', agentDid);
579
+ divider();
580
+ logOk(bold('Gateway paired'));
581
+ field(' Gateway ID', apiResult.gateway_id);
582
+ field(' Device ID', deviceId.slice(0, 16) + '...');
583
+ field(' Agent DID', agentDid);
584
+ } else {
585
+ divider();
586
+ logOk(bold('Device approved by gateway'));
587
+ field(' Device ID', deviceId.slice(0, 16) + '...');
588
+ field(' Agent DID', agentDid);
589
+ log(warn(' No device token received — gateway may not issue tokens for this role'));
590
+ }
629
591
  console.log();
630
592
  log(dim('View in dashboard:'), info('Mission Control > Task Board'));
631
593
  console.log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nervepay",
3
- "version": "1.3.6",
3
+ "version": "1.3.9",
4
4
  "description": "NervePay plugin for OpenClaw - Self-sovereign identity, vault, and orchestration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",