nervepay 1.3.7 → 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 +137 -182
  2. package/package.json +1 -1
@@ -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
  // ---------------------------------------------------------------------------
@@ -436,203 +510,84 @@ async function deviceNodePairing(options) {
436
510
  logInfo('Connecting...');
437
511
 
438
512
  const WebSocket = (await import('ws')).default;
439
- const ws = new WebSocket(wsUrl, { rejectUnauthorized: false });
440
- const timeoutMs = parseInt(options.timeout, 10) * 1000;
441
-
442
- // Pre-compute crypto outside WS handler to avoid async race conditions
443
513
  const pubKeyBytes = decodeBase58(publicKeyBase58);
444
- // OpenClaw expects base64url encoding (no padding) for public key
445
514
  const pubKeyB64Url = Buffer.from(pubKeyBytes).toString('base64')
446
515
  .replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
447
516
 
448
- const result = await new Promise((resolve, reject) => {
449
- let settled = false;
450
- let connectId = null;
451
- let pairId = null;
452
- let requestId = null;
453
- let lastError = null;
454
-
455
- function settle(fn, value) {
456
- if (settled) return;
457
- settled = true;
458
- clearTimeout(timer);
459
- fn(value);
460
- }
461
-
462
- const timer = setTimeout(() => {
463
- ws.close();
464
- settle(reject, new Error(`Approval timed out after ${options.timeout}s`));
465
- }, timeoutMs + 15000);
466
-
467
- 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;
468
519
 
469
- ws.on('close', (code, reason) => {
470
- setTimeout(() => {
471
- const detail = lastError || (reason?.toString() ? `${reason}` : `code ${code}`);
472
- settle(reject, new Error(detail));
473
- }, 150);
474
- });
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;
475
528
 
476
- ws.on('message', async (data) => {
477
- try {
478
- const frame = JSON.parse(data.toString());
479
- logDebug('<<', JSON.stringify(frame).slice(0, 300));
480
-
481
- // Challenge → build auth payload, sign, and connect
482
- if (frame.event === 'connect.challenge') {
483
- const nonce = frame.payload?.nonce || '';
484
- logOk('Challenge received');
485
-
486
- const signedAtMs = Date.now();
487
- const role = 'operator';
488
- const scopes = ['operator.read', 'operator.write'];
489
-
490
- // Build structured payload per OpenClaw device-auth protocol (v2)
491
- // Format: v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
492
- const payload = [
493
- 'v2',
494
- deviceId,
495
- 'node-host',
496
- 'node',
497
- role,
498
- scopes.join(','),
499
- String(signedAtMs),
500
- gatewayToken,
501
- nonce,
502
- ].join('|');
503
-
504
- logDebug('Auth payload:', payload);
505
-
506
- const payloadBytes = new TextEncoder().encode(payload);
507
- const signatureB64 = await signRawBytes(privateKey, payloadBytes);
508
- // Convert to base64url (no padding)
509
- const signatureB64Url = signatureB64
510
- .replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
511
-
512
- connectId = crypto.randomUUID();
513
- ws.send(JSON.stringify({
514
- type: 'req',
515
- id: connectId,
516
- method: 'connect',
517
- params: {
518
- role,
519
- minProtocol: 3,
520
- maxProtocol: 3,
521
- scopes,
522
- client: {
523
- id: 'node-host',
524
- version: VERSION,
525
- platform: process.platform,
526
- mode: 'node',
527
- },
528
- device: {
529
- id: deviceId,
530
- publicKey: pubKeyB64Url,
531
- signature: signatureB64Url,
532
- signedAt: signedAtMs,
533
- nonce,
534
- },
535
- auth: {
536
- token: gatewayToken,
537
- },
538
- },
539
- }));
540
- return;
541
- }
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();
542
536
 
543
- // Connect response
544
- if (frame.type === 'res' && frame.id === connectId) {
545
- if (frame.ok) {
546
- logOk('Connected to gateway');
547
-
548
- pairId = crypto.randomUUID();
549
- ws.send(JSON.stringify({
550
- type: 'req',
551
- id: pairId,
552
- method: 'node.pair.request',
553
- params: {
554
- displayName: `NervePay (${agentDid.split(':').pop().slice(0, 8)})`,
555
- platform: process.platform,
556
- },
557
- }));
558
- } else {
559
- lastError = formatFrameError('Connect rejected', frame);
560
- logErr(lastError);
561
- ws.close();
562
- settle(reject, new Error(lastError));
563
- }
564
- return;
565
- }
537
+ const pollInterval = 5000;
538
+ const maxAttempts = Math.ceil(timeoutMs / pollInterval);
539
+ let approved = false;
566
540
 
567
- // Pair response
568
- if (frame.type === 'res' && frame.id === pairId) {
569
- if (frame.ok) {
570
- requestId = frame.payload?.requestId || '';
571
- logOk('Pairing request submitted');
572
- console.log();
573
- logInfo('Waiting for gateway owner approval...');
574
- log(dim(`Approve: openclaw devices approve ${requestId}`));
575
- log(dim(`Timeout: ${options.timeout}s`));
576
- console.log();
577
- } else {
578
- lastError = formatFrameError('Pair rejected', frame);
579
- logErr(lastError);
580
- ws.close();
581
- settle(reject, new Error(lastError));
582
- }
583
- return;
584
- }
541
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
542
+ await new Promise(r => setTimeout(r, pollInterval));
543
+ process.stdout.write(dim('.'));
585
544
 
586
- // Pair resolved
587
- if (frame.event === 'node.pair.resolved') {
588
- const rid = frame.payload?.requestId;
589
- if (rid && rid !== requestId) return;
590
-
591
- const decision = frame.payload?.decision;
592
- if (decision === 'approved') {
593
- ws.close();
594
- settle(resolve, {
595
- token: frame.payload?.token || '',
596
- nodeId: frame.payload?.nodeId || '',
597
- requestId,
598
- });
599
- } else {
600
- ws.close();
601
- settle(reject, new Error(decision === 'rejected'
602
- ? 'Pairing rejected by gateway owner'
603
- : `Unexpected decision: ${decision}`));
604
- }
605
- 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;
606
555
  }
556
+ }
557
+ }
558
+ if (!approved) die(`Approval timed out after ${options.timeout}s`);
559
+ }
607
560
 
608
- // Unhandled
609
- logDebug('Unhandled frame:', JSON.stringify(frame).slice(0, 200));
610
- } catch { /* non-JSON */ }
611
- });
612
- });
613
-
614
- logOk(bold('Approved!'));
561
+ logOk(bold('Paired!'));
615
562
  console.log();
616
- logInfo('Saving to NervePay...');
617
563
 
618
- const client = new NervePayClient({
619
- apiUrl: options.apiUrl || apiUrl,
620
- agentDid,
621
- privateKey,
622
- });
564
+ if (deviceToken) {
565
+ logInfo('Saving to NervePay...');
566
+ const client = new NervePayClient({
567
+ apiUrl: options.apiUrl || apiUrl,
568
+ agentDid,
569
+ privateKey,
570
+ });
623
571
 
624
- const apiResult = await gateway.completeDevicePairing(client, {
625
- device_token: result.token,
626
- device_id: deviceId,
627
- gateway_url: gatewayUrl,
628
- gateway_name: options.name,
629
- });
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
+ });
630
578
 
631
- divider();
632
- logOk(bold('Gateway paired'));
633
- field(' Gateway ID', apiResult.gateway_id);
634
- field(' Device ID', deviceId.slice(0, 16) + '...');
635
- 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
+ }
636
591
  console.log();
637
592
  log(dim('View in dashboard:'), info('Mission Control > Task Board'));
638
593
  console.log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nervepay",
3
- "version": "1.3.7",
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",