@emkaylabs/realnode-sdk 1.1.5
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/README.md +132 -0
- package/package.json +64 -0
- package/src/browser/hanko.js +114 -0
- package/src/browser/storage.js +74 -0
- package/src/core/RealNodeClient.js +299 -0
- package/src/core/constants.js +26 -0
- package/src/index.js +7 -0
- package/src/react/index.jsx +423 -0
- package/types/index.d.ts +134 -0
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# @emkaylabs/realnode-sdk
|
|
2
|
+
|
|
3
|
+
RealNode Anti-Scalper & Bot Protection SDK.
|
|
4
|
+
Hardware-backed bot protection and fraud prevention for high-demand environments.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
The RealNode SDK provides an isomorphic, SSR-safe integration for the RealNode Security Protocol. It is designed specifically for ticketing platforms, flash sales, and high-demand e-commerce environments where preventing automated hoarding is critical.
|
|
11
|
+
|
|
12
|
+
The SDK allows modern web platforms (Next.js, React, Vue, Vanilla JS) to integrate hardware-attested biometric security (such as WebAuthn, FIDO2, and security keys) into transaction flows, establishing proof of human presence.
|
|
13
|
+
|
|
14
|
+
## Live Sandbox
|
|
15
|
+
|
|
16
|
+
A sandbox environment is available for testing: [https://realnode.emkaylabs.tech/sandbox](https://realnode.emkaylabs.tech/sandbox)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Install the package via your preferred package manager:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install @emkaylabs/realnode-sdk
|
|
26
|
+
# or
|
|
27
|
+
yarn add @emkaylabs/realnode-sdk
|
|
28
|
+
# or
|
|
29
|
+
pnpm add @emkaylabs/realnode-sdk
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage (React / Next.js)
|
|
33
|
+
|
|
34
|
+
The SDK exports fully typed React hooks and components that are strictly Server-Side Rendering (SSR) safe.
|
|
35
|
+
|
|
36
|
+
### 1. Setup the Provider
|
|
37
|
+
|
|
38
|
+
Wrap your application or checkout flow with the `RealNodeProvider`:
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { RealNodeProvider } from '@emkaylabs/realnode-sdk/react';
|
|
42
|
+
|
|
43
|
+
export default function App({ children }) {
|
|
44
|
+
return (
|
|
45
|
+
<RealNodeProvider
|
|
46
|
+
apiKey="pk_live_YOUR_PUBLIC_KEY"
|
|
47
|
+
plan="sentinel"
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
</RealNodeProvider>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 2. Implement the Checkout Component
|
|
56
|
+
|
|
57
|
+
Integrate the secure checkout button within your transaction flow:
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { RealNodeCheckoutButton } from '@emkaylabs/realnode-sdk/react';
|
|
61
|
+
|
|
62
|
+
export default function CheckoutPage() {
|
|
63
|
+
const handleSuccess = (proof) => {
|
|
64
|
+
// Transmit the cryptographic proof to your backend for validation
|
|
65
|
+
fetch('/api/process-payment', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
body: JSON.stringify(proof)
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div>
|
|
73
|
+
<h2>Complete your purchase</h2>
|
|
74
|
+
<RealNodeCheckoutButton quantity={2} onSuccess={handleSuccess}>
|
|
75
|
+
Confirm Order
|
|
76
|
+
</RealNodeCheckoutButton>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 3. Account Device Management (Optional)
|
|
83
|
+
|
|
84
|
+
Provide users with the ability to manage their authorized hardware devices, revoke access, or transfer ownership from their profile page:
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
import { RealNodeDeviceManager } from '@emkaylabs/realnode-sdk/react';
|
|
88
|
+
|
|
89
|
+
export default function UserProfile() {
|
|
90
|
+
return (
|
|
91
|
+
<RealNodeDeviceManager
|
|
92
|
+
apiKey="pk_live_YOUR_PUBLIC_KEY"
|
|
93
|
+
plan="sentinel"
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Usage (Vanilla JS / Node.js)
|
|
100
|
+
|
|
101
|
+
For non-React environments or edge functions, the core client can be imported directly. The core module has zero DOM dependencies and is environment-agnostic.
|
|
102
|
+
|
|
103
|
+
```javascript
|
|
104
|
+
import { RealNodeClient } from '@emkaylabs/realnode-sdk';
|
|
105
|
+
|
|
106
|
+
const client = new RealNodeClient({
|
|
107
|
+
apiKey: 'pk_live_YOUR_PUBLIC_KEY',
|
|
108
|
+
plan: 'insight'
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Passive validation
|
|
112
|
+
const result = await client.verify();
|
|
113
|
+
console.log('Validation Status:', result.status);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Technical Architecture
|
|
117
|
+
|
|
118
|
+
- **SSR Compatibility:** The core client and components contain strict guards against `window`, `document`, and `localStorage` invocation during server-side execution.
|
|
119
|
+
- **Fail-Open Strategy:** In the event of network timeouts or service unavailability, the SDK is designed to gracefully degrade, ensuring transaction continuity.
|
|
120
|
+
- **Type Definitions:** The package includes comprehensive TypeScript definitions (`.d.ts`) for strict typing and IDE autocomplete support.
|
|
121
|
+
|
|
122
|
+
## Support & Enterprise Contact
|
|
123
|
+
|
|
124
|
+
For integration assistance and enterprise deployments, please contact our technical team.
|
|
125
|
+
|
|
126
|
+
- **B2B Inquiries & Enterprise Architecture:** [realnode@emkaylabs.tech](mailto:realnode@emkaylabs.tech)
|
|
127
|
+
- **Security & Developer Support:** [security@emkaylabs.tech](mailto:security@emkaylabs.tech)
|
|
128
|
+
- **Documentation:** [https://realnode.emkaylabs.tech/blog](https://realnode.emkaylabs.tech/blog)
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT © EMKAY LABS
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@emkaylabs/realnode-sdk",
|
|
3
|
+
"version": "1.1.5",
|
|
4
|
+
"description": "RealNode Anti-Scalper SDK — Hardware-backed bot protection and fraud prevention using FIDO2/WebAuthn",
|
|
5
|
+
"author": "EMKAY LABS <security@emkaylabs.tech>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://realnode.emkaylabs.tech",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/Emkay-Labs/realnode-client-sdk.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"email": "security@emkaylabs.tech"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"anti-bot",
|
|
17
|
+
"fraud-prevention",
|
|
18
|
+
"anti-scalper",
|
|
19
|
+
"webauthn",
|
|
20
|
+
"fido2",
|
|
21
|
+
"passkey",
|
|
22
|
+
"checkout-security",
|
|
23
|
+
"bot-protection",
|
|
24
|
+
"woocommerce",
|
|
25
|
+
"nextjs",
|
|
26
|
+
"react"
|
|
27
|
+
],
|
|
28
|
+
"main": "src/index.js",
|
|
29
|
+
"module": "src/index.js",
|
|
30
|
+
"types": "types/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"import": "./src/index.js",
|
|
34
|
+
"require": "./src/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./react": {
|
|
37
|
+
"import": "./src/react/index.jsx"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src",
|
|
42
|
+
"types",
|
|
43
|
+
"README.md"
|
|
44
|
+
],
|
|
45
|
+
"sideEffects": false,
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": ">=17.0.0",
|
|
48
|
+
"react-dom": ">=17.0.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"react": { "optional": true },
|
|
52
|
+
"react-dom": { "optional": true }
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"react": "^18.0.0",
|
|
56
|
+
"react-dom": "^18.0.0"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=16.0.0"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @realnode/sdk — Hanko Browser Integration
|
|
3
|
+
* Contains ALL DOM interactions. Never imported server-side.
|
|
4
|
+
* Called only after isBrowser guard passes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isBrowser } from './storage.js';
|
|
8
|
+
|
|
9
|
+
const HANKO_ELEMENTS_URL = 'https://api.emkaylabs.tech/sdk/elements.js';
|
|
10
|
+
|
|
11
|
+
let _hankoRegisterPromise = null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load and register the Hanko Web Component.
|
|
15
|
+
* Cached so it only registers once per page.
|
|
16
|
+
* @param {string} proxyUrl
|
|
17
|
+
* @returns {Promise<void>}
|
|
18
|
+
*/
|
|
19
|
+
export async function registerHanko(proxyUrl) {
|
|
20
|
+
if (!isBrowser) return;
|
|
21
|
+
if (_hankoRegisterPromise) return _hankoRegisterPromise;
|
|
22
|
+
|
|
23
|
+
_hankoRegisterPromise = (async () => {
|
|
24
|
+
const { register } = await import(HANKO_ELEMENTS_URL);
|
|
25
|
+
const timeout = new Promise((_, reject) =>
|
|
26
|
+
setTimeout(() => reject(new Error('[RealNode] Hanko load timeout')), 30000)
|
|
27
|
+
);
|
|
28
|
+
await Promise.race([
|
|
29
|
+
register(proxyUrl, { methods: ['passkey', 'password'] }),
|
|
30
|
+
timeout,
|
|
31
|
+
]);
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
return _hankoRegisterPromise;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reset Hanko registration (used when re-initializing the modal).
|
|
39
|
+
*/
|
|
40
|
+
export function resetHankoRegistration() {
|
|
41
|
+
_hankoRegisterPromise = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Inject RealNode styles into a Hanko element's Shadow DOM.
|
|
46
|
+
* @param {HTMLElement} hankoElement
|
|
47
|
+
*/
|
|
48
|
+
export function injectHankoStyles(hankoElement) {
|
|
49
|
+
if (!isBrowser || !hankoElement) return;
|
|
50
|
+
|
|
51
|
+
const tryInject = () => {
|
|
52
|
+
if (!hankoElement.shadowRoot) {
|
|
53
|
+
setTimeout(tryInject, 100);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const style = document.createElement('style');
|
|
57
|
+
style.textContent = `
|
|
58
|
+
.hg-btn-processing {
|
|
59
|
+
pointer-events: none !important;
|
|
60
|
+
opacity: 0.6 !important;
|
|
61
|
+
filter: grayscale(0.5) !important;
|
|
62
|
+
cursor: wait !important;
|
|
63
|
+
}
|
|
64
|
+
button:active { transform: scale(0.98); }
|
|
65
|
+
@keyframes hg-quota-flash {
|
|
66
|
+
0% { box-shadow: 0 0 0 0 rgba(0, 255, 136, 0.4); }
|
|
67
|
+
50% { box-shadow: 0 0 20px 10px rgba(0, 255, 136, 0.6); }
|
|
68
|
+
100% { box-shadow: 0 0 0 0 rgba(0, 255, 136, 0); }
|
|
69
|
+
}
|
|
70
|
+
.hg-quota-restored {
|
|
71
|
+
animation: hg-quota-flash 0.8s ease-out 2 !important;
|
|
72
|
+
border-color: #00ff88 !important;
|
|
73
|
+
color: #fff !important;
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
hankoElement.shadowRoot.appendChild(style);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
tryInject();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Attach the auth-flow listener to a Hanko element.
|
|
84
|
+
* @param {HTMLElement} hankoElement
|
|
85
|
+
* @param {function} onAuthFlowCompleted
|
|
86
|
+
*/
|
|
87
|
+
export function attachAuthListener(hankoElement, onAuthFlowCompleted) {
|
|
88
|
+
if (!isBrowser || !hankoElement || hankoElement._rnListenerAttached) return;
|
|
89
|
+
hankoElement.addEventListener('onAuthFlowCompleted', onAuthFlowCompleted);
|
|
90
|
+
hankoElement.addEventListener('hanko-auth-flow-completed', onAuthFlowCompleted);
|
|
91
|
+
hankoElement._rnListenerAttached = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Remove the auth-flow listener from a Hanko element (cleanup).
|
|
96
|
+
* @param {HTMLElement} hankoElement
|
|
97
|
+
* @param {function} onAuthFlowCompleted
|
|
98
|
+
*/
|
|
99
|
+
export function detachAuthListener(hankoElement, onAuthFlowCompleted) {
|
|
100
|
+
if (!isBrowser || !hankoElement) return;
|
|
101
|
+
hankoElement.removeEventListener('onAuthFlowCompleted', onAuthFlowCompleted);
|
|
102
|
+
hankoElement.removeEventListener('hanko-auth-flow-completed', onAuthFlowCompleted);
|
|
103
|
+
hankoElement._rnListenerAttached = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Dispatch a custom event on window (browser only).
|
|
108
|
+
* @param {string} eventName
|
|
109
|
+
* @param {object} detail
|
|
110
|
+
*/
|
|
111
|
+
export function dispatchRealNodeEvent(eventName, detail) {
|
|
112
|
+
if (!isBrowser) return;
|
|
113
|
+
window.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true }));
|
|
114
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @realnode/sdk — Browser Storage Utilities
|
|
3
|
+
* All DOM/browser APIs encapsulated with SSR guards.
|
|
4
|
+
* Import this module ONLY from browser-side code.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** True only when running in a real browser environment */
|
|
8
|
+
export const isBrowser = typeof window !== 'undefined';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// localStorage
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export function getFromStorage(key) {
|
|
15
|
+
if (!isBrowser) return null;
|
|
16
|
+
try { return localStorage.getItem(key); } catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setInStorage(key, value) {
|
|
20
|
+
if (!isBrowser) return;
|
|
21
|
+
try { localStorage.setItem(key, value); } catch { /* quota exceeded — silent */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function removeFromStorage(key) {
|
|
25
|
+
if (!isBrowser) return;
|
|
26
|
+
try { localStorage.removeItem(key); } catch { /* silent */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Cookies
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export function getCookie(name) {
|
|
34
|
+
if (!isBrowser) return null;
|
|
35
|
+
try {
|
|
36
|
+
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
|
|
37
|
+
return match ? decodeURIComponent(match[2]) : null;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function clearAllCookies() {
|
|
42
|
+
if (!isBrowser) return;
|
|
43
|
+
try {
|
|
44
|
+
document.cookie.split(';').forEach((c) => {
|
|
45
|
+
document.cookie = c.replace(/^ +/, '').replace(/=.*/, `=;expires=${new Date().toUTCString()};path=/`);
|
|
46
|
+
});
|
|
47
|
+
} catch { /* silent */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// sessionStorage
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export function getFromSession(key) {
|
|
55
|
+
if (!isBrowser) return null;
|
|
56
|
+
try { return sessionStorage.getItem(key); } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function clearSession() {
|
|
60
|
+
if (!isBrowser) return;
|
|
61
|
+
try { sessionStorage.clear(); } catch { /* silent */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Device Token
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export function getDeviceToken() {
|
|
69
|
+
return getFromStorage('rn_device_token');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function setDeviceToken(token) {
|
|
73
|
+
setInStorage('rn_device_token', token);
|
|
74
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @realnode/sdk — Core API Client
|
|
3
|
+
*
|
|
4
|
+
* This module contains ONLY business logic and HTTP calls.
|
|
5
|
+
* It has ZERO references to window, document, localStorage, or navigator.
|
|
6
|
+
* It can be safely imported in Node.js, Next.js SSR, and Vercel Edge Functions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { REALNODE_API_BASE, TIMEOUTS } from './constants.js';
|
|
10
|
+
|
|
11
|
+
export class RealNodeClient {
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} config
|
|
14
|
+
* @param {string} config.apiKey — Public key (pk_live_...)
|
|
15
|
+
* @param {'insight'|'sentinel'|'vault'} [config.plan]
|
|
16
|
+
* @param {string} [config.eventId] — Required for Vault plan
|
|
17
|
+
* @param {string} [config.apiBase] — Override API base URL
|
|
18
|
+
* @param {string} [config.clientId] — Identifier for your store
|
|
19
|
+
* @param {function} [config.onSuccess]
|
|
20
|
+
* @param {function} [config.onError]
|
|
21
|
+
* @param {function} [config.onStatusChange]
|
|
22
|
+
*/
|
|
23
|
+
constructor(config = {}) {
|
|
24
|
+
this.apiKey = config.apiKey || '';
|
|
25
|
+
this.apiBase = config.apiBase || REALNODE_API_BASE;
|
|
26
|
+
this.plan = config.plan || 'insight';
|
|
27
|
+
this.eventId = config.eventId || '';
|
|
28
|
+
this.clientId = config.clientId || 'realnode-sdk-client';
|
|
29
|
+
|
|
30
|
+
// State
|
|
31
|
+
this.currentIdh = null;
|
|
32
|
+
this.currentDeviceHash = null;
|
|
33
|
+
this.currentDeviceToken = null;
|
|
34
|
+
this._lastKnownRemaining = null;
|
|
35
|
+
this._syncIntervalId = null;
|
|
36
|
+
this._destroyed = false;
|
|
37
|
+
|
|
38
|
+
// Callbacks
|
|
39
|
+
this.onSuccess = config.onSuccess || (() => {});
|
|
40
|
+
this.onError = config.onError || (() => {});
|
|
41
|
+
this.onStatusChange = config.onStatusChange || (() => {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// -------------------------------------------------------------------------
|
|
45
|
+
// HTTP Helpers
|
|
46
|
+
// -------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
_buildHeaders(includeSecret = false) {
|
|
49
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
50
|
+
if (this.apiKey) headers['X-RealNode-API-Key'] = this.apiKey;
|
|
51
|
+
return headers;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async _fetchWithTimeout(url, options = {}, timeoutMs = TIMEOUTS.API_CALL) {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const id = setTimeout(() => controller.abort(), timeoutMs);
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(url, { ...options, signal: controller.signal });
|
|
59
|
+
clearTimeout(id);
|
|
60
|
+
return res;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
clearTimeout(id);
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
// Session Management
|
|
69
|
+
// -------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Attempts to restore an existing session from the server.
|
|
73
|
+
* Safe to call on SSR — returns false immediately if no apiKey.
|
|
74
|
+
* @returns {Promise<boolean>}
|
|
75
|
+
*/
|
|
76
|
+
async restoreSession() {
|
|
77
|
+
if (!this.apiKey) return false;
|
|
78
|
+
try {
|
|
79
|
+
const res = await this._fetchWithTimeout(
|
|
80
|
+
`${this.apiBase}/check-session`,
|
|
81
|
+
{ headers: this._buildHeaders() }
|
|
82
|
+
);
|
|
83
|
+
if (!res || !res.ok) return false;
|
|
84
|
+
const data = await res.json();
|
|
85
|
+
if (!data || (data.status !== 'restored' && data.status !== 'authorized')) return false;
|
|
86
|
+
|
|
87
|
+
this.currentIdh = String(data.idh || '');
|
|
88
|
+
this.currentDeviceHash = String(data.device_hash || '');
|
|
89
|
+
this._lastKnownRemaining = Number(data.remaining ?? 0);
|
|
90
|
+
|
|
91
|
+
this.onStatusChange('[SUCCESS] PROFILE_ACTIVE');
|
|
92
|
+
this.onSuccess({
|
|
93
|
+
status: String(data.status),
|
|
94
|
+
idh: this.currentIdh,
|
|
95
|
+
device_hash: this.currentDeviceHash,
|
|
96
|
+
remaining: this._lastKnownRemaining,
|
|
97
|
+
max_limit: Number(data.max_limit ?? 0),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
this._startSync();
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false; // Network error — fail gracefully
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Passive verification (Insight plan). Does not block or show a modal.
|
|
109
|
+
* @returns {Promise<object|null>}
|
|
110
|
+
*/
|
|
111
|
+
async verify() {
|
|
112
|
+
if (!this.apiKey) return null;
|
|
113
|
+
try {
|
|
114
|
+
const res = await this._fetchWithTimeout(
|
|
115
|
+
`${this.apiBase}/verify`,
|
|
116
|
+
{
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: this._buildHeaders(),
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
idh: this.currentIdh,
|
|
121
|
+
client_id: this.clientId,
|
|
122
|
+
event_id: this.eventId || undefined,
|
|
123
|
+
}),
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
if (!res || !res.ok) return null;
|
|
127
|
+
const data = await res.json();
|
|
128
|
+
if (data.idh) this.currentIdh = data.idh;
|
|
129
|
+
if (data.device_hash) this.currentDeviceHash = data.device_hash;
|
|
130
|
+
return data;
|
|
131
|
+
} catch {
|
|
132
|
+
return null; // Fail-open
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Consume a quota slot (Sentinel / Vault). Call this after biometric success.
|
|
138
|
+
* @param {object} proof — The result from the biometric modal
|
|
139
|
+
* @param {number} [quantity=1]
|
|
140
|
+
* @returns {Promise<object|null>}
|
|
141
|
+
*/
|
|
142
|
+
async consume(proof = {}, quantity = 1) {
|
|
143
|
+
if (!this.apiKey) return null;
|
|
144
|
+
try {
|
|
145
|
+
const payload = {
|
|
146
|
+
quantity,
|
|
147
|
+
idh: proof.idh || this.currentIdh,
|
|
148
|
+
device_hash: proof.device_hash || this.currentDeviceHash,
|
|
149
|
+
device_token: proof.device_token || this.currentDeviceToken,
|
|
150
|
+
client_id: this.clientId,
|
|
151
|
+
};
|
|
152
|
+
if (this.eventId) payload.event_id = this.eventId;
|
|
153
|
+
|
|
154
|
+
const res = await this._fetchWithTimeout(
|
|
155
|
+
`${this.apiBase}/consume`,
|
|
156
|
+
{
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: this._buildHeaders(),
|
|
159
|
+
body: JSON.stringify(payload),
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
if (!res || !res.ok) return null;
|
|
163
|
+
const data = await res.json();
|
|
164
|
+
if (data.remaining !== undefined) this._lastKnownRemaining = data.remaining;
|
|
165
|
+
return data;
|
|
166
|
+
} catch {
|
|
167
|
+
return null; // Fail-open
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// -------------------------------------------------------------------------
|
|
172
|
+
// Device Management (Sentinel / Vault)
|
|
173
|
+
// -------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
async listDevices(idh) {
|
|
176
|
+
if (!this.apiKey || !idh) return { data: [] };
|
|
177
|
+
try {
|
|
178
|
+
const res = await this._fetchWithTimeout(
|
|
179
|
+
`${this.apiBase}/devices?idh=${encodeURIComponent(idh)}`,
|
|
180
|
+
{ headers: this._buildHeaders() }
|
|
181
|
+
);
|
|
182
|
+
if (!res || !res.ok) return { data: [] };
|
|
183
|
+
return await res.json();
|
|
184
|
+
} catch { return { data: [] }; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async revokeDevice(deviceHash) {
|
|
188
|
+
if (!this.apiKey) return false;
|
|
189
|
+
try {
|
|
190
|
+
const res = await this._fetchWithTimeout(
|
|
191
|
+
`${this.apiBase}/devices/revoke`,
|
|
192
|
+
{
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: this._buildHeaders(),
|
|
195
|
+
body: JSON.stringify({ device_hash: deviceHash }),
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
return res?.ok ?? false;
|
|
199
|
+
} catch { return false; }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async requestResale(idh) {
|
|
203
|
+
if (!this.apiKey) return false;
|
|
204
|
+
try {
|
|
205
|
+
const res = await this._fetchWithTimeout(
|
|
206
|
+
`${this.apiBase}/devices/resale`,
|
|
207
|
+
{
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: this._buildHeaders(),
|
|
210
|
+
body: JSON.stringify({ idh }),
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
return res?.ok ?? false;
|
|
214
|
+
} catch { return false; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async transferDevice(recipientId) {
|
|
218
|
+
if (!this.apiKey || !this.currentIdh) throw new Error('No active session to transfer.');
|
|
219
|
+
const res = await this._fetchWithTimeout(
|
|
220
|
+
`${this.apiBase}/devices/transfer`,
|
|
221
|
+
{
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: this._buildHeaders(),
|
|
224
|
+
body: JSON.stringify({ idh: this.currentIdh, recipient_id: recipientId }),
|
|
225
|
+
}
|
|
226
|
+
);
|
|
227
|
+
if (!res?.ok) throw new Error('Transfer failed.');
|
|
228
|
+
return await res.json();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async recover(code) {
|
|
232
|
+
if (!code) throw new Error('Recovery code required.');
|
|
233
|
+
const res = await this._fetchWithTimeout(
|
|
234
|
+
`${this.apiBase}/recover`,
|
|
235
|
+
{
|
|
236
|
+
method: 'POST',
|
|
237
|
+
headers: this._buildHeaders(),
|
|
238
|
+
body: JSON.stringify({ recovery_code: code }),
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
if (!res?.ok) throw new Error('Invalid recovery code.');
|
|
242
|
+
return await res.json();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async requestRescue(email) {
|
|
246
|
+
if (!email) throw new Error('Email required.');
|
|
247
|
+
const res = await this._fetchWithTimeout(
|
|
248
|
+
`${this.apiBase}/rescue`,
|
|
249
|
+
{
|
|
250
|
+
method: 'POST',
|
|
251
|
+
headers: this._buildHeaders(),
|
|
252
|
+
body: JSON.stringify({ email }),
|
|
253
|
+
}
|
|
254
|
+
);
|
|
255
|
+
if (!res?.ok) throw new Error('Rescue request failed.');
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async requestQuotaExtension() {
|
|
260
|
+
if (!this.apiKey || !this.currentIdh) return false;
|
|
261
|
+
const res = await this._fetchWithTimeout(
|
|
262
|
+
`${this.apiBase}/quota/extend`,
|
|
263
|
+
{
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: this._buildHeaders(),
|
|
266
|
+
body: JSON.stringify({ idh: this.currentIdh }),
|
|
267
|
+
}
|
|
268
|
+
);
|
|
269
|
+
return res?.ok ?? false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// -------------------------------------------------------------------------
|
|
273
|
+
// Sync (keep quota up to date)
|
|
274
|
+
// -------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
_startSync() {
|
|
277
|
+
if (this._destroyed || this._syncIntervalId) return;
|
|
278
|
+
this._syncIntervalId = setInterval(async () => {
|
|
279
|
+
if (this._destroyed) { this._stopSync(); return; }
|
|
280
|
+
await this.restoreSession();
|
|
281
|
+
}, TIMEOUTS.SESSION_POLL);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_stopSync() {
|
|
285
|
+
if (this._syncIntervalId) {
|
|
286
|
+
clearInterval(this._syncIntervalId);
|
|
287
|
+
this._syncIntervalId = null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Must be called when unmounting a React component or destroying the instance.
|
|
293
|
+
* Stops all intervals to prevent memory leaks.
|
|
294
|
+
*/
|
|
295
|
+
destroy() {
|
|
296
|
+
this._destroyed = true;
|
|
297
|
+
this._stopSync();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @realnode/sdk — Core Constants
|
|
3
|
+
* Shared across all environments (Node.js, browser, edge).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const REALNODE_API_BASE = 'https://api.emkaylabs.tech';
|
|
7
|
+
export const REALNODE_SDK_VERSION = '1.0.0';
|
|
8
|
+
|
|
9
|
+
export const PLANS = {
|
|
10
|
+
INSIGHT: 'insight',
|
|
11
|
+
SENTINEL: 'sentinel',
|
|
12
|
+
VAULT: 'vault',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const EVENTS = {
|
|
16
|
+
SENSOR_READY: 'rn:v2:sensor-ready',
|
|
17
|
+
PURCHASE_SUCCESS: 'rn-purchase-success',
|
|
18
|
+
VERIFIED: 'rn:verified',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const TIMEOUTS = {
|
|
22
|
+
API_CALL: 5000, // 5s — fail-open if RealNode unreachable
|
|
23
|
+
MODAL: 30000, // 30s — fail-open if user closes modal
|
|
24
|
+
SDK_LOAD: 10000, // 10s — SDK polling timeout
|
|
25
|
+
SESSION_POLL: 60000, // 1min — session keep-alive interval
|
|
26
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @realnode/sdk — Vanilla JS entry point
|
|
3
|
+
* For use with plain HTML, PHP, and non-React projects.
|
|
4
|
+
*/
|
|
5
|
+
export { RealNodeClient } from './core/RealNodeClient.js';
|
|
6
|
+
export { PLANS, EVENTS, TIMEOUTS, REALNODE_API_BASE } from './core/constants.js';
|
|
7
|
+
export { isBrowser, getDeviceToken, setDeviceToken } from './browser/storage.js';
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @realnode/sdk — React Hook & Components
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { useRealNode, RealNodeCheckoutButton, RealNodeDeviceManager }
|
|
6
|
+
* from '@realnode/sdk/react';
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use client'; // Next.js App Router directive — safe to include always
|
|
10
|
+
|
|
11
|
+
import { useEffect, useRef, useCallback, useState, createContext, useContext } from 'react';
|
|
12
|
+
import { RealNodeClient } from '../core/RealNodeClient.js';
|
|
13
|
+
import { isBrowser, getDeviceToken } from '../browser/storage.js';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Context
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
const RealNodeContext = createContext(null);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Provider — wrap your app or checkout layout with this.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* <RealNodeProvider apiKey="pk_live_xxx" plan="sentinel">
|
|
26
|
+
* <CheckoutPage />
|
|
27
|
+
* </RealNodeProvider>
|
|
28
|
+
*/
|
|
29
|
+
export function RealNodeProvider({ children, apiKey, plan = 'insight', eventId, clientId }) {
|
|
30
|
+
const hook = useRealNode({ apiKey, plan, eventId, clientId });
|
|
31
|
+
return (
|
|
32
|
+
<RealNodeContext.Provider value={hook}>
|
|
33
|
+
{children}
|
|
34
|
+
</RealNodeContext.Provider>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Use the RealNode context from a child component.
|
|
40
|
+
* Must be inside <RealNodeProvider>.
|
|
41
|
+
*/
|
|
42
|
+
export function useRealNodeContext() {
|
|
43
|
+
const ctx = useContext(RealNodeContext);
|
|
44
|
+
if (!ctx) throw new Error('[RealNode] useRealNodeContext must be used inside <RealNodeProvider>');
|
|
45
|
+
return ctx;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Core Hook
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* useRealNode — the main integration hook.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* const { status, quota, verify, showModal, client } = useRealNode({
|
|
57
|
+
* apiKey: 'pk_live_xxx',
|
|
58
|
+
* plan: 'sentinel',
|
|
59
|
+
* });
|
|
60
|
+
*
|
|
61
|
+
* @param {object} config
|
|
62
|
+
* @returns {{ status, quota, verify, showModal, client }}
|
|
63
|
+
*/
|
|
64
|
+
export function useRealNode({ apiKey, plan = 'insight', eventId, clientId } = {}) {
|
|
65
|
+
const clientRef = useRef(null);
|
|
66
|
+
const [status, setStatus] = useState('idle');
|
|
67
|
+
const [quota, setQuota] = useState({ remaining: null, max: null });
|
|
68
|
+
const [proof, setProof] = useState(null);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
// SSR Guard: do nothing on the server
|
|
72
|
+
if (!isBrowser) return;
|
|
73
|
+
if (!apiKey) return;
|
|
74
|
+
|
|
75
|
+
const client = new RealNodeClient({
|
|
76
|
+
apiKey,
|
|
77
|
+
plan,
|
|
78
|
+
eventId,
|
|
79
|
+
clientId,
|
|
80
|
+
onSuccess: (data) => {
|
|
81
|
+
setQuota({ remaining: data.remaining ?? null, max: data.max_limit ?? null });
|
|
82
|
+
setProof(data);
|
|
83
|
+
setStatus('authorized');
|
|
84
|
+
},
|
|
85
|
+
onError: (err) => {
|
|
86
|
+
console.warn('[RealNode]', err);
|
|
87
|
+
setStatus('error');
|
|
88
|
+
},
|
|
89
|
+
onStatusChange: (s) => setStatus(s),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
clientRef.current = client;
|
|
93
|
+
|
|
94
|
+
// Try to restore an existing session silently
|
|
95
|
+
client.restoreSession().catch(() => {});
|
|
96
|
+
|
|
97
|
+
// Cleanup: stop sync intervals when component unmounts
|
|
98
|
+
return () => {
|
|
99
|
+
client.destroy();
|
|
100
|
+
clientRef.current = null;
|
|
101
|
+
};
|
|
102
|
+
}, [apiKey, plan, eventId, clientId]);
|
|
103
|
+
|
|
104
|
+
/** Passive verification — Insight plan */
|
|
105
|
+
const verify = useCallback(async () => {
|
|
106
|
+
if (!clientRef.current) return null;
|
|
107
|
+
setStatus('verifying');
|
|
108
|
+
const result = await clientRef.current.verify();
|
|
109
|
+
if (result) {
|
|
110
|
+
setStatus('authorized');
|
|
111
|
+
setProof(result);
|
|
112
|
+
} else {
|
|
113
|
+
setStatus('idle'); // fail-open
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Trigger biometric modal (Sentinel / Vault).
|
|
120
|
+
* Resolves when the user completes or dismisses the modal.
|
|
121
|
+
* Always fail-open on timeout.
|
|
122
|
+
*/
|
|
123
|
+
const showModal = useCallback(async () => {
|
|
124
|
+
if (!clientRef.current || !isBrowser) return null;
|
|
125
|
+
setStatus('verifying');
|
|
126
|
+
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
// Listen for the verified event emitted by the Hanko element
|
|
129
|
+
const onVerified = (e) => {
|
|
130
|
+
window.removeEventListener('rn:verified', onVerified);
|
|
131
|
+
clearTimeout(fallback);
|
|
132
|
+
const result = e.detail;
|
|
133
|
+
setProof(result);
|
|
134
|
+
setStatus('authorized');
|
|
135
|
+
resolve(result);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Fail-open timeout — never block the user forever
|
|
139
|
+
const fallback = setTimeout(() => {
|
|
140
|
+
window.removeEventListener('rn:verified', onVerified);
|
|
141
|
+
setStatus('idle');
|
|
142
|
+
resolve(null);
|
|
143
|
+
}, 30000);
|
|
144
|
+
|
|
145
|
+
window.addEventListener('rn:verified', onVerified, { once: true });
|
|
146
|
+
|
|
147
|
+
// Trigger the modal
|
|
148
|
+
if (clientRef.current?.showModal) {
|
|
149
|
+
clientRef.current.showModal();
|
|
150
|
+
} else {
|
|
151
|
+
// If showModal not available (e.g. Insight plan), fallback to verify()
|
|
152
|
+
clientRef.current?.verify().then(resolve);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
status, // 'idle' | 'verifying' | 'authorized' | 'error'
|
|
159
|
+
quota, // { remaining: number|null, max: number|null }
|
|
160
|
+
proof, // Last verified result object
|
|
161
|
+
verify,
|
|
162
|
+
showModal,
|
|
163
|
+
client: clientRef.current,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ============================================================================
|
|
168
|
+
// RealNodeCheckoutButton
|
|
169
|
+
// ============================================================================
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Drop-in secure purchase button.
|
|
173
|
+
* Automatically handles biometric verification before calling onSuccess.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* // Sentinel/Vault — shows biometric modal
|
|
177
|
+
* <RealNodeCheckoutButton
|
|
178
|
+
* quantity={2}
|
|
179
|
+
* onSuccess={(proof) => sendToServer(proof)}
|
|
180
|
+
* plan="sentinel"
|
|
181
|
+
* >
|
|
182
|
+
* Buy Tickets
|
|
183
|
+
* </RealNodeCheckoutButton>
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* // Insight — passive, no modal
|
|
187
|
+
* <RealNodeCheckoutButton plan="insight" onSuccess={handleBuy}>
|
|
188
|
+
* Purchase
|
|
189
|
+
* </RealNodeCheckoutButton>
|
|
190
|
+
*/
|
|
191
|
+
export function RealNodeCheckoutButton({
|
|
192
|
+
children = 'Secure Purchase',
|
|
193
|
+
quantity = 1,
|
|
194
|
+
plan = 'insight',
|
|
195
|
+
onSuccess,
|
|
196
|
+
onDenied,
|
|
197
|
+
style = {},
|
|
198
|
+
className = '',
|
|
199
|
+
...rest
|
|
200
|
+
}) {
|
|
201
|
+
const { status, verify, showModal, proof } = useRealNode(
|
|
202
|
+
useContext(RealNodeContext) || {}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const isVerifying = status === 'verifying';
|
|
206
|
+
|
|
207
|
+
const handleClick = async (e) => {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
let result = null;
|
|
210
|
+
|
|
211
|
+
if (plan === 'insight') {
|
|
212
|
+
result = await verify();
|
|
213
|
+
} else {
|
|
214
|
+
result = await showModal();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Fail-open: if result is null (timeout/network), let it through
|
|
218
|
+
if (result === null) {
|
|
219
|
+
onSuccess?.({ status: 'fail-open', quantity });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (result.status === 'authorized' || result.status === 'restored') {
|
|
224
|
+
// Attach device token from localStorage if available
|
|
225
|
+
result.device_token = getDeviceToken() || result.device_token;
|
|
226
|
+
result.quantity = quantity;
|
|
227
|
+
onSuccess?.(result);
|
|
228
|
+
} else {
|
|
229
|
+
onDenied?.(result);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const buttonStyle = {
|
|
234
|
+
cursor: isVerifying ? 'wait' : 'pointer',
|
|
235
|
+
opacity: isVerifying ? 0.7 : 1,
|
|
236
|
+
transition: 'opacity 0.2s',
|
|
237
|
+
...style,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<button
|
|
242
|
+
onClick={handleClick}
|
|
243
|
+
disabled={isVerifying}
|
|
244
|
+
style={buttonStyle}
|
|
245
|
+
className={className}
|
|
246
|
+
aria-busy={isVerifying}
|
|
247
|
+
{...rest}
|
|
248
|
+
>
|
|
249
|
+
{isVerifying ? 'Verifying…' : children}
|
|
250
|
+
</button>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ============================================================================
|
|
255
|
+
// RealNodeQuotaBadge
|
|
256
|
+
// ============================================================================
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Displays real-time quota information for the Vault plan.
|
|
260
|
+
*
|
|
261
|
+
* @example
|
|
262
|
+
* <RealNodeQuotaBadge />
|
|
263
|
+
* // Renders: "Tickets remaining: 3 / 5"
|
|
264
|
+
*/
|
|
265
|
+
export function RealNodeQuotaBadge({ label = 'Tickets remaining', style = {} }) {
|
|
266
|
+
const { quota } = useContext(RealNodeContext) || { quota: { remaining: null, max: null } };
|
|
267
|
+
|
|
268
|
+
if (quota.remaining === null) return null;
|
|
269
|
+
|
|
270
|
+
const badgeStyle = {
|
|
271
|
+
display: 'inline-block',
|
|
272
|
+
padding: '4px 10px',
|
|
273
|
+
borderRadius: '4px',
|
|
274
|
+
fontSize: '0.9rem',
|
|
275
|
+
fontWeight: 600,
|
|
276
|
+
background: 'rgba(0, 200, 100, 0.1)',
|
|
277
|
+
color: '#15803d',
|
|
278
|
+
border: '1px solid rgba(0, 200, 100, 0.3)',
|
|
279
|
+
...style,
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<span style={badgeStyle} aria-live="polite">
|
|
284
|
+
{label}: <strong>{quota.remaining}</strong> / {quota.max}
|
|
285
|
+
</span>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ============================================================================
|
|
290
|
+
// RealNodeDeviceManager
|
|
291
|
+
// ============================================================================
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Full device management panel for "My Account" pages.
|
|
295
|
+
* Handles device listing, revocation, resale, transfer, and recovery.
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* <RealNodeDeviceManager
|
|
299
|
+
* apiKey="pk_live_xxx"
|
|
300
|
+
* plan="sentinel"
|
|
301
|
+
* />
|
|
302
|
+
*/
|
|
303
|
+
export function RealNodeDeviceManager({ apiKey, plan = 'sentinel' }) {
|
|
304
|
+
const { client, status } = useRealNode({ apiKey, plan });
|
|
305
|
+
const [devices, setDevices] = useState([]);
|
|
306
|
+
const [loading, setLoading] = useState(true);
|
|
307
|
+
const [transferId, setTransferId] = useState('');
|
|
308
|
+
const [recoveryCode, setRecoveryCode] = useState('');
|
|
309
|
+
const [rescueEmail, setRescueEmail] = useState('');
|
|
310
|
+
const [message, setMessage] = useState('');
|
|
311
|
+
|
|
312
|
+
const notify = (msg) => { setMessage(msg); setTimeout(() => setMessage(''), 4000); };
|
|
313
|
+
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
if (!client || status !== 'authorized') return;
|
|
316
|
+
client.listDevices(client.currentIdh).then((res) => {
|
|
317
|
+
setDevices(res?.data || []);
|
|
318
|
+
setLoading(false);
|
|
319
|
+
});
|
|
320
|
+
}, [client, status]);
|
|
321
|
+
|
|
322
|
+
if (plan === 'insight') return null; // Not applicable to Insight plan
|
|
323
|
+
|
|
324
|
+
const inputStyle = {
|
|
325
|
+
padding: '8px 12px', border: '1px solid #cbd5e1', borderRadius: '6px',
|
|
326
|
+
width: '100%', maxWidth: '320px', marginBottom: '8px', fontSize: '14px',
|
|
327
|
+
};
|
|
328
|
+
const btnStyle = {
|
|
329
|
+
padding: '8px 16px', borderRadius: '6px', border: '1px solid #cbd5e1',
|
|
330
|
+
background: '#f8fafc', cursor: 'pointer', fontSize: '14px', marginRight: '8px',
|
|
331
|
+
};
|
|
332
|
+
const dangerBtnStyle = {
|
|
333
|
+
...btnStyle, background: 'transparent',
|
|
334
|
+
border: '1px solid #ef4444', color: '#ef4444',
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div style={{ padding: '24px', border: '1px solid #e2e8f0', borderRadius: '8px', background: '#f8fafc' }}>
|
|
339
|
+
<h3 style={{ marginTop: 0, fontSize: '18px', color: '#0f172a' }}>RealNode Security</h3>
|
|
340
|
+
<p style={{ color: '#64748b', fontSize: '14px' }}>
|
|
341
|
+
Manage the hardware-secured devices linked to your account.
|
|
342
|
+
</p>
|
|
343
|
+
|
|
344
|
+
{message && (
|
|
345
|
+
<div style={{ padding: '8px 12px', background: '#d4edda', borderRadius: '6px', marginBottom: '12px', fontSize: '14px', color: '#155724' }}>
|
|
346
|
+
{message}
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
|
|
350
|
+
{/* Device List */}
|
|
351
|
+
<div style={{ marginBottom: '20px' }}>
|
|
352
|
+
{loading && <p style={{ color: '#64748b', fontSize: '14px' }}>Loading devices…</p>}
|
|
353
|
+
{!loading && devices.length === 0 && (
|
|
354
|
+
<p style={{ color: '#64748b', fontSize: '14px' }}>No devices registered yet.</p>
|
|
355
|
+
)}
|
|
356
|
+
{devices.map((dev) => (
|
|
357
|
+
<div key={dev.device_hash} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: '#fff', border: '1px solid #cbd5e1', borderRadius: '6px', marginBottom: '8px' }}>
|
|
358
|
+
<span style={{ fontFamily: 'monospace', fontSize: '14px' }}>
|
|
359
|
+
Device: {dev.device_hash.substring(0, 8)}…
|
|
360
|
+
</span>
|
|
361
|
+
<button
|
|
362
|
+
style={dangerBtnStyle}
|
|
363
|
+
onClick={async () => {
|
|
364
|
+
if (window.confirm('Block this device from the network?')) {
|
|
365
|
+
await client.revokeDevice(dev.device_hash);
|
|
366
|
+
setDevices((d) => d.filter((x) => x.device_hash !== dev.device_hash));
|
|
367
|
+
notify('Device blocked successfully.');
|
|
368
|
+
}
|
|
369
|
+
}}
|
|
370
|
+
>
|
|
371
|
+
Block (Stolen)
|
|
372
|
+
</button>
|
|
373
|
+
</div>
|
|
374
|
+
))}
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
{/* Resale */}
|
|
378
|
+
<button style={btnStyle} onClick={async () => {
|
|
379
|
+
if (window.confirm('Clean this device of your identity before reselling?')) {
|
|
380
|
+
await client.requestResale(client.currentIdh);
|
|
381
|
+
notify('Device freed for resale.');
|
|
382
|
+
}
|
|
383
|
+
}}>
|
|
384
|
+
Free device (For Resale)
|
|
385
|
+
</button>
|
|
386
|
+
|
|
387
|
+
<hr style={{ margin: '20px 0', border: 0, borderTop: '1px solid #e2e8f0' }} />
|
|
388
|
+
|
|
389
|
+
{/* Transfer */}
|
|
390
|
+
<h4 style={{ margin: '0 0 8px', fontSize: '15px' }}>Transfer Account</h4>
|
|
391
|
+
<input style={inputStyle} placeholder="Recipient ID" value={transferId} onChange={(e) => setTransferId(e.target.value)} />
|
|
392
|
+
<button style={btnStyle} onClick={async () => {
|
|
393
|
+
if (!transferId) return;
|
|
394
|
+
if (window.confirm('Irreversibly transfer your account to this user?')) {
|
|
395
|
+
try { await client.transferDevice(transferId); notify('Transfer successful.'); }
|
|
396
|
+
catch { notify('Transfer failed. Please check the recipient ID.'); }
|
|
397
|
+
}
|
|
398
|
+
}}>
|
|
399
|
+
Confirm Transfer
|
|
400
|
+
</button>
|
|
401
|
+
|
|
402
|
+
<hr style={{ margin: '20px 0', border: 0, borderTop: '1px solid #e2e8f0' }} />
|
|
403
|
+
|
|
404
|
+
{/* Recovery */}
|
|
405
|
+
<h4 style={{ margin: '0 0 8px', fontSize: '15px' }}>Emergency Recovery</h4>
|
|
406
|
+
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
|
407
|
+
<input style={{ ...inputStyle, marginBottom: 0 }} placeholder="Recovery Code (RN-REC-…)" value={recoveryCode} onChange={(e) => setRecoveryCode(e.target.value)} />
|
|
408
|
+
<button style={btnStyle} onClick={async () => {
|
|
409
|
+
try { await client.recover(recoveryCode); notify('Identity restored!'); }
|
|
410
|
+
catch { notify('Invalid recovery code.'); }
|
|
411
|
+
}}>Restore</button>
|
|
412
|
+
</div>
|
|
413
|
+
<p style={{ fontSize: '13px', color: '#64748b', margin: '0 0 6px' }}>Lost your code?</p>
|
|
414
|
+
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
415
|
+
<input style={{ ...inputStyle, marginBottom: 0 }} type="email" placeholder="your@email.com" value={rescueEmail} onChange={(e) => setRescueEmail(e.target.value)} />
|
|
416
|
+
<button style={btnStyle} onClick={async () => {
|
|
417
|
+
try { await client.requestRescue(rescueEmail); notify('Recovery link sent to your email.'); }
|
|
418
|
+
catch { notify('Request failed. Please check your email address.'); }
|
|
419
|
+
}}>Request Link</button>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @realnode/sdk — TypeScript Definitions
|
|
3
|
+
* Provides full autocomplete and type checking for consumers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Configuration
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export type RealNodePlan = 'insight' | 'sentinel' | 'vault';
|
|
11
|
+
|
|
12
|
+
export interface RealNodeConfig {
|
|
13
|
+
/** Public API key (starts with pk_live_ or pk_test_) */
|
|
14
|
+
apiKey: string;
|
|
15
|
+
/** Service tier. Defaults to 'insight'. */
|
|
16
|
+
plan?: RealNodePlan;
|
|
17
|
+
/** Event ID — required when plan is 'vault' */
|
|
18
|
+
eventId?: string;
|
|
19
|
+
/** Identifier for your store, used in RealNode analytics */
|
|
20
|
+
clientId?: string;
|
|
21
|
+
/** Override the RealNode API base URL (advanced) */
|
|
22
|
+
apiBase?: string;
|
|
23
|
+
onSuccess?: (result: RealNodeResult) => void;
|
|
24
|
+
onError?: (error: Error) => void;
|
|
25
|
+
onStatusChange?: (status: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Results
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export interface RealNodeResult {
|
|
33
|
+
status: 'authorized' | 'restored' | 'denied' | 'fail-open';
|
|
34
|
+
idh?: string;
|
|
35
|
+
device_hash?: string;
|
|
36
|
+
device_token?: string;
|
|
37
|
+
remaining?: number;
|
|
38
|
+
max_limit?: number;
|
|
39
|
+
quantity?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RealNodeDevice {
|
|
43
|
+
device_hash: string;
|
|
44
|
+
created_at?: string;
|
|
45
|
+
last_seen?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RealNodeQuota {
|
|
49
|
+
remaining: number | null;
|
|
50
|
+
max: number | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Core Client
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
export declare class RealNodeClient {
|
|
58
|
+
currentIdh: string | null;
|
|
59
|
+
currentDeviceHash: string | null;
|
|
60
|
+
|
|
61
|
+
constructor(config: RealNodeConfig);
|
|
62
|
+
|
|
63
|
+
restoreSession(): Promise<boolean>;
|
|
64
|
+
verify(): Promise<RealNodeResult | null>;
|
|
65
|
+
consume(proof?: Partial<RealNodeResult>, quantity?: number): Promise<RealNodeResult | null>;
|
|
66
|
+
|
|
67
|
+
listDevices(idh: string): Promise<{ data: RealNodeDevice[] }>;
|
|
68
|
+
revokeDevice(deviceHash: string): Promise<boolean>;
|
|
69
|
+
requestResale(idh: string): Promise<boolean>;
|
|
70
|
+
transferDevice(recipientId: string): Promise<object>;
|
|
71
|
+
recover(code: string): Promise<object>;
|
|
72
|
+
requestRescue(email: string): Promise<boolean>;
|
|
73
|
+
requestQuotaExtension(): Promise<boolean>;
|
|
74
|
+
|
|
75
|
+
/** Must be called when unmounting to prevent memory leaks */
|
|
76
|
+
destroy(): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// React Hook
|
|
81
|
+
// ============================================================================
|
|
82
|
+
|
|
83
|
+
export interface UseRealNodeReturn {
|
|
84
|
+
/** Current status of the verification flow */
|
|
85
|
+
status: 'idle' | 'verifying' | 'authorized' | 'error';
|
|
86
|
+
/** Real-time quota data (Vault plan) */
|
|
87
|
+
quota: RealNodeQuota;
|
|
88
|
+
/** The last successful verification proof */
|
|
89
|
+
proof: RealNodeResult | null;
|
|
90
|
+
/** Passive verification — Insight plan */
|
|
91
|
+
verify: () => Promise<RealNodeResult | null>;
|
|
92
|
+
/** Opens biometric modal — Sentinel / Vault plan */
|
|
93
|
+
showModal: () => Promise<RealNodeResult | null>;
|
|
94
|
+
/** Direct access to the underlying RealNodeClient */
|
|
95
|
+
client: RealNodeClient | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export declare function useRealNode(config: RealNodeConfig): UseRealNodeReturn;
|
|
99
|
+
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// React Components
|
|
102
|
+
// ============================================================================
|
|
103
|
+
|
|
104
|
+
import type { ReactNode, CSSProperties } from 'react';
|
|
105
|
+
|
|
106
|
+
export interface RealNodeProviderProps extends RealNodeConfig {
|
|
107
|
+
children: ReactNode;
|
|
108
|
+
}
|
|
109
|
+
export declare function RealNodeProvider(props: RealNodeProviderProps): JSX.Element;
|
|
110
|
+
export declare function useRealNodeContext(): UseRealNodeReturn;
|
|
111
|
+
|
|
112
|
+
export interface RealNodeCheckoutButtonProps {
|
|
113
|
+
children?: ReactNode;
|
|
114
|
+
quantity?: number;
|
|
115
|
+
plan?: RealNodePlan;
|
|
116
|
+
onSuccess?: (result: RealNodeResult) => void;
|
|
117
|
+
onDenied?: (result: RealNodeResult) => void;
|
|
118
|
+
style?: CSSProperties;
|
|
119
|
+
className?: string;
|
|
120
|
+
[key: string]: unknown;
|
|
121
|
+
}
|
|
122
|
+
export declare function RealNodeCheckoutButton(props: RealNodeCheckoutButtonProps): JSX.Element;
|
|
123
|
+
|
|
124
|
+
export interface RealNodeQuotaBadgeProps {
|
|
125
|
+
label?: string;
|
|
126
|
+
style?: CSSProperties;
|
|
127
|
+
}
|
|
128
|
+
export declare function RealNodeQuotaBadge(props: RealNodeQuotaBadgeProps): JSX.Element | null;
|
|
129
|
+
|
|
130
|
+
export interface RealNodeDeviceManagerProps {
|
|
131
|
+
apiKey: string;
|
|
132
|
+
plan?: RealNodePlan;
|
|
133
|
+
}
|
|
134
|
+
export declare function RealNodeDeviceManager(props: RealNodeDeviceManagerProps): JSX.Element | null;
|