@tn3w/openage 1.0.4 → 1.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tn3w/openage",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Privacy-first age verification widget. On-device face analysis with liveness detection.",
5
5
  "type": "module",
6
6
  "main": "dist/openage.umd.js",
package/src/challenge.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  import {
28
28
  BURST_FRAMES,
29
29
  BURST_INTERVAL_MS,
30
+ ERROR_STEP_SECONDS,
30
31
  MAX_RETRIES,
31
32
  MOTION_CAPTURE_MS,
32
33
  MOTION_SAMPLE_MS,
@@ -110,6 +111,73 @@ function sleep(ms) {
110
111
  return new Promise((r) => setTimeout(r, ms));
111
112
  }
112
113
 
114
+ function resolveChallengeErrorMessage(error) {
115
+ const name = typeof error?.name === 'string' ? error.name : '';
116
+ const message = typeof error?.message === 'string' ? error.message : String(error || '');
117
+ const normalized = `${name} ${message}`.toLowerCase();
118
+
119
+ if (
120
+ name === 'NotFoundError' ||
121
+ /request is not allowed by the user agent or the platform in the current context/.test(
122
+ normalized
123
+ ) ||
124
+ /requested device not found|device not found|no camera|could not start video source/.test(
125
+ normalized
126
+ )
127
+ ) {
128
+ return 'No camera available. Plug in a camera and try again.';
129
+ }
130
+
131
+ if (
132
+ name === 'NotAllowedError' ||
133
+ name === 'PermissionDeniedError' ||
134
+ /permission|camera access|access denied/.test(normalized)
135
+ ) {
136
+ return 'Camera access was blocked. Allow camera access and try again.';
137
+ }
138
+
139
+ if (/positioning timeout/.test(normalized)) {
140
+ return 'Face positioning timed out. Reopen the check and try again.';
141
+ }
142
+
143
+ return 'Verification failed. Please try again.';
144
+ }
145
+
146
+ function isInlineLayout(widget) {
147
+ return widget?.params?.layout === 'inline';
148
+ }
149
+
150
+ async function showErrorStep(widget, error) {
151
+ const message = resolveChallengeErrorMessage(error);
152
+
153
+ if (isInlineLayout(widget)) {
154
+ widget.showResult?.('retry', message);
155
+ return;
156
+ }
157
+
158
+ if (!widget.popup) {
159
+ widget.openPopup?.();
160
+ }
161
+
162
+ widget.showError?.(message);
163
+
164
+ for (let seconds = ERROR_STEP_SECONDS; seconds > 0; seconds--) {
165
+ if (!widget.popup) break;
166
+ widget.setErrorCountdown?.(seconds);
167
+ await sleep(1000);
168
+ }
169
+
170
+ widget.closePopup?.();
171
+ widget.setState?.('retry');
172
+ }
173
+
174
+ async function handleChallengeError(widget, emitter, error) {
175
+ console.log('Error during challenge:', error);
176
+ await showErrorStep(widget, error);
177
+ emitter.emit('error', error, widget.id);
178
+ widget.params.errorCallback?.(error);
179
+ }
180
+
113
181
  export async function runChallenge(widget, emitter) {
114
182
  const mode = widget.params.mode || 'serverless';
115
183
 
@@ -139,10 +207,14 @@ async function runServerless(widget, emitter) {
139
207
  await Promise.all([loadVision(), initAgeEstimator()]);
140
208
 
141
209
  modelBuffer = await loadModel();
142
- widget.showReady();
143
210
 
144
- await waitForStart(widget);
145
- await startCameraFlow(widget, modelBuffer);
211
+ if (isInlineLayout(widget)) {
212
+ await startCameraFlow(widget, modelBuffer);
213
+ } else {
214
+ widget.showReady();
215
+ await waitForStart(widget);
216
+ await startCameraFlow(widget, modelBuffer);
217
+ }
146
218
 
147
219
  const transport = createTransport('serverless', params);
148
220
 
@@ -190,10 +262,7 @@ async function runServerless(widget, emitter) {
190
262
  emitResult(widget, emitter, result);
191
263
  } catch (error) {
192
264
  cleanupLocal();
193
- console.log('Error during challenge:', error);
194
- widget.showResult('fail', 'Verification failed');
195
- emitter.emit('error', error, widget.id);
196
- params.errorCallback?.(error);
265
+ await handleChallengeError(widget, emitter, error);
197
266
  }
198
267
  }
199
268
 
@@ -234,10 +303,16 @@ async function runServer(widget, emitter) {
234
303
  captureFrame: () => (captureFrame() ? 'true' : 'null'),
235
304
  });
236
305
 
237
- widget.showReady();
238
- await waitForStart(widget);
306
+ let video;
307
+
308
+ if (isInlineLayout(widget)) {
309
+ video = widget.showCamera();
310
+ } else {
311
+ widget.showReady();
312
+ await waitForStart(widget);
313
+ video = widget.showCamera();
314
+ }
239
315
 
240
- const video = widget.showCamera();
241
316
  widget.setVideoStatus('Requesting camera…');
242
317
  await startCamera(video);
243
318
  exposeMirrorVideo(video);
@@ -324,11 +399,8 @@ async function runServer(widget, emitter) {
324
399
 
325
400
  cleanupVM(transport);
326
401
  } catch (error) {
327
- console.log('Error during challenge:', error);
328
402
  cleanupVM();
329
- widget.showResult('fail', 'Verification failed');
330
- emitter.emit('error', error, widget.id);
331
- params.errorCallback?.(error);
403
+ await handleChallengeError(widget, emitter, error);
332
404
  }
333
405
  }
