@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 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
+ }
@@ -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;