@tn3w/openage 1.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.
@@ -0,0 +1,220 @@
1
+ import {
2
+ TASK_TIMEOUT_MS,
3
+ MIN_TASK_TIME_MS,
4
+ TASK_COUNT,
5
+ REQUIRED_TASK_PASSES,
6
+ } from './constants.js';
7
+
8
+ const TASKS = [
9
+ {
10
+ id: 'turn-left',
11
+ instruction: 'Turn your head to the left',
12
+ check: (h) => detectYawShift(h, 20),
13
+ },
14
+ {
15
+ id: 'turn-right',
16
+ instruction: 'Turn your head to the right',
17
+ check: (h) => detectYawShift(h, -20),
18
+ },
19
+ {
20
+ id: 'nod',
21
+ instruction: 'Nod your head down then up',
22
+ check: (h) => detectNod(h),
23
+ },
24
+ {
25
+ id: 'blink-twice',
26
+ instruction: 'Blink twice',
27
+ check: (h) => detectDoubleBlink(h),
28
+ },
29
+ {
30
+ id: 'move-closer',
31
+ instruction: 'Move closer then back',
32
+ check: (h) => detectDistanceChange(h),
33
+ },
34
+ ];
35
+
36
+ export function pickTasks(count = TASK_COUNT) {
37
+ const shuffled = [...TASKS].sort(() => Math.random() - 0.5);
38
+ return shuffled.slice(0, count);
39
+ }
40
+
41
+ export function createSession(tasks) {
42
+ return {
43
+ tasks: tasks || pickTasks(),
44
+ currentIndex: 0,
45
+ history: [],
46
+ taskStartTime: 0,
47
+ completedTasks: 0,
48
+ requiredPasses: REQUIRED_TASK_PASSES,
49
+ failed: false,
50
+ failReason: null,
51
+ };
52
+ }
53
+
54
+ export function processFrame(session, trackingResult) {
55
+ if (session.failed || isComplete(session)) return;
56
+ if (session.currentIndex >= session.tasks.length) return;
57
+ if (!trackingResult || trackingResult.faceCount === 0) return;
58
+
59
+ if (trackingResult.faceCount > 1) {
60
+ session.failed = true;
61
+ session.failReason = null;
62
+ return;
63
+ }
64
+
65
+ const entry = {
66
+ timestamp: trackingResult.timestampMs,
67
+ headPose: trackingResult.headPose,
68
+ blendshapes: trackingResult.blendshapes,
69
+ boundingBox: trackingResult.boundingBox,
70
+ };
71
+
72
+ if (session.history.length === 0) {
73
+ session.taskStartTime = trackingResult.timestampMs;
74
+ }
75
+
76
+ session.history.push(entry);
77
+
78
+ const elapsed = trackingResult.timestampMs - session.taskStartTime;
79
+
80
+ if (elapsed > TASK_TIMEOUT_MS) {
81
+ advanceTask(session);
82
+ return;
83
+ }
84
+
85
+ if (elapsed < MIN_TASK_TIME_MS) return;
86
+
87
+ const task = session.tasks[session.currentIndex];
88
+ if (!task.check(session.history)) return;
89
+
90
+ if (isSuspicious(session.history)) {
91
+ session.failed = true;
92
+ session.failReason = null;
93
+ return;
94
+ }
95
+
96
+ session.completedTasks++;
97
+ advanceTask(session);
98
+ }
99
+
100
+ export function isComplete(session) {
101
+ return session.currentIndex >= session.tasks.length;
102
+ }
103
+
104
+ export function isPassed(session) {
105
+ return session.completedTasks >= session.requiredPasses;
106
+ }
107
+
108
+ export function currentInstruction(session) {
109
+ if (session.currentIndex >= session.tasks.length) {
110
+ return null;
111
+ }
112
+ return session.tasks[session.currentIndex].instruction;
113
+ }
114
+
115
+ export function currentTaskId(session) {
116
+ if (session.currentIndex >= session.tasks.length) {
117
+ return null;
118
+ }
119
+ return session.tasks[session.currentIndex].id;
120
+ }
121
+
122
+ export function progress(session) {
123
+ if (session.tasks.length === 0) return 1;
124
+ return Math.min(session.currentIndex / session.tasks.length, 1);
125
+ }
126
+
127
+ function advanceTask(session) {
128
+ session.currentIndex++;
129
+ session.history = [];
130
+ session.taskStartTime = 0;
131
+
132
+ const remaining = session.tasks.length - session.currentIndex;
133
+ const canStillPass = session.completedTasks + remaining >= session.requiredPasses;
134
+
135
+ if (!canStillPass) {
136
+ session.failed = true;
137
+ session.failReason = null;
138
+ }
139
+ }
140
+
141
+ function detectYawShift(history, targetDelta) {
142
+ if (history.length < 5) return false;
143
+ const baseYaw = history[0].headPose.yaw;
144
+ const direction = Math.sign(targetDelta);
145
+ const threshold = Math.abs(targetDelta);
146
+
147
+ return history.some((entry) => {
148
+ const delta = (entry.headPose.yaw - baseYaw) * direction;
149
+ return delta > threshold;
150
+ });
151
+ }
152
+
153
+ function detectNod(history) {
154
+ if (history.length < 10) return false;
155
+ const basePitch = history[0].headPose.pitch;
156
+ let wentDown = false;
157
+ let cameBack = false;
158
+
159
+ for (const entry of history) {
160
+ const delta = entry.headPose.pitch - basePitch;
161
+ if (delta > 15) wentDown = true;
162
+ if (wentDown && Math.abs(delta) < 8) cameBack = true;
163
+ }
164
+
165
+ return wentDown && cameBack;
166
+ }
167
+
168
+ function detectDoubleBlink(history) {
169
+ if (history.length < 10) return false;
170
+ let blinkCount = 0;
171
+ let eyesClosed = false;
172
+
173
+ for (const entry of history) {
174
+ const left = entry.blendshapes.eyeBlinkLeft ?? 0;
175
+ const right = entry.blendshapes.eyeBlinkRight ?? 0;
176
+ const bothClosed = left > 0.6 && right > 0.6;
177
+
178
+ if (bothClosed && !eyesClosed) {
179
+ blinkCount++;
180
+ eyesClosed = true;
181
+ } else if (!bothClosed) {
182
+ eyesClosed = false;
183
+ }
184
+ }
185
+
186
+ return blinkCount >= 2;
187
+ }
188
+
189
+ function detectDistanceChange(history) {
190
+ if (history.length < 10) return false;
191
+ const baseArea = history[0].boundingBox.area;
192
+ let wentCloser = false;
193
+ let cameBack = false;
194
+
195
+ for (const entry of history) {
196
+ const ratio = entry.boundingBox.area / baseArea;
197
+ if (ratio > 1.3) wentCloser = true;
198
+ if (wentCloser && ratio < 1.15) cameBack = true;
199
+ }
200
+
201
+ return wentCloser && cameBack;
202
+ }
203
+
204
+ export function isSuspicious(history) {
205
+ if (history.length < 5) return false;
206
+
207
+ const deltas = [];
208
+ for (let i = 1; i < history.length; i++) {
209
+ const dy = Math.abs(history[i].headPose.yaw - history[i - 1].headPose.yaw);
210
+ const dp = Math.abs(history[i].headPose.pitch - history[i - 1].headPose.pitch);
211
+ deltas.push(dy + dp);
212
+ }
213
+
214
+ if (deltas.every((d) => d < 0.1)) return true;
215
+
216
+ const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length;
217
+ const variance = deltas.reduce((s, d) => s + (d - mean) ** 2, 0) / deltas.length;
218
+
219
+ return variance < 0.01 && mean > 0.5;
220
+ }
@@ -0,0 +1,13 @@
1
+ export interface PositioningCallbacks {
2
+ onStatus?: (text: string) => void;
3
+ onReady?: () => void;
4
+ }
5
+
6
+ export interface PositioningHandle {
7
+ cancel: () => void;
8
+ }
9
+
10
+ export declare function startPositioning(
11
+ video: HTMLVideoElement,
12
+ callbacks: PositioningCallbacks
13
+ ): PositioningHandle;
@@ -0,0 +1,39 @@
1
+ import { track } from './face-tracker.js';
2
+ import { STABLE_FRAMES_REQUIRED, POSITION_CHECK_MS } from './constants.js';
3
+
4
+ export function startPositioning(video, callbacks) {
5
+ let stableFrames = 0;
6
+ let cancelled = false;
7
+
8
+ const check = () => {
9
+ if (cancelled) return;
10
+
11
+ const result = track(video, performance.now());
12
+
13
+ if (!result || result.faceCount === 0) {
14
+ callbacks.onStatus?.('Look at the camera');
15
+ stableFrames = 0;
16
+ } else if (result.faceCount > 1) {
17
+ callbacks.onStatus?.('Only one person please');
18
+ stableFrames = 0;
19
+ } else {
20
+ callbacks.onStatus?.('Hold still…');
21
+ stableFrames++;
22
+ }
23
+
24
+ if (stableFrames >= STABLE_FRAMES_REQUIRED) {
25
+ callbacks.onReady?.();
26
+ return;
27
+ }
28
+
29
+ setTimeout(check, POSITION_CHECK_MS);
30
+ };
31
+
32
+ check();
33
+
34
+ return {
35
+ cancel: () => {
36
+ cancelled = true;
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,61 @@
1
+ export interface TokenPayload {
2
+ ageConfirmed: boolean;
3
+ estimatedAge: number;
4
+ minAge: number;
5
+ mode: string;
6
+ iat: number;
7
+ exp: number;
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ export declare function createToken(
12
+ payload: Record<string, unknown>
13
+ ): Promise<string>;
14
+
15
+ export declare function verifyToken(
16
+ token: string
17
+ ): Promise<TokenPayload | null>;
18
+
19
+ export declare function decodeToken(
20
+ token: string
21
+ ): TokenPayload | null;
22
+
23
+ export interface TransportResult {
24
+ success: boolean;
25
+ ageConfirmed: boolean;
26
+ token?: string | null;
27
+ error?: string;
28
+ }
29
+
30
+ export interface ServerSession {
31
+ sessionId: string;
32
+ transport: "websocket" | "poll";
33
+ [key: string]: unknown;
34
+ }
35
+
36
+ export interface Transport {
37
+ verify(
38
+ payload: Record<string, unknown>
39
+ ): Promise<TransportResult>;
40
+ createSession?(): Promise<ServerSession>;
41
+ openChannel?(): void;
42
+ receive?(): Promise<unknown>;
43
+ send?(data: unknown): Promise<void>;
44
+ sendAndReceive?(
45
+ data: unknown
46
+ ): Promise<unknown | null>;
47
+ getSession?(): ServerSession | null;
48
+ close(): void;
49
+ }
50
+
51
+ export interface TransportOptions {
52
+ server?: string;
53
+ sitekey?: string;
54
+ action?: string;
55
+ minAge?: number;
56
+ }
57
+
58
+ export declare function createTransport(
59
+ mode: "serverless" | "sitekey" | "custom",
60
+ options?: TransportOptions
61
+ ): Transport;
@@ -0,0 +1,305 @@
1
+ import { TOKEN_EXPIRY_S } from './constants.js';
2
+
3
+ function base64UrlEncode(data) {
4
+ const text = typeof data === 'string' ? data : String.fromCharCode(...new Uint8Array(data));
5
+ return btoa(text).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
6
+ }
7
+
8
+ function base64UrlDecode(str) {
9
+ const padded = str + '='.repeat((4 - (str.length % 4)) % 4);
10
+ const binary = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
11
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0));
12
+ }
13
+
14
+ async function getSigningKey() {
15
+ const raw = crypto.getRandomValues(new Uint8Array(32));
16
+ return crypto.subtle.importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, [
17
+ 'sign',
18
+ 'verify',
19
+ ]);
20
+ }
21
+
22
+ let cachedKey = null;
23
+
24
+ async function ensureKey() {
25
+ if (!cachedKey) cachedKey = await getSigningKey();
26
+ return cachedKey;
27
+ }
28
+
29
+ export async function createToken(payload) {
30
+ const key = await ensureKey();
31
+
32
+ const header = base64UrlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
33
+
34
+ const body = base64UrlEncode(
35
+ JSON.stringify({
36
+ ...payload,
37
+ iat: Math.floor(Date.now() / 1000),
38
+ exp: Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_S,
39
+ })
40
+ );
41
+
42
+ const data = new TextEncoder().encode(`${header}.${body}`);
43
+ const signature = await crypto.subtle.sign('HMAC', key, data);
44
+
45
+ return `${header}.${body}.${base64UrlEncode(signature)}`;
46
+ }
47
+
48
+ export async function verifyToken(token) {
49
+ const key = await ensureKey();
50
+ const [header, body, sig] = token.split('.');
51
+
52
+ if (!header || !body || !sig) return null;
53
+
54
+ let signature;
55
+ let data;
56
+ try {
57
+ data = new TextEncoder().encode(`${header}.${body}`);
58
+ signature = base64UrlDecode(sig);
59
+ } catch {
60
+ return null;
61
+ }
62
+
63
+ const valid = await crypto.subtle.verify('HMAC', key, signature, data);
64
+
65
+ if (!valid) return null;
66
+
67
+ const decoded = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));
68
+
69
+ if (decoded.exp && decoded.exp < Date.now() / 1000) {
70
+ return null;
71
+ }
72
+
73
+ return decoded;
74
+ }
75
+
76
+ export function decodeToken(token) {
77
+ const [, body] = token.split('.');
78
+ if (!body) return null;
79
+ return JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));
80
+ }
81
+
82
+ export function createTransport(mode, options = {}) {
83
+ if (mode === 'serverless') {
84
+ return createServerlessTransport(options);
85
+ }
86
+
87
+ const baseUrl = mode === 'custom' ? options.server : 'https://api.openage.dev';
88
+
89
+ return createServerTransport(baseUrl, options);
90
+ }
91
+
92
+ function createServerlessTransport(options) {
93
+ return {
94
+ async verify(payload) {
95
+ const { estimatedAge, livenessOk } = payload;
96
+
97
+ if (!livenessOk) {
98
+ return { success: false, token: null };
99
+ }
100
+
101
+ const token = await createToken({
102
+ estimatedAge,
103
+ livenessOk: true,
104
+ mode: 'serverless',
105
+ });
106
+
107
+ return { success: true, token };
108
+ },
109
+ close() {},
110
+ };
111
+ }
112
+
113
+ function createServerTransport(baseUrl, options) {
114
+ let session = null;
115
+ let channel = null;
116
+
117
+ return {
118
+ async createSession() {
119
+ const transports = [];
120
+ if (typeof WebSocket !== 'undefined') {
121
+ transports.push('websocket');
122
+ }
123
+ transports.push('poll');
124
+
125
+ const response = await fetch(`${baseUrl}/api/session`, {
126
+ method: 'POST',
127
+ headers: {
128
+ 'Content-Type': 'application/json',
129
+ },
130
+ body: JSON.stringify({
131
+ sitekey: options.sitekey,
132
+ action: options.action,
133
+ supportedTransports: transports,
134
+ }),
135
+ });
136
+
137
+ if (!response.ok) {
138
+ throw new Error('Request failed');
139
+ }
140
+
141
+ session = await response.json();
142
+ return session;
143
+ },
144
+
145
+ openChannel() {
146
+ if (!session) {
147
+ throw new Error('No session');
148
+ }
149
+
150
+ if (session.transport === 'websocket') {
151
+ channel = createWsChannel(baseUrl, session.sessionId);
152
+ } else {
153
+ channel = createPollChannel(baseUrl, session.sessionId);
154
+ }
155
+ },
156
+
157
+ async receive() {
158
+ if (!channel) return null;
159
+ return channel.receive();
160
+ },
161
+
162
+ async send(data) {
163
+ if (!channel) return;
164
+ return channel.send(data);
165
+ },
166
+
167
+ async sendAndReceive(data) {
168
+ if (!channel) return null;
169
+ return channel.sendAndReceive(data);
170
+ },
171
+
172
+ async verify(payload) {
173
+ if (channel) {
174
+ return this.verifyViaChannel(payload);
175
+ }
176
+
177
+ const response = await fetch(`${baseUrl}/verify`, {
178
+ method: 'POST',
179
+ headers: {
180
+ 'Content-Type': 'application/json',
181
+ },
182
+ body: JSON.stringify({
183
+ response: payload.token,
184
+ sitekey: options.sitekey,
185
+ action: options.action,
186
+ }),
187
+ });
188
+
189
+ if (!response.ok) {
190
+ return {
191
+ success: false,
192
+ token: null,
193
+ };
194
+ }
195
+
196
+ return response.json();
197
+ },
198
+
199
+ async verifyViaChannel(payload) {
200
+ await channel.send(payload);
201
+ return channel.receive();
202
+ },
203
+
204
+ getSession() {
205
+ return session;
206
+ },
207
+
208
+ close() {
209
+ channel?.close();
210
+ channel = null;
211
+ session = null;
212
+ },
213
+ };
214
+ }
215
+
216
+ function createWsChannel(baseUrl, sessionId) {
217
+ const wsUrl = baseUrl.replace(/^http/, 'ws').replace(/\/$/, '');
218
+ const url = `${wsUrl}/api/ws/${sessionId}`;
219
+ const ws = new WebSocket(url);
220
+ let pending = [];
221
+ let ready = false;
222
+ let closed = false;
223
+
224
+ const waitReady = new Promise((resolve, reject) => {
225
+ ws.onopen = () => {
226
+ ready = true;
227
+ resolve();
228
+ };
229
+ ws.onerror = () => {
230
+ reject(new Error('Connection failed'));
231
+ };
232
+ });
233
+
234
+ ws.onmessage = (event) => {
235
+ const message = JSON.parse(event.data);
236
+ const resolver = pending.shift();
237
+ if (resolver) resolver(message);
238
+ };
239
+
240
+ ws.onclose = () => {
241
+ closed = true;
242
+ for (const resolver of pending) resolver(null);
243
+ pending = [];
244
+ };
245
+
246
+ return {
247
+ receive() {
248
+ if (closed) return Promise.resolve(null);
249
+ return new Promise((resolve) => {
250
+ pending.push(resolve);
251
+ });
252
+ },
253
+ async send(data) {
254
+ await waitReady;
255
+ ws.send(JSON.stringify(data));
256
+ },
257
+ async sendAndReceive(data) {
258
+ await waitReady;
259
+ ws.send(JSON.stringify(data));
260
+ return this.receive();
261
+ },
262
+ close() {
263
+ closed = true;
264
+ ws.close();
265
+ },
266
+ };
267
+ }
268
+
269
+ function createPollChannel(baseUrl, sessionId) {
270
+ return {
271
+ async receive() {
272
+ const response = await fetch(`${baseUrl}/api/poll/${sessionId}`);
273
+ if (!response.ok) {
274
+ throw new Error('Request failed');
275
+ }
276
+ return response.json();
277
+ },
278
+ async send(data) {
279
+ const response = await fetch(`${baseUrl}/api/verify/${sessionId}`, {
280
+ method: 'POST',
281
+ headers: {
282
+ 'Content-Type': 'application/json',
283
+ },
284
+ body: JSON.stringify(data),
285
+ });
286
+ if (!response.ok) {
287
+ throw new Error('Request failed');
288
+ }
289
+ },
290
+ async sendAndReceive(data) {
291
+ const response = await fetch(`${baseUrl}/api/verify/${sessionId}`, {
292
+ method: 'POST',
293
+ headers: {
294
+ 'Content-Type': 'application/json',
295
+ },
296
+ body: JSON.stringify(data),
297
+ });
298
+ if (!response.ok) {
299
+ throw new Error('Request failed');
300
+ }
301
+ return response.json();
302
+ },
303
+ close() {},
304
+ };
305
+ }
package/src/ui.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ export declare function resolveTheme(
2
+ theme: "light" | "dark" | "auto"
3
+ ): "light" | "dark";
4
+
5
+ export declare function watchTheme(
6
+ host: HTMLElement,
7
+ theme: "light" | "dark" | "auto"
8
+ ): (() => void) | undefined;
9
+
10
+ export declare function checkboxTemplate(
11
+ labelText: string
12
+ ): string;
13
+
14
+ export declare function heroTemplate(
15
+ statusText: string
16
+ ): string;
17
+
18
+ export declare function challengeTemplate(): string;
19
+
20
+ export declare function resultTemplate(
21
+ outcome: "fail" | "retry",
22
+ message: string
23
+ ): string;
24
+
25
+ export declare function errorBannerTemplate(
26
+ message: string
27
+ ): string;
28
+
29
+ export declare const FACE_SVG: string;
30
+ export declare const FACE_ICON_SVG: string;
31
+ export declare const FACE_GUIDE_SVG: string;
32
+ export declare const CHECK_SVG: string;
33
+ export declare const CLOSE_SVG: string;
34
+ export declare const RETRY_SVG: string;
35
+ export declare const SPINNER_SVG: string;
36
+ export declare const SHIELD_SVG: string;
37
+ export declare const STYLES: string;