334
406
 
@@ -8,6 +8,7 @@ export declare const FACEAPI_MODEL_CDN: string;
8
8
  export declare const DEFAULT_MIN_AGE: number;
9
9
  export declare const FAIL_FLOOR: number;
10
10
  export declare const MAX_RETRIES: number;
11
+ export declare const ERROR_STEP_SECONDS: number;
11
12
  export declare const BURST_FRAMES: number;
12
13
  export declare const BURST_INTERVAL_MS: number;
13
14
  export declare const POSITION_CHECK_MS: number;
package/src/constants.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export const VERSION = '1.0.0';
2
2
 
3
- export const MEDIAPIPE_CDN = 'https://cdn.jsdelivr.net/npm/' + '@mediapipe/tasks-vision@0.10.17';
3
+ export const MEDIAPIPE_CDN = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.17';
4
4
 
5
5
  export const MEDIAPIPE_WASM = `${MEDIAPIPE_CDN}/wasm`;
6
6
 
@@ -11,13 +11,13 @@ export const MEDIAPIPE_MODEL =
11
11
  'face_landmarker/face_landmarker/float16/1/' +
12
12
  'face_landmarker.task';
13
13
 
14
- export const FACEAPI_CDN =
15
- 'https://cdn.jsdelivr.net/npm/' + 'face-api.js@0.22.2/dist/face-api.min.js';
14
+ export const FACEAPI_CDN = 'https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js';
16
15
 
17
16
  export const FACEAPI_MODEL_CDN =
18
- 'https://cdn.jsdelivr.net/gh/' + 'justadudewhohacks/face-api.js@master/weights';
17
+ 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights';
19
18
 
20
19
  export const MAX_RETRIES = 3;
20
+ export const ERROR_STEP_SECONDS = 5;
21
21
  export const BURST_FRAMES = 5;
22
22
  export const BURST_INTERVAL_MS = 200;
23
23
  export const POSITION_CHECK_MS = 100;
