@safepassage/sdk 3.0.0
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/LICENSE +21 -0
- package/README.md +222 -0
- package/dist/components/SafePassageVerification.d.ts +4 -0
- package/dist/components/SafePassageVerification.js +196 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.js +7 -0
- package/dist/safepassage.min.js +3 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 SafePassage
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# SafePassage SDK v3.0.0 - Redirect Implementation
|
|
2
|
+
|
|
3
|
+
A lightweight SDK for integrating SafePassage age verification using a simple redirect flow.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Ultra-lightweight**: Only 4.9KB minified (1.9KB gzipped)
|
|
8
|
+
- **Simple integration**: Just 10 lines of code
|
|
9
|
+
- **Two modes**: Same-tab redirect or new-tab popup
|
|
10
|
+
- **TypeScript support**: Full type definitions included
|
|
11
|
+
- **Auto-environment detection**: Works seamlessly in development
|
|
12
|
+
- **Secure**: Merchant-generated session IDs prevent attacks
|
|
13
|
+
- **Compliant**: Enforces minimum age of 25
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @safepassage/sdk
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```javascript
|
|
24
|
+
// Initialize SDK
|
|
25
|
+
const sp = new SafePassage({
|
|
26
|
+
apiKey: 'pk_live_xxxxx',
|
|
27
|
+
returnUrl: 'https://yoursite.com/verified',
|
|
28
|
+
cancelUrl: 'https://yoursite.com/cancelled'
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Trigger verification
|
|
32
|
+
sp.verify({
|
|
33
|
+
sessionId: generateUUID() // You must generate this
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
| Option | Type | Required | Description |
|
|
40
|
+
|--------|------|----------|-------------|
|
|
41
|
+
| apiKey | string | Yes | Your public API key (pk_live_xxx or pk_test_xxx) |
|
|
42
|
+
| returnUrl | string | Yes | URL to redirect after successful verification |
|
|
43
|
+
| cancelUrl | string | Yes | URL to redirect if user cancels |
|
|
44
|
+
| environment | string | No | 'production', 'staging', or 'development' (auto-detected) |
|
|
45
|
+
| mode | string | No | 'redirect' (default) or 'new-tab' |
|
|
46
|
+
| onComplete | function | No | Callback for new-tab mode completion |
|
|
47
|
+
| onCancel | function | No | Callback for new-tab mode cancellation |
|
|
48
|
+
| onError | function | No | Error handler |
|
|
49
|
+
|
|
50
|
+
## Verification Options
|
|
51
|
+
|
|
52
|
+
```javascript
|
|
53
|
+
sp.verify({
|
|
54
|
+
sessionId: 'uuid-v4', // Required: merchant-generated UUID
|
|
55
|
+
challengeAge: 30, // Optional: min 25 (default from dashboard)
|
|
56
|
+
verificationMode: 'L2' // Optional: 'L1' or 'L2' (default from dashboard)
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Modes
|
|
61
|
+
|
|
62
|
+
### Same-Tab Redirect (Default)
|
|
63
|
+
User is redirected to SafePassage, then back to your site:
|
|
64
|
+
|
|
65
|
+
```javascript
|
|
66
|
+
const sp = new SafePassage({
|
|
67
|
+
apiKey: 'pk_live_xxxxx',
|
|
68
|
+
returnUrl: '/age-verified',
|
|
69
|
+
cancelUrl: '/age-gate'
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
sp.verify({ sessionId: generateUUID() });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### New-Tab Mode
|
|
76
|
+
Verification opens in a popup window:
|
|
77
|
+
|
|
78
|
+
```javascript
|
|
79
|
+
const sp = new SafePassage({
|
|
80
|
+
apiKey: 'pk_live_xxxxx',
|
|
81
|
+
returnUrl: '/age-verified',
|
|
82
|
+
cancelUrl: '/age-gate',
|
|
83
|
+
mode: 'new-tab',
|
|
84
|
+
onComplete: (result) => {
|
|
85
|
+
console.log('Verified:', result.sessionId);
|
|
86
|
+
// Validate on your server!
|
|
87
|
+
},
|
|
88
|
+
onCancel: () => {
|
|
89
|
+
console.log('User cancelled');
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
sp.verify({ sessionId: generateUUID() });
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Server-Side Validation (Required!)
|
|
97
|
+
|
|
98
|
+
Always validate the session on your server:
|
|
99
|
+
|
|
100
|
+
```javascript
|
|
101
|
+
// Node.js example
|
|
102
|
+
const response = await fetch('https://api.safepassageapp.com/v1/sessions/validate', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: {
|
|
105
|
+
'Authorization': 'Bearer sk_live_xxxxx', // Secret key
|
|
106
|
+
'Content-Type': 'application/json'
|
|
107
|
+
},
|
|
108
|
+
body: JSON.stringify({ sessionId })
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const result = await response.json();
|
|
112
|
+
if (result.verified && result.estimatedAge >= result.challengeAge) {
|
|
113
|
+
// Grant access
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## UUID Generation
|
|
118
|
+
|
|
119
|
+
You must generate session IDs on your end:
|
|
120
|
+
|
|
121
|
+
```javascript
|
|
122
|
+
function generateUUID() {
|
|
123
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
124
|
+
const r = Math.random() * 16 | 0;
|
|
125
|
+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
126
|
+
return v.toString(16);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Complete Example
|
|
132
|
+
|
|
133
|
+
```html
|
|
134
|
+
<!DOCTYPE html>
|
|
135
|
+
<html>
|
|
136
|
+
<head>
|
|
137
|
+
<!-- Install via NPM: npm install @safepassage/sdk -->
|
|
138
|
+
<script src="node_modules/@safepassage/sdk/dist/safepassage.min.js"></script>
|
|
139
|
+
</head>
|
|
140
|
+
<body>
|
|
141
|
+
<button onclick="verifyAge()">Verify Your Age</button>
|
|
142
|
+
|
|
143
|
+
<script>
|
|
144
|
+
function generateUUID() {
|
|
145
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
146
|
+
const r = Math.random() * 16 | 0;
|
|
147
|
+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
148
|
+
return v.toString(16);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const sp = new SafePassage({
|
|
153
|
+
apiKey: 'pk_live_xxxxx',
|
|
154
|
+
returnUrl: window.location.href + '?verified=true',
|
|
155
|
+
cancelUrl: window.location.href
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
function verifyAge() {
|
|
159
|
+
const sessionId = generateUUID();
|
|
160
|
+
sessionStorage.setItem('pendingVerification', sessionId);
|
|
161
|
+
sp.verify({ sessionId });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check if returning from verification
|
|
165
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
166
|
+
if (urlParams.get('verified') === 'true') {
|
|
167
|
+
const sessionId = sessionStorage.getItem('pendingVerification');
|
|
168
|
+
// Validate session server-side here
|
|
169
|
+
console.log('Validate session:', sessionId);
|
|
170
|
+
}
|
|
171
|
+
</script>
|
|
172
|
+
</body>
|
|
173
|
+
</html>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## TypeScript
|
|
177
|
+
|
|
178
|
+
Full TypeScript support included:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
import { SafePassage, SafePassageConfig } from '@safepassage/sdk';
|
|
182
|
+
|
|
183
|
+
const config: SafePassageConfig = {
|
|
184
|
+
apiKey: process.env.SAFEPASSAGE_PUBLIC_KEY!,
|
|
185
|
+
returnUrl: '/verified',
|
|
186
|
+
cancelUrl: '/cancelled'
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const sp = new SafePassage(config);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Migration from v2 (iframe)
|
|
193
|
+
|
|
194
|
+
Old iframe approach (1000+ lines):
|
|
195
|
+
```javascript
|
|
196
|
+
// Complex iframe setup...
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
New redirect approach (10 lines):
|
|
200
|
+
```javascript
|
|
201
|
+
const sp = new SafePassage({
|
|
202
|
+
apiKey: 'pk_live_xxxxx',
|
|
203
|
+
returnUrl: '/verified',
|
|
204
|
+
cancelUrl: '/cancelled'
|
|
205
|
+
});
|
|
206
|
+
sp.verify({ sessionId: generateUUID() });
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Browser Support
|
|
210
|
+
|
|
211
|
+
- Chrome 60+
|
|
212
|
+
- Firefox 60+
|
|
213
|
+
- Safari 12+
|
|
214
|
+
- Edge 79+
|
|
215
|
+
- Mobile browsers
|
|
216
|
+
|
|
217
|
+
## Security Notes
|
|
218
|
+
|
|
219
|
+
1. Always generate session IDs on the merchant side
|
|
220
|
+
2. Validate sessions server-side before granting access
|
|
221
|
+
3. Pre-register callback URLs in your dashboard
|
|
222
|
+
4. Never expose your secret key (sk_live_xxx)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useSafePassage } from '../hooks/useSafePassage';
|
|
3
|
+
const SafePassageVerification = ({ wsUrl, mode = 'enhanced_verification', maxReconnectAttempts = 5, reconnectDelay = 3000, frameRate = 10, imageQuality = 0.8, config_overrides, onSuccess, onFailure, onStateChange, customMessages, className = '', style = {} }) => {
|
|
4
|
+
const { error, feedback, processingStep, zoneTransition, qualityScore, videoRef, canvasRef, isSessionStarted } = useSafePassage({
|
|
5
|
+
wsUrl,
|
|
6
|
+
mode,
|
|
7
|
+
maxReconnectAttempts,
|
|
8
|
+
reconnectDelay,
|
|
9
|
+
frameRate,
|
|
10
|
+
imageQuality,
|
|
11
|
+
config_overrides
|
|
12
|
+
}, {
|
|
13
|
+
onSuccess,
|
|
14
|
+
onFailure,
|
|
15
|
+
onStateChange
|
|
16
|
+
}, customMessages);
|
|
17
|
+
const getDisplayMessage = () => {
|
|
18
|
+
if (zoneTransition) {
|
|
19
|
+
return zoneTransition.message;
|
|
20
|
+
}
|
|
21
|
+
if (feedback && !processingStep) {
|
|
22
|
+
return feedback;
|
|
23
|
+
}
|
|
24
|
+
if (processingStep) {
|
|
25
|
+
return processingStep;
|
|
26
|
+
}
|
|
27
|
+
if (!isSessionStarted) {
|
|
28
|
+
return 'Starting camera...';
|
|
29
|
+
}
|
|
30
|
+
return 'Position your face in the camera and look forward';
|
|
31
|
+
};
|
|
32
|
+
const getMessageClass = () => {
|
|
33
|
+
if (zoneTransition)
|
|
34
|
+
return 'safepassage-instruction-zone-transition';
|
|
35
|
+
if (feedback && !processingStep)
|
|
36
|
+
return 'safepassage-instruction-feedback';
|
|
37
|
+
if (processingStep)
|
|
38
|
+
return 'safepassage-instruction-processing';
|
|
39
|
+
return 'safepassage-instruction-default';
|
|
40
|
+
};
|
|
41
|
+
// Calculate glow intensity based on quality score and feedback
|
|
42
|
+
const getGlowIntensity = () => {
|
|
43
|
+
// Base glow on quality score (0-1)
|
|
44
|
+
let intensity = qualityScore;
|
|
45
|
+
// Boost intensity for positive feedback (both generic and zone-specific)
|
|
46
|
+
if (feedback === 'good_position' ||
|
|
47
|
+
feedback === 'good_wide_position' ||
|
|
48
|
+
feedback === 'good_close_position') {
|
|
49
|
+
intensity = Math.max(intensity, 0.9);
|
|
50
|
+
}
|
|
51
|
+
// Medium intensity for zone positioning messages
|
|
52
|
+
if (feedback === 'position_at_comfortable_distance' ||
|
|
53
|
+
feedback === 'move_closer_to_camera') {
|
|
54
|
+
intensity = Math.max(intensity, 0.6);
|
|
55
|
+
}
|
|
56
|
+
// Reduce intensity for negative feedback
|
|
57
|
+
if (feedback === 'no_face_detected' ||
|
|
58
|
+
feedback === 'move_closer' ||
|
|
59
|
+
feedback === 'move_further' ||
|
|
60
|
+
feedback === 'center_face' ||
|
|
61
|
+
feedback === 'improve_lighting') {
|
|
62
|
+
intensity = Math.min(intensity, 0.3);
|
|
63
|
+
}
|
|
64
|
+
// Special handling for zone transitions - pulse effect
|
|
65
|
+
if (zoneTransition) {
|
|
66
|
+
intensity = Math.max(intensity, 0.7);
|
|
67
|
+
}
|
|
68
|
+
// Clamp between 0 and 1
|
|
69
|
+
return Math.max(0, Math.min(1, intensity));
|
|
70
|
+
};
|
|
71
|
+
const glowIntensity = getGlowIntensity();
|
|
72
|
+
return (_jsxs("div", { className: `safepassage-verification-container ${className}`, style: style, children: [_jsxs("div", { className: "safepassage-video-container", children: [_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, className: "safepassage-verification-video" }), _jsx("div", { className: "safepassage-video-overlay", children: _jsx("div", { className: "safepassage-oval-frame", style: {
|
|
73
|
+
'--glow-intensity': glowIntensity,
|
|
74
|
+
'--glow-opacity': glowIntensity > 0.1 ? glowIntensity : 0
|
|
75
|
+
} }) })] }), _jsx("div", { className: "safepassage-instructions-area", children: _jsx("div", { className: `safepassage-instruction-text ${getMessageClass()}`, children: getDisplayMessage() }) }), _jsx("canvas", { ref: canvasRef, style: { display: 'none' } }), error && (_jsx("div", { className: "safepassage-error-message", children: error })), _jsx("style", { children: `
|
|
76
|
+
.safepassage-verification-container {
|
|
77
|
+
display: flex;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
align-items: center;
|
|
80
|
+
justify-content: center;
|
|
81
|
+
width: 100%;
|
|
82
|
+
min-height: 100vh;
|
|
83
|
+
margin: 0;
|
|
84
|
+
padding: 40px 20px;
|
|
85
|
+
text-align: center;
|
|
86
|
+
background-color: #2a2a2a;
|
|
87
|
+
position: fixed;
|
|
88
|
+
top: 0;
|
|
89
|
+
left: 0;
|
|
90
|
+
right: 0;
|
|
91
|
+
bottom: 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.safepassage-video-container {
|
|
95
|
+
position: relative;
|
|
96
|
+
width: 320px;
|
|
97
|
+
height: 320px;
|
|
98
|
+
margin-bottom: 40px;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.safepassage-verification-video {
|
|
102
|
+
width: 100%;
|
|
103
|
+
height: 100%;
|
|
104
|
+
object-fit: cover;
|
|
105
|
+
border-radius: 50%;
|
|
106
|
+
background-color: #000;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.safepassage-video-overlay {
|
|
110
|
+
position: absolute;
|
|
111
|
+
top: 0;
|
|
112
|
+
left: 0;
|
|
113
|
+
width: 100%;
|
|
114
|
+
height: 100%;
|
|
115
|
+
pointer-events: none;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.safepassage-oval-frame {
|
|
119
|
+
width: 100%;
|
|
120
|
+
height: 100%;
|
|
121
|
+
border: 3px solid #ffffff;
|
|
122
|
+
border-radius: 50%;
|
|
123
|
+
box-shadow:
|
|
124
|
+
0 0 20px rgba(255, 255, 255, 0.3),
|
|
125
|
+
0 0 calc(30px * var(--glow-intensity, 0)) rgba(34, 197, 94, calc(var(--glow-opacity, 0) * 0.8)),
|
|
126
|
+
0 0 calc(50px * var(--glow-intensity, 0)) rgba(34, 197, 94, calc(var(--glow-opacity, 0) * 0.4)),
|
|
127
|
+
0 0 calc(80px * var(--glow-intensity, 0)) rgba(34, 197, 94, calc(var(--glow-opacity, 0) * 0.2));
|
|
128
|
+
transition: box-shadow 0.3s ease-out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.safepassage-instructions-area {
|
|
132
|
+
width: 100%;
|
|
133
|
+
max-width: 400px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.safepassage-instruction-text {
|
|
137
|
+
font-size: 18px;
|
|
138
|
+
font-weight: 500;
|
|
139
|
+
color: #ffffff;
|
|
140
|
+
text-align: center;
|
|
141
|
+
line-height: 1.4;
|
|
142
|
+
min-height: 50px;
|
|
143
|
+
display: flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
justify-content: center;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.safepassage-instruction-zone-transition {
|
|
149
|
+
color: #4fc3f7;
|
|
150
|
+
font-weight: 600;
|
|
151
|
+
animation: safepassage-pulse 0.5s ease-in-out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.safepassage-instruction-feedback {
|
|
155
|
+
color: #ffffff;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.safepassage-instruction-processing {
|
|
159
|
+
color: #81c784;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.safepassage-instruction-default {
|
|
163
|
+
color: #ffffff;
|
|
164
|
+
opacity: 0.9;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.safepassage-error-message {
|
|
168
|
+
color: #e53e3e;
|
|
169
|
+
margin-top: 10px;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@keyframes safepassage-pulse {
|
|
173
|
+
0% { opacity: 0.7; transform: scale(1); }
|
|
174
|
+
50% { opacity: 1; transform: scale(1.02); }
|
|
175
|
+
100% { opacity: 0.9; transform: scale(1); }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@media (max-width: 480px) {
|
|
179
|
+
.safepassage-verification-container {
|
|
180
|
+
padding: 20px 15px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.safepassage-video-container {
|
|
184
|
+
width: 280px;
|
|
185
|
+
height: 280px;
|
|
186
|
+
margin-bottom: 30px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.safepassage-instruction-text {
|
|
190
|
+
font-size: 16px;
|
|
191
|
+
min-height: 45px;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
` })] }));
|
|
195
|
+
};
|
|
196
|
+
export default SafePassageVerification;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafePassage SDK Type Definitions
|
|
3
|
+
*/
|
|
4
|
+
export interface SafePassageConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Public API key (starts with pk_live_ or pk_test_)
|
|
7
|
+
*/
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/**
|
|
10
|
+
* URL to redirect to after successful verification
|
|
11
|
+
* Must be pre-registered in dashboard
|
|
12
|
+
*/
|
|
13
|
+
returnUrl: string;
|
|
14
|
+
/**
|
|
15
|
+
* URL to redirect to if user cancels verification
|
|
16
|
+
* Must be pre-registered in dashboard
|
|
17
|
+
*/
|
|
18
|
+
cancelUrl: string;
|
|
19
|
+
/**
|
|
20
|
+
* Environment to use
|
|
21
|
+
* @default Auto-detected based on hostname
|
|
22
|
+
*/
|
|
23
|
+
environment?: 'production' | 'staging' | 'development';
|
|
24
|
+
/**
|
|
25
|
+
* Verification mode
|
|
26
|
+
* @default 'redirect'
|
|
27
|
+
*/
|
|
28
|
+
mode?: 'redirect' | 'new-tab';
|
|
29
|
+
/**
|
|
30
|
+
* Default challenge age (minimum 25)
|
|
31
|
+
* Can be overridden per verification
|
|
32
|
+
*/
|
|
33
|
+
defaultChallengeAge?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Default verification mode
|
|
36
|
+
* Can be overridden per verification
|
|
37
|
+
*/
|
|
38
|
+
defaultVerificationMode?: 'L1' | 'L2';
|
|
39
|
+
/**
|
|
40
|
+
* Callback when verification completes (new-tab mode only)
|
|
41
|
+
*/
|
|
42
|
+
onComplete?: (result: VerificationResult) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Callback when user cancels (new-tab mode only)
|
|
45
|
+
*/
|
|
46
|
+
onCancel?: () => void;
|
|
47
|
+
/**
|
|
48
|
+
* Callback for errors
|
|
49
|
+
*/
|
|
50
|
+
onError?: (error: Error) => void;
|
|
51
|
+
}
|
|
52
|
+
export interface VerificationOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Merchant-generated UUID v4 for this verification session
|
|
55
|
+
* Required for security - prevents session fixation attacks
|
|
56
|
+
*/
|
|
57
|
+
sessionId: string;
|
|
58
|
+
/**
|
|
59
|
+
* Minimum age to verify (minimum 25)
|
|
60
|
+
* @default Uses merchant dashboard configuration
|
|
61
|
+
*/
|
|
62
|
+
challengeAge?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Verification mode
|
|
65
|
+
* L1: Age estimation allowed if user appears older
|
|
66
|
+
* L2: Full ID verification required
|
|
67
|
+
* @default Uses merchant dashboard configuration
|
|
68
|
+
*/
|
|
69
|
+
verificationMode?: 'L1' | 'L2';
|
|
70
|
+
}
|
|
71
|
+
export interface VerificationResult {
|
|
72
|
+
/**
|
|
73
|
+
* The session ID that was verified
|
|
74
|
+
*/
|
|
75
|
+
sessionId: string;
|
|
76
|
+
/**
|
|
77
|
+
* Binary result: 'verified' or 'failed'
|
|
78
|
+
* Full details available via server-side API
|
|
79
|
+
*/
|
|
80
|
+
status: 'verified' | 'failed' | 'cancelled';
|
|
81
|
+
}
|
|
82
|
+
export interface StatePayload {
|
|
83
|
+
merchantId: string;
|
|
84
|
+
sessionId: string;
|
|
85
|
+
returnUrl: string;
|
|
86
|
+
cancelUrl: string;
|
|
87
|
+
challengeAge?: number;
|
|
88
|
+
verificationMode?: 'L1' | 'L2';
|
|
89
|
+
timestamp: number;
|
|
90
|
+
}
|
|
91
|
+
export interface SessionValidationResponse {
|
|
92
|
+
sessionId: string;
|
|
93
|
+
merchantId: string;
|
|
94
|
+
status: 'verified' | 'failed';
|
|
95
|
+
verified: boolean;
|
|
96
|
+
estimatedAge?: number;
|
|
97
|
+
challengeAge: number;
|
|
98
|
+
verificationMode: 'L1' | 'L2';
|
|
99
|
+
verificationMethod?: 'facial' | 'document' | 'combined';
|
|
100
|
+
timestamp: string;
|
|
101
|
+
expiresAt: string;
|
|
102
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// SafePassage SDK v3.0.0 - Redirect Implementation
|
|
2
|
+
// Main export for the new redirect-based SDK
|
|
3
|
+
export { SafePassage } from '../src-redirect/core/SafePassageSDK';
|
|
4
|
+
// Re-export for convenience
|
|
5
|
+
export * from '../src-redirect/types';
|
|
6
|
+
// Default export
|
|
7
|
+
export { SafePassage as default } from '../src-redirect/core/SafePassageSDK';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/* SafePassage SDK v3.0.0 - Redirect Implementation */
|
|
2
|
+
"use strict";var SafePassageSDK=(()=>{var p=Object.defineProperty;var W=Object.getOwnPropertyDescriptor;var H=Object.getOwnPropertyNames;var K=Object.prototype.hasOwnProperty;var c=(t,e)=>()=>(t&&(e=t(t=0)),e);var u=(t,e)=>{for(var n in e)p(t,n,{get:e[n],enumerable:!0})},q=(t,e,n,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of H(e))!K.call(t,o)&&o!==n&&p(t,o,{get:()=>e[o],enumerable:!(i=W(e,o))||i.enumerable});return t};var I=t=>q(p({},"__esModule",{value:!0}),t);function j(t,e){return E[e].includes(t)}function P(t,e,n=[]){let{origin:i}=t;return j(i,e)||n.length>0&&n.some(r=>{if(r.startsWith("*.")){let a=r.slice(2);return i.endsWith(`.${a}`)||i===`https://${a}`||i===`http://${a}`}return i===r})?!0:(console.warn(`SafePassage Security: Blocked PostMessage from untrusted origin: ${i}`,{environment:e,trustedOrigins:E[e],allowedCustomOrigins:n,eventType:t.data?.type}),!1)}function T(t,e){let{data:n}=t;return!n||typeof n!="object"?{isValid:!1,error:"Invalid message format"}:n.type!=="safepassage:verification:complete"?{isValid:!1,error:"Invalid message type"}:!n.sessionId||n.sessionId!==e?{isValid:!1,error:"Session ID mismatch"}:!n.status||!["verified","failed","cancelled"].includes(n.status)?{isValid:!1,error:"Invalid status value"}:{isValid:!0}}function U(t){if(t==="production"&&window.location.protocol!=="https:"){let e=window.location.href.replace("http:","https:");console.error("SafePassage Security: HTTPS required in production. Redirecting...",{current:window.location.href,redirect:e}),window.location.replace(e)}}function f(t,e){try{let n=new URL(t);if(e==="production"&&n.protocol!=="https:")return{isValid:!1,error:"HTTPS required for return URLs in production"};if(e==="development"&&!(n.hostname==="localhost"||n.hostname==="127.0.0.1"||n.hostname.endsWith(".local"))&&n.protocol!=="https:")return{isValid:!1,error:"Non-localhost URLs must use HTTPS"};if(e==="staging"&&n.protocol!=="https:")return{isValid:!1,error:"HTTPS required for return URLs in staging"};let i=[/data:/i,/javascript:/i,/vbscript:/i,/file:/i,/ftp:/i];for(let o of i)if(o.test(t))return{isValid:!1,error:"Blocked suspicious URL scheme"};return{isValid:!0}}catch{return{isValid:!1,error:"Invalid URL format"}}}function s(t,e){console.warn(`SafePassage Security Event: ${t}`,{timestamp:new Date().toISOString(),userAgent:navigator.userAgent,url:window.location.href,...e})}var E,g,V,h=c(()=>{"use strict";E={production:["https://verify.safepassageapp.com","https://portal.safepassageapp.com","https://api.safepassageapp.com"],staging:["https://verify-staging.safepassageapp.com","https://portal-staging.safepassageapp.com","https://api-staging.safepassageapp.com"],development:["http://localhost:5173","http://localhost:3000","http://localhost:3001","http://localhost:3002","http://127.0.0.1:5173","http://127.0.0.1:3000","http://127.0.0.1:3001","http://127.0.0.1:3002"]};g=class{constructor(){this.attempts=new Map;this.maxAttempts=5;this.timeWindow=6e4}isAllowed(e){let n=Date.now(),o=(this.attempts.get(e)||[]).filter(r=>n-r<this.timeWindow);return o.length>=this.maxAttempts?(console.warn(`SafePassage Security: Rate limit exceeded for ${e}`),!1):(o.push(n),this.attempts.set(e,o),!0)}reset(e){this.attempts.delete(e)}},V=new g});var L={};u(L,{createSignedState:()=>J,generateHMAC:()=>m,generateSecureToken:()=>b,getSigningSecret:()=>w,parseSignedState:()=>B,verifyHMAC:()=>A});async function m(t,e){let n=new TextEncoder,i=n.encode(e),o=n.encode(t),r=await crypto.subtle.importKey("raw",i,{name:"HMAC",hash:"SHA-256"},!1,["sign"]),a=await crypto.subtle.sign("HMAC",r,o);return Array.from(new Uint8Array(a)).map(d=>d.toString(16).padStart(2,"0")).join("")}async function A(t,e,n){try{let i=await m(t,n);return F(e,i)}catch{return!1}}function F(t,e){if(t.length!==e.length)return!1;let n=0;for(let i=0;i<t.length;i++)n|=t.charCodeAt(i)^e.charCodeAt(i);return n===0}function b(t=32){let e=new Uint8Array(t);return crypto.getRandomValues(e),Array.from(e,n=>n.toString(16).padStart(2,"0")).join("")}function w(t){return{production:"safepassage-prod-hmac-2025",staging:"safepassage-stage-hmac-2025",development:"safepassage-dev-hmac-2025"}[t]}async function J(t,e){let n={...t,timestamp:Date.now(),nonce:b(16)},i=JSON.stringify(n),o=w(e),r=await m(i,o);return btoa(JSON.stringify({data:n,signature:r}))}async function B(t,e,n=10*60*1e3){try{let i=atob(t),o=JSON.parse(i);if(!o.data||!o.signature)return console.warn("SafePassage: Invalid signed state format"),null;let{data:r,signature:a}=o,d=JSON.stringify(r),k=w(e);if(!await A(d,a,k))return console.warn("SafePassage: State signature verification failed"),null;if(r.timestamp){let y=Date.now()-r.timestamp;if(y>n)return console.warn("SafePassage: State parameter expired",{age:y,maxAge:n}),null}let{timestamp:ne,nonce:ie,...$}=r;return $}catch(i){return console.warn("SafePassage: Failed to parse signed state",i),null}}var x=c(()=>{"use strict"});function C(t){if(!t.apiKey)throw new Error("apiKey is required");if(!G.test(t.apiKey))throw new Error("Invalid apiKey format. Expected pk_xxx or sk_xxx");if(!t.returnUrl)throw new Error("returnUrl is required");if(!t.cancelUrl)throw new Error("cancelUrl is required");let e=Y(),n=f(t.returnUrl,e);if(!n.isValid)throw new Error(`returnUrl validation failed: ${n.error}`);let i=f(t.cancelUrl,e);if(!i.isValid)throw new Error(`cancelUrl validation failed: ${i.error}`);if(t.defaultChallengeAge!==void 0&&t.defaultChallengeAge<M)throw new Error(`defaultChallengeAge must be at least ${M}`);if(t.defaultVerificationMode&&!["L1","L2"].includes(t.defaultVerificationMode))throw new Error("defaultVerificationMode must be L1 or L2");if(t.mode&&!["redirect","new-tab"].includes(t.mode))throw new Error("mode must be redirect or new-tab")}function Y(){let t=window.location.hostname;return t==="localhost"||t==="127.0.0.1"||t.includes(".local")?"development":t.includes("staging")||t.includes("stage")?"staging":"production"}async function R(t,e){let{createSignedState:n}=await Promise.resolve().then(()=>(x(),L));return n(t,e)}var M,G,O=c(()=>{"use strict";h();M=25,G=/^(pk|sk)_[a-zA-Z0-9]+$/});function v(t){let e=Z[t];if((t==="production"||t==="staging")&&!e.startsWith("https://"))throw new Error(`HTTPS required for ${t} environment`);return e}function z(t){let n={production:"https://api.safepassageapp.com",staging:"https://api-staging.safepassageapp.com",development:"http://localhost:3001"}[t];if((t==="production"||t==="staging")&&!n.startsWith("https://"))throw new Error(`HTTPS required for API URLs in ${t} environment`);return n}function D(t){let e=window.location.protocol==="https:",n=window.location.hostname;switch(t){case"production":if(!e)throw new Error("SafePassage requires HTTPS in production environment");break;case"staging":e||console.warn("SafePassage Warning: HTTPS strongly recommended in staging environment");break;case"development":let i=n==="localhost"||n==="127.0.0.1"||n.includes(".local");!e&&!i&&console.warn("SafePassage Warning: HTTPS recommended for non-localhost development");break}try{v(t),z(t)}catch(i){throw new Error(`Environment configuration validation failed: ${i}`)}}var Z,N=c(()=>{"use strict";Z={production:"https://verify.safepassageapp.com",staging:"https://verify-staging.safepassageapp.com",development:"http://localhost:5173"}});var _={};u(_,{SafePassage:()=>l,default:()=>X});var l,X,S=c(()=>{"use strict";O();N();h();l=class{constructor(e){this.popupWindow=null;this.messageListener=null;this.popupMonitorInterval=null;this.unloadListener=null;this.isVerificationInProgress=!1;this.currentSessionId=null;C(e),this.config={...e,environment:e.environment||this.detectEnvironment(),mode:e.mode||"redirect"},D(this.config.environment),U(this.config.environment),s("SDK_INITIALIZED",{environment:this.config.environment,mode:this.config.mode,origin:window.location.origin,protocol:window.location.protocol,hostname:window.location.hostname}),this.setupAutoCleanup()}async verify(e){if(!e.sessionId)throw new Error("sessionId is required - must be a merchant-generated UUID v4");if(this.isVerificationInProgress){let n=new Error(`Verification already in progress for session ${this.currentSessionId?.substring(0,8)}...`);throw s("RACE_CONDITION_PREVENTED",{currentSession:this.currentSessionId?.substring(0,8)+"...",attemptedSession:e.sessionId.substring(0,8)+"...",origin:window.location.origin}),this.config.onError?.(n),n}this.isVerificationInProgress=!0,this.currentSessionId=e.sessionId;try{let n=`${this.config.apiKey}:${window.location.origin}`;if(!V.isAllowed(n)){let o=new Error("Too many verification attempts. Please wait before trying again.");throw s("RATE_LIMIT_EXCEEDED",{apiKey:this.config.apiKey.substring(0,8)+"...",origin:window.location.origin,sessionId:e.sessionId.substring(0,8)+"..."}),this.config.onError?.(o),o}let i=await this.buildVerificationUrl(e);s("VERIFICATION_INITIATED",{environment:this.config.environment,mode:this.config.mode,sessionId:e.sessionId.substring(0,8)+"...",origin:window.location.origin}),this.config.mode==="new-tab"?this.openNewTab(i,e.sessionId):(this.unlockVerification(),this.redirect(i))}catch(n){throw this.unlockVerification(),n}}async buildVerificationUrl(e){let n=v(this.config.environment),i=await R({merchantId:this.config.apiKey,sessionId:e.sessionId,returnUrl:this.config.returnUrl,cancelUrl:this.config.cancelUrl,challengeAge:e.challengeAge||this.config.defaultChallengeAge,verificationMode:e.verificationMode||this.config.defaultVerificationMode,timestamp:Date.now()},this.config.environment),o=new URLSearchParams({state:i,sessionId:e.sessionId,mode:this.config.mode});return`${n}/verify?${o.toString()}`}redirect(e){window.location.href=e}openNewTab(e,n){if(this.cleanup(),this.popupMonitorInterval&&(clearInterval(this.popupMonitorInterval),this.popupMonitorInterval=null),this.popupWindow=window.open(e,"safepassage-verify","width=600,height=700"),!this.popupWindow){this.config.onError?.(new Error("Failed to open verification window. Please check popup blocker settings."));return}this.messageListener=i=>{if(!P(i,this.config.environment)){s("POSTMESSAGE_ORIGIN_BLOCKED",{origin:i.origin,environment:this.config.environment,expectedOrigins:`SafePassage trusted origins for ${this.config.environment}`,messageType:i.data?.type});return}let o=T(i,n);if(!o.isValid){s("POSTMESSAGE_VALIDATION_FAILED",{error:o.error,origin:i.origin,sessionId:n.substring(0,8)+"...",messageType:i.data?.type});return}let r={sessionId:i.data.sessionId,status:i.data.status};s("VERIFICATION_COMPLETED",{status:r.status,sessionId:n.substring(0,8)+"...",origin:i.origin}),this.cleanup(),this.unlockVerification(),this.popupMonitorInterval&&(clearInterval(this.popupMonitorInterval),this.popupMonitorInterval=null),r.status==="verified"?this.config.onComplete?.(r):r.status==="cancelled"?this.config.onCancel?.():this.config.onError?.(new Error(`Verification failed: ${r.status}`))},window.addEventListener("message",this.messageListener),this.popupMonitorInterval=setInterval(()=>{this.popupWindow&&this.popupWindow.closed&&(s("POPUP_CLOSED_BY_USER",{sessionId:n.substring(0,8)+"...",environment:this.config.environment}),this.cleanup(),this.unlockVerification(),this.config.onCancel?.())},500)}setupAutoCleanup(){if(this.unloadListener=()=>{s("SDK_AUTO_CLEANUP",{environment:this.config.environment,trigger:"page_unload"}),this.cleanup(),this.unlockVerification()},window.addEventListener("beforeunload",this.unloadListener),window.addEventListener("pagehide",this.unloadListener),window.history&&window.history.pushState){let e=window.history.pushState;window.history.pushState=(...n)=>(this.cleanup(),this.unlockVerification(),e.apply(window.history,n))}}detectEnvironment(){let e=window.location.hostname;return e==="localhost"||e==="127.0.0.1"||e.includes(".local")?"development":e.includes("staging")||e.includes("stage")?"staging":"production"}unlockVerification(){this.isVerificationInProgress=!1,this.currentSessionId=null,s("VERIFICATION_UNLOCKED",{environment:this.config.environment,origin:window.location.origin})}cleanup(){this.popupWindow&&!this.popupWindow.closed&&this.popupWindow.close(),this.popupWindow=null,this.messageListener&&(window.removeEventListener("message",this.messageListener),this.messageListener=null),this.popupMonitorInterval&&(clearInterval(this.popupMonitorInterval),this.popupMonitorInterval=null)}removeAutoCleanupListeners(){this.unloadListener&&(window.removeEventListener("beforeunload",this.unloadListener),window.removeEventListener("pagehide",this.unloadListener),this.unloadListener=null)}destroy(){s("SDK_DESTROYED",{environment:this.config.environment,origin:window.location.origin}),this.cleanup(),this.unlockVerification(),this.removeAutoCleanupListeners()}},X=l});var ee={};u(ee,{SafePassage:()=>l,VERSION:()=>Q,default:()=>l});S();var Q="3.0.0";typeof window<"u"&&window&&(window.SafePassage=(S(),I(_)).SafePassage);return I(ee);})();
|
|
3
|
+
if(typeof SafePassageSDK !== "undefined" && SafePassageSDK.SafePassage) { window.SafePassage = SafePassageSDK.SafePassage; }
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@safepassage/sdk",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "SafePassage SDK - Lightweight redirect-based age verification",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc && node build-sdk.js",
|
|
14
|
+
"dev": "tsc --watch",
|
|
15
|
+
"demo": "python3 -m http.server 8080",
|
|
16
|
+
"serve-demo": "npx serve . -p 8080",
|
|
17
|
+
"test": "jest --config test/jest.config.js",
|
|
18
|
+
"test:browser": "jest --config test/jest.config.js test/browser-compat",
|
|
19
|
+
"test:mobile": "jest --config test/jest.config.js test/mobile",
|
|
20
|
+
"test:performance": "jest --config test/jest.config.js test/performance",
|
|
21
|
+
"test:security": "jest --config test/jest.config.js test/security",
|
|
22
|
+
"test:accessibility": "jest --config test/jest.config.js test/accessibility",
|
|
23
|
+
"test:edge": "jest --config test/jest.config.js test/edge-cases",
|
|
24
|
+
"test:integration": "jest --config test/jest.config.js test/integration",
|
|
25
|
+
"test:all": "node test/run-tests.js",
|
|
26
|
+
"test:coverage": "jest --config test/jest.config.js --coverage",
|
|
27
|
+
"benchmark": "node test/performance/benchmark.js",
|
|
28
|
+
"prepublishOnly": "npm run build",
|
|
29
|
+
"version": "npm run build && git add -A dist",
|
|
30
|
+
"postversion": "git push && git push --tags"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"safepassage",
|
|
34
|
+
"age-verification",
|
|
35
|
+
"redirect",
|
|
36
|
+
"sdk",
|
|
37
|
+
"typescript",
|
|
38
|
+
"lightweight"
|
|
39
|
+
],
|
|
40
|
+
"author": "SafePassage",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@babel/core": "^7.27.4",
|
|
44
|
+
"@babel/preset-env": "^7.27.2",
|
|
45
|
+
"@types/jest": "^29.5.12",
|
|
46
|
+
"@types/node": "^24.0.1",
|
|
47
|
+
"babel-jest": "^30.0.0",
|
|
48
|
+
"esbuild": "^0.25.5",
|
|
49
|
+
"jest": "^29.7.0",
|
|
50
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
51
|
+
"puppeteer": "^21.11.0",
|
|
52
|
+
"terser": "^5.42.0",
|
|
53
|
+
"ts-jest": "^29.1.2",
|
|
54
|
+
"typescript": "~5.8.3"
|
|
55
|
+
},
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "https://github.com/safepassage/safepassage-monorepo",
|
|
59
|
+
"directory": "services/verify-ui/sdk"
|
|
60
|
+
},
|
|
61
|
+
"bugs": {
|
|
62
|
+
"url": "https://github.com/safepassage/safepassage-monorepo/issues"
|
|
63
|
+
},
|
|
64
|
+
"homepage": "https://safepassageapp.com/docs/sdk",
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public",
|
|
67
|
+
"registry": "https://registry.npmjs.org/"
|
|
68
|
+
},
|
|
69
|
+
"engines": {
|
|
70
|
+
"node": ">=14.0.0"
|
|
71
|
+
}
|
|
72
|
+
}
|