@kuratchi/auth 0.0.1

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,275 @@
1
+ /**
2
+ * @kuratchi/auth — Turnstile API
3
+ *
4
+ * Config-driven Cloudflare Turnstile bot protection.
5
+ * Runs in the worker entry before route handlers.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * // In kuratchi.config.ts:
10
+ * auth: {
11
+ * turnstile: {
12
+ * secretEnv: 'TURNSTILE_SECRET',
13
+ * routes: [
14
+ * { path: '/auth/login', methods: ['POST'] },
15
+ * { path: '/auth/signup', methods: ['POST'] },
16
+ * ]
17
+ * }
18
+ * }
19
+ * ```
20
+ */
21
+
22
+ const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
23
+
24
+ const DEFAULT_FIELD_NAMES = ['cf-turnstile-response', 'turnstileToken', 'turnstile_token'];
25
+ const DEFAULT_HEADER_NAMES = ['cf-turnstile-token', 'x-turnstile-token'];
26
+
27
+ // ============================================================================
28
+ // Types
29
+ // ============================================================================
30
+
31
+ export interface TurnstileRouteConfig {
32
+ /** Unique identifier (defaults to path) */
33
+ id?: string;
34
+ /** Path matcher — literal path or glob with * */
35
+ path: string;
36
+ /** HTTP methods to match (defaults to ['POST']) */
37
+ methods?: string[];
38
+ /** Custom field name to read the token from */
39
+ tokenField?: string;
40
+ /** Custom header name to read the token from */
41
+ tokenHeader?: string;
42
+ /** Override failure message */
43
+ message?: string;
44
+ /** Expected Turnstile action value(s) */
45
+ expectedAction?: string;
46
+ }
47
+
48
+ export interface TurnstileConfig {
49
+ /** Env var name for Turnstile secret (default: 'TURNSTILE_SECRET') */
50
+ secretEnv?: string;
51
+ /** Env var name for Turnstile site key (default: 'TURNSTILE_SITE_KEY') — exposed to client */
52
+ siteKeyEnv?: string;
53
+ /** Skip Turnstile verification in dev mode (default: true) */
54
+ skipInDev?: boolean;
55
+ /** Routes that require Turnstile verification */
56
+ routes?: TurnstileRouteConfig[];
57
+ }
58
+
59
+ // ============================================================================
60
+ // Module state
61
+ // ============================================================================
62
+
63
+ let _config: TurnstileConfig | null = null;
64
+
65
+ /**
66
+ * Configure Turnstile. Called automatically by the compiler from kuratchi.config.ts.
67
+ */
68
+ export function configureTurnstile(config: TurnstileConfig): void {
69
+ _config = config;
70
+ }
71
+
72
+ // ============================================================================
73
+ // Framework context
74
+ // ============================================================================
75
+
76
+ function _getContext() {
77
+ const dezContext = (globalThis as any).__kuratchi_context__;
78
+ const env = (globalThis as any).__cloudflare_env__ ?? {};
79
+ return {
80
+ request: dezContext?.request as Request | undefined,
81
+ locals: dezContext?.locals as Record<string, any> ?? {},
82
+ env,
83
+ };
84
+ }
85
+
86
+ // ============================================================================
87
+ // Pattern matching
88
+ // ============================================================================
89
+
90
+ function _matchPath(pathname: string, pattern: string): boolean {
91
+ const trimmed = pattern !== '/' && pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
92
+ const escaped = trimmed
93
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
94
+ .replace(/\\\*/g, '.*');
95
+ return new RegExp(`^${escaped}/?$`, 'i').test(pathname);
96
+ }
97
+
98
+ // ============================================================================
99
+ // Token extraction
100
+ // ============================================================================
101
+
102
+ async function _extractToken(request: Request, route: TurnstileRouteConfig): Promise<string | null> {
103
+ // Check headers first
104
+ const headerNames = route.tokenHeader ? [route.tokenHeader] : DEFAULT_HEADER_NAMES;
105
+ for (const name of headerNames) {
106
+ const val = request.headers.get(name);
107
+ if (val) return val;
108
+ }
109
+
110
+ // Check body
111
+ const contentType = request.headers.get('content-type') || '';
112
+
113
+ if (contentType.includes('application/json')) {
114
+ try {
115
+ const cloned = request.clone();
116
+ const json = await cloned.json() as Record<string, any>;
117
+ const fieldNames = route.tokenField ? [route.tokenField] : DEFAULT_FIELD_NAMES;
118
+ for (const name of fieldNames) {
119
+ if (json[name]) return String(json[name]);
120
+ }
121
+ } catch { /* ignore */ }
122
+ }
123
+
124
+ if (contentType.includes('form')) {
125
+ try {
126
+ const cloned = request.clone();
127
+ const formData = await cloned.formData();
128
+ const fieldNames = route.tokenField ? [route.tokenField] : DEFAULT_FIELD_NAMES;
129
+ for (const name of fieldNames) {
130
+ const val = formData.get(name);
131
+ if (val) return String(val);
132
+ }
133
+ } catch { /* ignore */ }
134
+ }
135
+
136
+ return null;
137
+ }
138
+
139
+ // ============================================================================
140
+ // Public API
141
+ // ============================================================================
142
+
143
+ /**
144
+ * Check Turnstile verification for the current request.
145
+ * Called by the compiler-generated worker entry before route handlers.
146
+ * Returns a 403 Response if verification fails, or null to proceed.
147
+ */
148
+ export function checkTurnstile(): Promise<Response | null> {
149
+ return _checkTurnstileAsync();
150
+ }
151
+
152
+ async function _checkTurnstileAsync(): Promise<Response | null> {
153
+ if (!_config?.routes?.length) return null;
154
+
155
+ // Skip in dev mode (default: true)
156
+ if (_config.skipInDev !== false) {
157
+ const env = (globalThis as any).__cloudflare_env__ ?? {};
158
+ // Wrangler local dev sets no specific flag, but we can check if TURNSTILE_SECRET is missing
159
+ // or use the __DEV__ flag if the compiler sets it
160
+ if ((globalThis as any).__kuratchi_DEV__) return null;
161
+ }
162
+
163
+ const { request, env } = _getContext();
164
+ if (!request) return null;
165
+
166
+ const url = new URL(request.url);
167
+ const method = request.method.toUpperCase();
168
+ const pathname = url.pathname;
169
+
170
+ // Find matching route
171
+ const matched = _config.routes.find(route => {
172
+ if (!_matchPath(pathname, route.path)) return false;
173
+ const methods = route.methods || ['POST'];
174
+ return methods.map(m => m.toUpperCase()).includes(method);
175
+ });
176
+
177
+ if (!matched) return null;
178
+
179
+ // Resolve secret from env
180
+ const secretKey = _config.secretEnv || 'TURNSTILE_SECRET';
181
+ const secret = env[secretKey];
182
+ if (!secret) return null; // No secret configured — skip
183
+
184
+ // Extract token
185
+ const token = await _extractToken(request, matched);
186
+ if (!token) {
187
+ return new Response(JSON.stringify({
188
+ error: 'turnstile_token_missing',
189
+ message: matched.message || 'Turnstile verification required.',
190
+ }), {
191
+ status: 403,
192
+ headers: { 'Content-Type': 'application/json' },
193
+ });
194
+ }
195
+
196
+ // Verify with Cloudflare
197
+ const ip = request.headers.get('cf-connecting-ip')
198
+ || request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
199
+ || undefined;
200
+
201
+ const verifyBody: Record<string, string> = { secret, response: token };
202
+ if (ip) verifyBody.remoteip = ip;
203
+
204
+ const verifyRes = await fetch(TURNSTILE_VERIFY_URL, {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
207
+ body: new URLSearchParams(verifyBody),
208
+ });
209
+
210
+ const result: any = await verifyRes.json();
211
+
212
+ if (!result.success) {
213
+ return new Response(JSON.stringify({
214
+ error: 'turnstile_verification_failed',
215
+ message: matched.message || 'Bot verification failed.',
216
+ details: result['error-codes'],
217
+ }), {
218
+ status: 403,
219
+ headers: { 'Content-Type': 'application/json' },
220
+ });
221
+ }
222
+
223
+ // Check expected action
224
+ if (matched.expectedAction && result.action && result.action !== matched.expectedAction) {
225
+ return new Response(JSON.stringify({
226
+ error: 'turnstile_action_mismatch',
227
+ message: 'Turnstile action mismatch.',
228
+ }), {
229
+ status: 403,
230
+ headers: { 'Content-Type': 'application/json' },
231
+ });
232
+ }
233
+
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * Verify a Turnstile token manually (for use in server functions).
239
+ */
240
+ export async function verifyTurnstile(token: string, options?: {
241
+ expectedAction?: string;
242
+ }): Promise<{ success: boolean; error?: string }> {
243
+ const { env } = _getContext();
244
+ const secretKey = _config?.secretEnv || 'TURNSTILE_SECRET';
245
+ const secret = env[secretKey];
246
+
247
+ if (!secret) return { success: false, error: 'TURNSTILE_SECRET not configured' };
248
+
249
+ const { request } = _getContext();
250
+ const ip = request?.headers.get('cf-connecting-ip') || undefined;
251
+
252
+ const verifyBody: Record<string, string> = { secret, response: token };
253
+ if (ip) verifyBody.remoteip = ip;
254
+
255
+ const res = await fetch(TURNSTILE_VERIFY_URL, {
256
+ method: 'POST',
257
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
258
+ body: new URLSearchParams(verifyBody),
259
+ });
260
+
261
+ const result: any = await res.json();
262
+
263
+ if (!result.success) {
264
+ return { success: false, error: result['error-codes']?.join(', ') || 'Verification failed' };
265
+ }
266
+
267
+ if (options?.expectedAction && result.action !== options.expectedAction) {
268
+ return { success: false, error: 'Action mismatch' };
269
+ }
270
+
271
+ return { success: true };
272
+ }
273
+
274
+
275
+
package/src/index.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @kuratchi/auth — Config-driven auth for KuratchiJS
3
+ *
4
+ * All auth features are configured in kuratchi.config.ts and auto-initialized
5
+ * by the compiler. Import callable functions directly:
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { signUp, signIn, getCurrentUser, logActivity } from '@kuratchi/auth';
10
+ *
11
+ * const user = await getCurrentUser();
12
+ * await logActivity('user.login', { detail: 'Logged in' });
13
+ * ```
14
+ */
15
+
16
+ // Core — low-level auth context (cookie/session access)
17
+ export { getAuth } from './core/auth.js';
18
+ export type { AuthContext, AuthSession, CookieOptions, GetAuthOptions } from './core/auth.js';
19
+
20
+ // Credentials — signup, signin, signout, session lookup, password reset
21
+ export { signUp, signIn, signOut, getCurrentUser, configureCredentials, requestPasswordReset, resetPassword } from './core/credentials.js';
22
+ export type { CredentialsConfig } from './core/credentials.js';
23
+
24
+ // Activity — structured audit logging
25
+ export { logActivity, getActivity, defineActivities, getActivityDefinitions } from './core/activity.js';
26
+ export type { LogActivityOptions, GetActivityOptions, ActivityConfig } from './core/activity.js';
27
+
28
+ // Roles — RBAC with permission wildcards
29
+ export { defineRoles, hasRole, hasPermission, getPermissionsForRole, assignRole, getRolesData, getRoleDefinitions, getAllRoles, getDefaultRole } from './core/roles.js';
30
+
31
+ // OAuth — provider-based authentication
32
+ export { configureOAuth, getOAuthData, startOAuth, handleOAuthCallback, getOAuthProviders } from './core/oauth.js';
33
+ export type { OAuthConfig } from './core/oauth.js';
34
+
35
+ // Guards — route protection (compiler interceptor)
36
+ export { configureGuards, checkGuard, requireAuth as requireAuthGuard } from './core/guards.js';
37
+ export type { GuardsConfig } from './core/guards.js';
38
+
39
+ // Rate Limiting — per-route throttling (compiler interceptor)
40
+ export { configureRateLimit, checkRateLimit, getRateLimitInfo } from './core/rate-limit.js';
41
+ export type { RateLimitConfig, RateLimitRouteConfig as RateLimitRoute } from './core/rate-limit.js';
42
+
43
+ // Turnstile — Cloudflare bot protection (compiler interceptor)
44
+ export { configureTurnstile, checkTurnstile, verifyTurnstile } from './core/turnstile.js';
45
+ export type { TurnstileConfig, TurnstileRouteConfig as TurnstileRoute } from './core/turnstile.js';
46
+
47
+ // Organization — multi-tenant DO orchestration
48
+ export { configureOrganization, getOrgClient, getOrgStubByName, createOrgDatabase, getOrgDatabaseInfo, resolveOrgDatabaseName, isOrgAvailable } from './core/organization.js';
49
+ export type { OrganizationConfig } from './core/organization.js';
50
+
51
+ // Activity action constants
52
+ export { ActivityAction, getActivityActions, isValidAction } from './utils/activity-actions.js';
53
+
54
+ // Crypto utilities
55
+ export {
56
+ generateSessionToken,
57
+ hashPassword,
58
+ comparePassword,
59
+ hashToken,
60
+ buildSessionCookie,
61
+ parseSessionCookie,
62
+ signState,
63
+ verifyState,
64
+ toBase64Url,
65
+ fromBase64Url,
66
+ } from './utils/crypto.js';
67
+
68
+
69
+
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Activity Action Constants
3
+ *
4
+ * Provides typed constants for activity actions loaded from the database.
5
+ * This is populated by the activityPlugin during initialization.
6
+ */
7
+
8
+ // Cache for activity types
9
+ let activityTypesCache: Record<string, string> = {};
10
+
11
+ /**
12
+ * Activity Action Constants
13
+ * Provides autocomplete for activity actions
14
+ *
15
+ * Usage: ActivityAction.ORGANIZATION_CREATED
16
+ */
17
+ export const ActivityAction = new Proxy({} as Record<string, string>, {
18
+ get(_target, prop: string) {
19
+ // Return cached value if available
20
+ if (prop in activityTypesCache) {
21
+ return activityTypesCache[prop];
22
+ }
23
+
24
+ // If not cached, convert constant name to action string
25
+ // ORGANIZATION_CREATED -> organization.created
26
+ return prop.toLowerCase().replace(/_/g, '.');
27
+ }
28
+ });
29
+
30
+ /**
31
+ * Internal: Update the activity types cache
32
+ * Called by activityPlugin during initialization
33
+ */
34
+ export function _updateActivityTypesCache(types: Record<string, string>) {
35
+ activityTypesCache = types;
36
+ }
37
+
38
+ /**
39
+ * Get all available activity actions
40
+ */
41
+ export function getActivityActions(): string[] {
42
+ return Object.values(activityTypesCache);
43
+ }
44
+
45
+ /**
46
+ * Check if an action is valid
47
+ */
48
+ export function isValidAction(action: string): boolean {
49
+ return Object.values(activityTypesCache).includes(action);
50
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * @kuratchi/auth — Crypto utilities
3
+ * Pure WebCrypto API — works in Cloudflare Workers, Node 20+, Deno, Bun
4
+ */
5
+
6
+ /**
7
+ * Generate a secure random session token using Web Crypto API
8
+ * @returns A base64url encoded random string
9
+ */
10
+ export function generateSessionToken(): string {
11
+ const bytes = new Uint8Array(20);
12
+ crypto.getRandomValues(bytes);
13
+ return toBase64Url(bytes);
14
+ }
15
+
16
+ /**
17
+ * Hash a password with PBKDF2 + optional pepper
18
+ * Returns "saltHex:hashHex"
19
+ */
20
+ export const hashPassword = async (password: string, providedSalt?: Uint8Array, pepper?: string): Promise<string> => {
21
+ const encoder = new TextEncoder();
22
+ const salt = providedSalt || crypto.getRandomValues(new Uint8Array(16));
23
+
24
+ const keyMaterial = await crypto.subtle.importKey(
25
+ 'raw',
26
+ encoder.encode(pepper ? `${password}:${pepper}` : password),
27
+ { name: 'PBKDF2' },
28
+ false,
29
+ ['deriveBits', 'deriveKey']
30
+ );
31
+
32
+ const key = await crypto.subtle.deriveKey(
33
+ {
34
+ name: 'PBKDF2',
35
+ salt: salt.buffer as ArrayBuffer,
36
+ iterations: 100000,
37
+ hash: 'SHA-256',
38
+ },
39
+ keyMaterial,
40
+ { name: 'AES-GCM', length: 256 },
41
+ true,
42
+ ['encrypt', 'decrypt']
43
+ );
44
+
45
+ const exportedKey = await crypto.subtle.exportKey('raw', key);
46
+ const hashArray = Array.from(new Uint8Array(exportedKey));
47
+ const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
48
+
49
+ const saltArray = Array.from(salt);
50
+ const saltHex = saltArray.map(b => b.toString(16).padStart(2, '0')).join('');
51
+
52
+ return `${saltHex}:${hashHex}`;
53
+ };
54
+
55
+ /**
56
+ * Compare a plaintext password against a stored hash
57
+ */
58
+ export const comparePassword = async (password: string, hashedPassword: string, pepper?: string): Promise<boolean> => {
59
+ const [salt, hash] = hashedPassword.split(':');
60
+ const saltMatches = salt.match(/.{1,2}/g);
61
+ if (!saltMatches) return false;
62
+ const saltBuffer = new Uint8Array(saltMatches.map(byte => parseInt(byte, 16)));
63
+
64
+ const _hash = await hashPassword(password, saltBuffer, pepper);
65
+ const [, _hashHex] = _hash.split(':');
66
+
67
+ return _hashHex === hash;
68
+ };
69
+
70
+ /**
71
+ * SHA-256 hash a token string
72
+ */
73
+ export const hashToken = async (token: string): Promise<string> => {
74
+ const encoder = new TextEncoder();
75
+ const data = encoder.encode(token);
76
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
77
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
78
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
79
+ };
80
+
81
+ // ==============================
82
+ // Base64url helpers
83
+ // ==============================
84
+
85
+ const _toBytes = (input: string | ArrayBuffer | Uint8Array) =>
86
+ typeof input === 'string' ? new TextEncoder().encode(input) : (input instanceof Uint8Array ? input : new Uint8Array(input));
87
+
88
+ const _bytesToBase64 = (bytes: Uint8Array) => {
89
+ const B = (globalThis as any).Buffer;
90
+ if (B) return B.from(bytes).toString('base64');
91
+ let binary = '';
92
+ for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
93
+ return btoa(binary);
94
+ };
95
+
96
+ const _base64ToBytes = (b64: string) => {
97
+ const B = (globalThis as any).Buffer;
98
+ if (B) return new Uint8Array(B.from(b64, 'base64'));
99
+ const binary = atob(b64);
100
+ const bytes = new Uint8Array(binary.length);
101
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
102
+ return bytes;
103
+ };
104
+
105
+ export const toBase64Url = (buf: ArrayBuffer | Uint8Array | string) =>
106
+ _bytesToBase64(_toBytes(buf)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
107
+
108
+ export const fromBase64Url = (b64url: string): Uint8Array => {
109
+ const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(b64url.length / 4) * 4, '=');
110
+ return _base64ToBytes(b64);
111
+ };
112
+
113
+ export function b64urlDecodeToString(str: string): string {
114
+ const decoded = fromBase64Url(str);
115
+ return new TextDecoder().decode(decoded);
116
+ }
117
+
118
+ // ==============================
119
+ // Session cookie encryption (AES-GCM)
120
+ // ==============================
121
+
122
+ const importAesGcmKey = async (secret: string): Promise<CryptoKey> => {
123
+ if (!secret) {
124
+ throw new Error('[kuratchi/auth] Cannot derive encryption key from empty secret. Set AUTH_SECRET.');
125
+ }
126
+ const enc = new TextEncoder();
127
+ const raw = await crypto.subtle.digest('SHA-256', enc.encode(secret));
128
+ return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
129
+ };
130
+
131
+ /**
132
+ * Build an opaque encrypted session cookie from orgId and tokenHash
133
+ */
134
+ export const buildSessionCookie = async (
135
+ secret: string,
136
+ orgId: string,
137
+ tokenHash: string
138
+ ): Promise<string> => {
139
+ const key = await importAesGcmKey(secret);
140
+ const iv = crypto.getRandomValues(new Uint8Array(12));
141
+ const payload = JSON.stringify({ o: orgId, th: tokenHash });
142
+ const pt = new TextEncoder().encode(payload);
143
+ const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, pt);
144
+ const combined = new Uint8Array(iv.byteLength + (ct as ArrayBuffer).byteLength);
145
+ combined.set(iv, 0);
146
+ combined.set(new Uint8Array(ct), iv.byteLength);
147
+ return toBase64Url(combined);
148
+ };
149
+
150
+ /**
151
+ * Parse an opaque session cookie into { orgId, tokenHash }
152
+ */
153
+ export const parseSessionCookie = async (
154
+ secret: string,
155
+ cookie: string
156
+ ): Promise<{ orgId: string; tokenHash: string } | null> => {
157
+ try {
158
+ if (!cookie) return null;
159
+ const data = fromBase64Url(cookie);
160
+ if (data.byteLength < 13) return null;
161
+ const iv = data.slice(0, 12);
162
+ const ct = data.slice(12);
163
+ const key = await importAesGcmKey(secret);
164
+ const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
165
+ const json = new TextDecoder().decode(pt);
166
+ const parsed = JSON.parse(json);
167
+ if (!parsed?.o || !parsed?.th) return null;
168
+ return { orgId: parsed.o, tokenHash: parsed.th };
169
+ } catch {
170
+ return null;
171
+ }
172
+ };
173
+
174
+ // ==============================
175
+ // HMAC state signing (for OAuth)
176
+ // ==============================
177
+
178
+ export const importHmacKey = async (secret: string): Promise<CryptoKey> =>
179
+ crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
180
+
181
+ export const signState = async (secret: string, payload: Record<string, any>): Promise<string> => {
182
+ const key = await importHmacKey(secret);
183
+ const json = JSON.stringify(payload);
184
+ const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(json));
185
+ return `${toBase64Url(json)}.${toBase64Url(new Uint8Array(sig))}`;
186
+ };
187
+
188
+ export const verifyState = async (secret: string, state: string): Promise<Record<string, any> | null> => {
189
+ try {
190
+ const [p, s] = state.split('.', 2);
191
+ if (!p || !s) return null;
192
+ const json = b64urlDecodeToString(p);
193
+ const key = await importHmacKey(secret);
194
+ const sigBytes = fromBase64Url(s);
195
+ const sigCopy = new Uint8Array(sigBytes.byteLength);
196
+ sigCopy.set(sigBytes);
197
+ const sigBuf: ArrayBuffer = sigCopy.buffer;
198
+ const valid = await crypto.subtle.verify('HMAC', key, sigBuf, new TextEncoder().encode(json));
199
+ if (!valid) return null;
200
+ return JSON.parse(json);
201
+ } catch {
202
+ return null;
203
+ }
204
+ };
205
+
206
+
207
+