package/src/index.d.ts CHANGED
@@ -82,6 +82,7 @@ declare global {
82
82
  sitekey?: string;
83
83
  server?: string;
84
84
  mode?: "serverless" | "sitekey" | "custom";
85
+ layout?: "widget" | "inline" | "embed" | "embedded";
85
86
  locale?: string;
86
87
  theme?: "light" | "dark" | "auto";
87
88
  render?: "explicit";
package/src/index.js CHANGED
@@ -52,8 +52,19 @@ export class EventEmitter {
52
52
  const emitter = new EventEmitter();
53
53
  const widgets = new Map();
54
54
 
55
+ function normalizeLayout(layout) {
56
+ if (layout === 'inline' || layout === 'embed' || layout === 'embedded') {
57
+ return 'inline';
58
+ }
59
+
60
+ return 'widget';
61
+ }
62
+
55
63
  function normalizeParams(params) {
56
64
  const globalConfig = typeof window !== 'undefined' ? window.openage || {} : {};
65
+ const layout = normalizeLayout(
66
+ params.layout ?? params.display ?? globalConfig.layout ?? globalConfig.display
67
+ );
57
68
 
58
69
  return {
59
70
  mode: 'serverless',
@@ -62,6 +73,7 @@ function normalizeParams(params) {
62
73
  minAge: 18,
63
74
  ...globalConfig,
64
75
  ...params,
76
+ layout,
65
77
  };
66
78
  }
67
79
 
@@ -70,6 +82,10 @@ function startWidget(widget) {
70
82
  emitter.emit('opened', widget.id);
71
83
  runChallenge(widget, emitter);
72
84
  };
85
+
86
+ if (widget.params.layout === 'inline') {
87
+ widget.startChallenge();
88
+ }
73
89
  }
74
90
 
75
91
  function render(container, params = {}) {
@@ -218,6 +234,7 @@ function autoRender() {
218
234
  size: element.dataset.size,
219
235
  action: element.dataset.action,
220
236
  mode: element.dataset.mode,
237
+ layout: element.dataset.layout || element.dataset.display,
221
238
  server: element.dataset.server,
222
239
  };
223
240
 
package/src/ui.d.ts CHANGED
@@ -17,6 +17,10 @@ export declare function heroTemplate(
17
17
 
18
18
  export declare function challengeTemplate(): string;
19
19
 
20
+ export declare function errorStepTemplate(
21
+ message: string
22
+ ): string;
23
+
20
24
  export declare function resultTemplate(
21
25
  outcome: "fail" | "retry",
22
26
  message: string
package/src/ui.js CHANGED
@@ -541,6 +541,20 @@ export const STYLES = `
541
541
  flex-direction: column;
542
542
  }
543
543
 
544
+ .oa-inline-shell {
545
+ background: var(--oa-bg);
546
+ border: 1px solid var(--oa-border);
547
+ border-radius: var(--oa-radius);
548
+ box-shadow: 0 20px 60px rgba(0,0,0,0.18),
549
+ 0 0 0 1px rgba(0,0,0,0.06);
550
+ width: min(100%, 360px);
551
+ max-width: 100%;
552
+ overflow: hidden;
553
+ display: flex;
554
+ flex-direction: column;
555
+ margin: 0 auto;
556
+ }
557
+
544
558
  .oa-modal-overlay {
545
559
  position: fixed;
546
560
  inset: 0;
@@ -893,6 +907,52 @@ export const STYLES = `
893
907
  .oa-result-fail { color: var(--oa-danger); }
894
908
  .oa-result-retry { color: var(--oa-warn); }
895
909
 
910
+ .oa-error-step {
911
+ display: flex;
912
+ flex-direction: column;
913
+ align-items: center;
914
+ gap: 0.7rem;
915
+ padding: 2rem 1.2rem;
916
+ background: var(--oa-surface);
917
+ border: 1px solid var(--oa-border);
918
+ border-radius: var(--oa-radius);
919
+ margin: 10px;
920
+ animation: oa-fade-in 0.4s ease;
921
+ text-align: center;
922
+ }
923
+
924
+ .oa-error-step-icon {
925
+ width: 3rem;
926
+ height: 3rem;
927
+ display: flex;
928
+ align-items: center;
929
+ justify-content: center;
930
+ border-radius: 999px;
931
+ background: rgba(239, 68, 68, 0.12);
932
+ color: var(--oa-danger);
933
+ font-size: 1.7rem;
934
+ line-height: 1;
935
+ }
936
+
937
+ .oa-error-step-title {
938
+ font-size: 1rem;
939
+ font-weight: 700;
940
+ color: var(--oa-text);
941
+ }
942
+
943
+ .oa-error-step-message {
944
+ font-size: 0.84rem;
945
+ line-height: 1.5;
946
+ color: var(--oa-text-muted);
947
+ max-width: 260px;
948
+ }
949
+
950
+ .oa-error-step-countdown {
951
+ font-size: 0.75rem;
952
+ font-weight: 600;
953
+ color: var(--oa-danger);
954
+ }
955
+
896
956
  .oa-hidden { display: none !important; }
897
957
 
898
958
  /* ── Animations ──────────────────────────────── */
@@ -1019,6 +1079,17 @@ export function challengeTemplate() {
1019
1079
  `;
1020
1080
  }
1021
1081
 
1082
+ export function errorStepTemplate(message) {
1083
+ return `
1084
+ <div class="oa-error-step">
1085
+ <div class="oa-error-step-icon">✕</div>
1086
+ <div class="oa-error-step-title">Verification stopped</div>
1087
+ <div class="oa-error-step-message">${message}</div>
1088
+ <div class="oa-error-step-countdown"></div>
1089
+ </div>
1090
+ `;
1091
+ }
1092
+
1022
1093
  export function resultTemplate(outcome, message) {
1023
1094
  const icons = {
1024
1095
  fail: '✕',
package/src/widget.d.ts CHANGED
@@ -2,6 +2,7 @@ export interface WidgetParams {
2
2
  sitekey?: string;
3
3
  server?: string;
4
4
  mode?: "serverless" | "sitekey" | "custom";
5
+ layout?: "widget" | "inline" | "embed" | "embedded";
5
6
  theme?: "light" | "dark" | "auto";
6
7
  size?: "compact" | "normal" | "invisible";
7
8
  action?: string;
@@ -46,6 +47,7 @@ export declare class Widget {
46
47
  message: string
47
48
  ): void;
48
49
  showError(message: string): void;
50
+ setErrorCountdown(seconds: number): void;
49
51
  clearError(): void;
50
52
  setState(state: string): void;
51
53
  getToken(): string | null;
package/src/widget.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  checkboxTemplate,
4
4
  heroTemplate,
5
5
  challengeTemplate,
6
+ errorStepTemplate,
6
7
  resultTemplate,
7
8
  resolveTheme,
8
9
  watchTheme,
@@ -26,6 +27,7 @@ export class Widget {
26
27
  this.popup = null;
27
28
  this.shadow = null;
28
29
  this.elements = {};
30
+ this.popupElements = null;
29
31
  this.onChallenge = null;
30
32
  this.onStartClick = null;
31
33
  this.popupFrame = 0;
@@ -38,6 +40,7 @@ export class Widget {
38
40
  const host = document.createElement('div');
39
41
  host.id = this.id;
40
42
  this.shadow = host.attachShadow({ mode: 'open' });
43
+ this.host = host;
41
44
 
42
45
  const style = document.createElement('style');
43
46
  style.textContent = STYLES;
@@ -50,7 +53,12 @@ export class Widget {
50
53
  if (this.params.size === 'invisible') {
51
54
  host.style.display = 'none';
52
55
  this.container.appendChild(host);
53
- this.host = host;
56
+ return;
57
+ }
58
+
59
+ if (this.isInlineLayout()) {
60
+ this.renderInlineShell();
61
+ this.container.appendChild(host);
54
62
  return;
55
63
  }
56
64
 
@@ -86,7 +94,32 @@ export class Widget {
86
94
  this.elements.errorSlot = this.shadow.querySelector('.oa-error-slot');
87
95
 
88
96
  this.container.appendChild(host);
89
- this.host = host;
97
+ }
98
+
99
+ isInlineLayout() {
100
+ return this.params.layout === 'inline';
101
+ }
102
+
103
+ renderInlineShell() {
104
+ const inlineShell = document.createElement('div');
105
+ inlineShell.className = 'oa-inline-shell';
106
+ inlineShell.innerHTML = this.buildPopupContent({ closeable: false });
107
+ this.shadow.appendChild(inlineShell);
108
+
109
+ this.popup = {
110
+ host: this.host,
111
+ root: inlineShell,
112
+ inline: true,
113
+ };
114
+
115
+ this.bindPopupEvents(inlineShell);
116
+ }
117
+
118
+ resetInlineShell() {
119
+ if (!this.popup?.root) return;
120
+
121
+ this.popup.root.innerHTML = this.buildPopupContent({ closeable: false });
122
+ this.bindPopupEvents(this.popup.root);
90
123
  }
91
124
 
92
125
  startChallenge() {
@@ -119,6 +152,14 @@ export class Widget {
119
152
  }
120
153
 
121
154
  openPopup() {
155
+ if (this.isInlineLayout()) {
156
+ if (!this.popup) {
157
+ this.renderInlineShell();
158
+ }
159
+
160
+ return this.getVideo();
161
+ }
162
+
122
163
  if (this.popup) return this.getVideo();
123
164
 
124
165
  const anchor = this.getPopupAnchor();
@@ -296,7 +337,7 @@ export class Widget {
296
337
  this.popup.root.style.pointerEvents = 'auto';
297
338
  }
298
339
 
299
- buildPopupContent() {
340
+ buildPopupContent({ closeable = true } = {}) {
300
341
  return `
301
342
  <div class="oa-header">
302
343
  <div class="oa-title">
@@ -307,10 +348,14 @@ export class Widget {
307
348
  </a>
308
349
  <span class="oa-badge">on-device</span>
309
350
  </div>
310
- <button class="oa-close-btn"
311
- aria-label="Close">
312
- ${CLOSE_SVG}
313
- </button>
351
+ ${
352
+ closeable
353
+ ? `<button class="oa-close-btn"
354
+ aria-label="Close">
355
+ ${CLOSE_SVG}
356
+ </button>`
357
+ : ''
358
+ }
314
359
  </div>
315
360
  <div class="oa-body">
316
361
  ${heroTemplate('Initializing…')}
@@ -323,7 +368,7 @@ export class Widget {
323
368
  `;
324
369
  }
325
370
 
326
- bindPopupEvents(root, shadow) {
371
+ bindPopupEvents(root) {
327
372
  const closeBtn = root.querySelector('.oa-close-btn');
328
373
  if (closeBtn) {
329
374
  closeBtn.addEventListener('click', () => {
@@ -441,6 +486,22 @@ export class Widget {
441
486
  }
442
487
 
443
488
  showResult(outcome, message) {
489
+ if (this.isInlineLayout()) {
490
+ if (!this.popupElements?.body) return;
491
+
492
+ this.popupElements.body.innerHTML = resultTemplate(outcome, message);
493
+ this.hideActions();
494
+
495
+ if (outcome === 'pass') {
496
+ this.setState('verified');
497
+ return;
498
+ }
499
+
500
+ this.setState(outcome === 'fail' ? 'failed' : 'retry');
501
+ this.showActions('Try Again');
502
+ return;
503
+ }
504
+
444
505
  if (outcome === 'pass') {
445
506
  this.closePopup();
446
507
  this.setState('verified');
@@ -476,8 +537,21 @@ export class Widget {
476
537
  }
477
538
  }
478
539
 
479
- showError() {
480
- this.setState('retry');
540
+ showError(message) {
541
+ if (!this.popupElements?.body) return;
542
+
543
+ this.popupElements.body.innerHTML = errorStepTemplate(message);
544
+ this.popupElements.errorCountdown = this.popupElements.body.querySelector(
545
+ '.oa-error-step-countdown'
546
+ );
547
+ this.hideActions();
548
+ }
549
+
550
+ setErrorCountdown(seconds) {
551
+ if (!this.popupElements?.errorCountdown) return;
552
+
553
+ const unit = seconds === 1 ? 'second' : 'seconds';
554
+ this.popupElements.errorCountdown.textContent = `Closing in ${seconds} ${unit}…`;
481
555
  }
482
556
 
483
557
  clearError() {
@@ -488,6 +562,22 @@ export class Widget {
488
562
 
489
563
  closePopup() {
490
564
  if (!this.popup) return;
565
+
566
+ if (this.popup.inline) {
567
+ this.popup.cleanup?.();
568
+ if (this.popupFrame) {
569
+ cancelAnimationFrame(this.popupFrame);
570
+ this.popupFrame = 0;
571
+ }
572
+ this.resetInlineShell();
573
+
574
+ if (this.state === 'loading') {
575
+ this.setState('idle');
576
+ }
577
+
578
+ return;
579
+ }
580
+
491
581
  this.popup.cleanup?.();
492
582
  this.popup.themeCleanup?.();
493
583
  this.popup.host.remove();