From 98e5bfbafd069d25bff1c99d7461cfe15de14bff Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Mon, 2 Mar 2026 11:08:32 +0100 Subject: [PATCH] fix: add auth endpoints to server, fix auth bypass and registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/: commit server code for the first time (was untracked) - POST /auth/signup and /auth/login endpoints now deployed - GET /v1/billing/summary now verifies user exists in auth_users (prevents stale JWTs from bypassing auth → fixes empty dashboard) - app/_layout.tsx: dual-marker install check (SQLite + SecureStore) to detect fresh installs reliably on Android - app/auth/login.tsx, signup.tsx: replace Ionicons leaf logo with actual app icon image (assets/icon.png) - services/authService.ts: log HTTP status + server message on auth errors Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 10 + app/_layout.tsx | 118 ++ app/auth/login.tsx | 288 +++ app/auth/signup.tsx | 394 ++++ server/app.json | 9 + server/index.js | 863 +++++++++ server/lib/auth.js | 107 ++ server/lib/billing.js | 490 +++++ server/lib/openai.js | 446 +++++ server/lib/plants.js | 652 +++++++ server/lib/sqlite.js | 86 + server/package-lock.json | 2273 ++++++++++++++++++++++++ server/package.json | 22 + server/scripts/plant-diagnostics.js | 23 + server/scripts/rebuild-from-batches.js | 123 ++ services/authService.ts | 121 ++ 16 files changed, 6025 insertions(+) create mode 100644 app/_layout.tsx create mode 100644 app/auth/login.tsx create mode 100644 app/auth/signup.tsx create mode 100644 server/app.json create mode 100644 server/index.js create mode 100644 server/lib/auth.js create mode 100644 server/lib/billing.js create mode 100644 server/lib/openai.js create mode 100644 server/lib/plants.js create mode 100644 server/lib/sqlite.js create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/scripts/plant-diagnostics.js create mode 100644 server/scripts/rebuild-from-batches.js create mode 100644 services/authService.ts diff --git a/.gitignore b/.gitignore index a547bf3..e18afea 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ node_modules dist dist-ssr *.local +server/.env +server/data/*.sqlite +server/data/*.sqlite-* # Editor directories and files .vscode/* @@ -22,3 +25,10 @@ dist-ssr *.njsproj *.sln *.sw? + +# Expo +.expo/ + +# Claude / Agents (symlinks incompatible with EAS Build on Windows) +.agents/ +.claude/ diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..33d99f5 --- /dev/null +++ b/app/_layout.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { StripeProvider } from '@stripe/stripe-react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { AppProvider, useApp } from '../context/AppContext'; +import { CoachMarksProvider } from '../context/CoachMarksContext'; +import { CoachMarksOverlay } from '../components/CoachMarksOverlay'; +import { useColors } from '../constants/Colors'; +import { AuthService } from '../services/authService'; +import { initDatabase, AppMetaDb } from '../services/database'; +import * as SecureStore from 'expo-secure-store'; + +type InitialRoute = 'onboarding' | 'auth/login' | '(tabs)'; +const STRIPE_PUBLISHABLE_KEY = (process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_mock_key').trim(); +const SECURE_INSTALL_MARKER = 'greenlens_install_v1'; + +const ensureInstallConsistency = async (): Promise => { + try { + const [sqliteMarker, secureMarker] = await Promise.all([ + Promise.resolve(AppMetaDb.get('install_marker_v2')), + SecureStore.getItemAsync(SECURE_INSTALL_MARKER).catch(() => null), + ]); + + if (sqliteMarker && secureMarker) return false; // Kein Fresh Install + + // Fresh Install: Alles zurücksetzen + await AuthService.logout(); + await AsyncStorage.removeItem('greenlens_show_tour'); + AppMetaDb.set('install_marker_v2', '1'); + await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1'); + return true; + } catch (error) { + console.error('Failed to initialize install marker', error); + return false; + } +}; + +function RootLayoutInner() { + const { isDarkMode, colorPalette, signOut } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + const [initialRoute, setInitialRoute] = useState(null); + + useEffect(() => { + (async () => { + const didResetSessionForFreshInstall = await ensureInstallConsistency(); + if (didResetSessionForFreshInstall) { + await signOut(); + } + const session = await AuthService.getSession(); + if (!session) { + // Kein Benutzer → immer zum Onboarding (Landing + Register/Login) + setInitialRoute('auth/login'); + return; + } + const validity = await AuthService.validateWithServer(); + if (validity === 'invalid') { + await AuthService.logout(); + await signOut(); + setInitialRoute('auth/login'); + return; + } + // 'valid' or 'unreachable' (offline) → allow tab navigation + setInitialRoute('(tabs)'); + })(); + }, [signOut]); + + if (initialRoute === null) return null; + + return ( + <> + + + + + + + + + + + {/* Coach Marks rendern über allem */} + + + ); +} + +export default function RootLayout() { + initDatabase(); + + return ( + + + + + + + + ); +} diff --git a/app/auth/login.tsx b/app/auth/login.tsx new file mode 100644 index 0000000..ac64ca3 --- /dev/null +++ b/app/auth/login.tsx @@ -0,0 +1,288 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + KeyboardAvoidingView, + Platform, + ActivityIndicator, + ScrollView, + Image, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import { useApp } from '../../context/AppContext'; +import { useColors } from '../../constants/Colors'; +import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { AuthService } from '../../services/authService'; + +export default function LoginScreen() { + const { isDarkMode, colorPalette, hydrateSession } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleLogin = async () => { + if (!email.trim() || !password) { + setError('Bitte alle Felder ausfüllen.'); + return; + } + setLoading(true); + setError(null); + try { + const session = await AuthService.login(email, password); + await hydrateSession(session); + router.replace('/(tabs)'); + } catch (e: any) { + if (e.message === 'USER_NOT_FOUND') { + setError('Kein Konto mit dieser E-Mail gefunden.'); + } else if (e.message === 'WRONG_PASSWORD') { + setError('Falsches Passwort.'); + } else if (e.message === 'BACKEND_URL_MISSING') { + setError('Backend-URL fehlt. Bitte EXPO_PUBLIC_BACKEND_URL konfigurieren.'); + } else if (e.message === 'NETWORK_ERROR') { + setError('Server nicht erreichbar. Bitte versuche es erneut.'); + } else { + setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.'); + } + } finally { + setLoading(false); + } + }; + + return ( + + + + {/* Logo / Header */} + + + GreenLens + + Willkommen zurück + + + + {/* Card */} + + {/* Email */} + + E-Mail + + + + + + + {/* Password */} + + Passwort + + + + setShowPassword((v) => !v)} style={styles.eyeBtn}> + + + + + + {/* Error */} + {error && ( + + + {error} + + )} + + {/* Login Button */} + + {loading ? ( + + ) : ( + Anmelden + )} + + + + {/* Divider */} + + + oder + + + + {/* Sign Up Link */} + router.push('/auth/signup')} + activeOpacity={0.82} + > + + Noch kein Konto?{' '} + Registrieren + + + + + ); +} + +const styles = StyleSheet.create({ + flex: { flex: 1 }, + scroll: { + flexGrow: 1, + justifyContent: 'center', + paddingHorizontal: 24, + paddingVertical: 48, + }, + header: { + alignItems: 'center', + marginBottom: 32, + }, + logoIcon: { + width: 88, + height: 88, + borderRadius: 20, + marginBottom: 16, + }, + appName: { + fontSize: 30, + fontWeight: '700', + letterSpacing: -0.5, + marginBottom: 6, + }, + subtitle: { + fontSize: 15, + fontWeight: '400', + }, + card: { + borderRadius: 20, + borderWidth: 1, + padding: 24, + gap: 16, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 1, + shadowRadius: 12, + elevation: 4, + }, + fieldGroup: { + gap: 6, + }, + label: { + fontSize: 13, + fontWeight: '500', + marginLeft: 2, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 14, + height: 50, + }, + inputIcon: { + marginRight: 10, + }, + input: { + flex: 1, + fontSize: 15, + height: 50, + }, + eyeBtn: { + padding: 4, + marginLeft: 6, + }, + errorBox: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + }, + errorText: { + fontSize: 13, + flex: 1, + }, + primaryBtn: { + height: 52, + borderRadius: 14, + justifyContent: 'center', + alignItems: 'center', + marginTop: 4, + }, + primaryBtnText: { + fontSize: 16, + fontWeight: '600', + }, + dividerRow: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: 20, + gap: 12, + }, + dividerLine: { + flex: 1, + height: 1, + }, + dividerText: { + fontSize: 13, + }, + secondaryBtn: { + height: 52, + borderRadius: 14, + borderWidth: 1, + justifyContent: 'center', + alignItems: 'center', + }, + secondaryBtnText: { + fontSize: 15, + }, +}); diff --git a/app/auth/signup.tsx b/app/auth/signup.tsx new file mode 100644 index 0000000..67f7d13 --- /dev/null +++ b/app/auth/signup.tsx @@ -0,0 +1,394 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + KeyboardAvoidingView, + Platform, + ActivityIndicator, + ScrollView, + Image, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { router } from 'expo-router'; +import { useApp } from '../../context/AppContext'; +import { useColors } from '../../constants/Colors'; +import { ThemeBackdrop } from '../../components/ThemeBackdrop'; +import { AuthService } from '../../services/authService'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export default function SignupScreen() { + const { isDarkMode, colorPalette, hydrateSession } = useApp(); + const colors = useColors(isDarkMode, colorPalette); + + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const validate = (): string | null => { + if (!name.trim()) return 'Bitte gib deinen Namen ein.'; + if (!email.trim() || !email.includes('@')) return 'Bitte gib eine gültige E-Mail ein.'; + if (password.length < 6) return 'Das Passwort muss mindestens 6 Zeichen haben.'; + if (password !== passwordConfirm) return 'Die Passwörter stimmen nicht überein.'; + return null; + }; + + const handleSignup = async () => { + const validationError = validate(); + if (validationError) { + setError(validationError); + return; + } + setLoading(true); + setError(null); + try { + const session = await AuthService.signUp(email, name, password); + await hydrateSession(session); + // Flag setzen: Tour beim nächsten App-Öffnen anzeigen + await AsyncStorage.setItem('greenlens_show_tour', 'true'); + router.replace('/(tabs)'); + } catch (e: any) { + if (e.message === 'EMAIL_TAKEN') { + setError('Diese E-Mail ist bereits registriert.'); + } else if (e.message === 'BACKEND_URL_MISSING') { + setError('Backend-URL fehlt. Bitte EXPO_PUBLIC_BACKEND_URL konfigurieren.'); + } else if (e.message === 'NETWORK_ERROR') { + setError('Server nicht erreichbar. Bitte versuche es erneut.'); + } else if (e.message === 'SERVER_ERROR') { + setError('Server-Fehler. Bitte versuche es später erneut.'); + } else if (e.message === 'AUTH_ERROR') { + setError('Registrierung fehlgeschlagen. Bitte versuche es erneut.'); + } else { + setError(`Fehler (${e.message}). Bitte versuche es erneut.`); + } + } finally { + setLoading(false); + } + }; + + return ( + + + + {/* Header */} + + router.back()} + > + + + + GreenLens + + Konto erstellen + + + + {/* Card */} + + {/* Name */} + + Name + + + + + + + {/* Email */} + + E-Mail + + + + + + + {/* Password */} + + Passwort + + + + setShowPassword((v) => !v)} style={styles.eyeBtn}> + + + + + + {/* Password Confirm */} + + Passwort bestätigen + + + + setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}> + + + + + + {/* Password strength hint */} + {password.length > 0 && ( + + {[1, 2, 3, 4].map((level) => ( + = level * 3 + ? level <= 1 + ? colors.danger + : level === 2 + ? colors.warning + : colors.success + : colors.border, + }, + ]} + /> + ))} + + {password.length < 4 + ? 'Zu kurz' + : password.length < 7 + ? 'Schwach' + : password.length < 10 + ? 'Mittel' + : 'Stark'} + + + )} + + {/* Error */} + {error && ( + + + {error} + + )} + + {/* Signup Button */} + + {loading ? ( + + ) : ( + Registrieren + )} + + + + {/* Login link */} + router.back()}> + + Bereits ein Konto?{' '} + Anmelden + + + + + ); +} + +const styles = StyleSheet.create({ + flex: { flex: 1 }, + scroll: { + flexGrow: 1, + justifyContent: 'center', + paddingHorizontal: 24, + paddingVertical: 48, + }, + header: { + alignItems: 'center', + marginBottom: 32, + }, + backBtn: { + position: 'absolute', + left: 0, + top: 0, + width: 40, + height: 40, + borderRadius: 20, + borderWidth: 1, + justifyContent: 'center', + alignItems: 'center', + }, + logoIcon: { + width: 88, + height: 88, + borderRadius: 20, + marginBottom: 16, + }, + appName: { + fontSize: 30, + fontWeight: '700', + letterSpacing: -0.5, + marginBottom: 6, + }, + subtitle: { + fontSize: 15, + fontWeight: '400', + }, + card: { + borderRadius: 20, + borderWidth: 1, + padding: 24, + gap: 14, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 1, + shadowRadius: 12, + elevation: 4, + }, + fieldGroup: { + gap: 6, + }, + label: { + fontSize: 13, + fontWeight: '500', + marginLeft: 2, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 14, + height: 50, + }, + inputIcon: { + marginRight: 10, + }, + input: { + flex: 1, + fontSize: 15, + height: 50, + }, + eyeBtn: { + padding: 4, + marginLeft: 6, + }, + strengthRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + marginTop: -4, + }, + strengthBar: { + flex: 1, + height: 3, + borderRadius: 2, + }, + strengthText: { + fontSize: 11, + marginLeft: 4, + width: 40, + }, + errorBox: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + }, + errorText: { + fontSize: 13, + flex: 1, + }, + primaryBtn: { + height: 52, + borderRadius: 14, + justifyContent: 'center', + alignItems: 'center', + marginTop: 4, + }, + primaryBtnText: { + fontSize: 16, + fontWeight: '600', + }, + loginLink: { + alignItems: 'center', + marginTop: 24, + paddingVertical: 8, + }, + loginLinkText: { + fontSize: 15, + }, +}); diff --git a/server/app.json b/server/app.json new file mode 100644 index 0000000..f865b5a --- /dev/null +++ b/server/app.json @@ -0,0 +1,9 @@ +{ + "expo": { + "extra": { + "eas": { + "projectId": "4fb7372e-83ce-4f18-9b48-e735f00d7243" + } + } + } +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..f32ecda --- /dev/null +++ b/server/index.js @@ -0,0 +1,863 @@ +const path = require('path'); +const dotenv = require('dotenv'); +const express = require('express'); +const cors = require('cors'); +const Stripe = require('stripe'); + +// override: true ensures .env always wins over existing shell env vars +dotenv.config({ path: path.join(__dirname, '.env'), override: true }); +dotenv.config({ path: path.join(__dirname, '.env.local'), override: true }); +dotenv.config({ path: path.join(__dirname, '..', '.env') }); +dotenv.config({ path: path.join(__dirname, '..', '.env.local') }); + +const { closeDatabase, getDefaultDbPath, openDatabase } = require('./lib/sqlite'); +const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth'); +const { + PlantImportValidationError, + ensurePlantSchema, + getPlantDiagnostics, + getPlants, + rebuildPlantsCatalog, +} = require('./lib/plants'); +const { + chargeKey, + consumeCreditsWithIdempotency, + endpointKey, + ensureBillingSchema, + getAccountSnapshot, + getBillingSummary, + getEndpointResponse, + isInsufficientCreditsError, + simulatePurchase, + simulateWebhook, + storeEndpointResponse, +} = require('./lib/billing'); +const { + analyzePlantHealth, + getHealthModel, + getScanModel, + identifyPlant, + isConfigured: isOpenAiConfigured, +} = require('./lib/openai'); + +const app = express(); +const port = Number(process.env.PORT || 3000); +const stripe = new Stripe((process.env.STRIPE_SECRET_KEY || '').trim() || 'sk_test_mock_key'); + +const resolveStripeModeFromKey = (key, livePrefix, testPrefix) => { + const normalized = String(key || '').trim(); + if (normalized.startsWith(livePrefix)) return 'LIVE'; + if (normalized.startsWith(testPrefix)) return 'TEST'; + return 'MOCK'; +}; + +const getStripeSecretMode = () => + resolveStripeModeFromKey(process.env.STRIPE_SECRET_KEY, 'sk_live_', 'sk_test_'); + +const getStripePublishableMode = () => + resolveStripeModeFromKey( + process.env.STRIPE_PUBLISHABLE_KEY || process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY, + 'pk_live_', + 'pk_test_', + ); + +const SCAN_PRIMARY_COST = 1; +const SCAN_REVIEW_COST = 1; +const SEMANTIC_SEARCH_COST = 2; +const HEALTH_CHECK_COST = 2; +const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; + +const DEFAULT_BOOTSTRAP_PLANTS = [ + { + id: '1', + name: 'Monstera Deliciosa', + botanicalName: 'Monstera deliciosa', + imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/330px-Monstera_deliciosa2.jpg', + description: 'A popular houseplant with large, holey leaves.', + categories: ['easy', 'large', 'air_purifier'], + confidence: 1, + careInfo: { + waterIntervalDays: 7, + temp: '18-27C', + light: 'Indirect bright light', + }, + }, + { + id: '2', + name: 'Snake Plant', + botanicalName: 'Sansevieria trifasciata', + imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg/330px-Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg', + description: 'A hardy indoor plant known for its upright, sword-like leaves.', + categories: ['succulent', 'easy', 'low_light', 'air_purifier'], + confidence: 1, + careInfo: { + waterIntervalDays: 21, + temp: '15-30C', + light: 'Low to full light', + }, + }, +]; + +let db; + +const parseBoolean = (value, fallbackValue) => { + if (typeof value !== 'string') return fallbackValue; + const normalized = value.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1') return true; + if (normalized === 'false' || normalized === '0') return false; + return fallbackValue; +}; + +const normalizeText = (value) => { + return String(value || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, ' ') + .trim(); +}; + +const hashString = (value) => { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0; + } + return Math.abs(hash); +}; + +const clamp = (value, min, max) => { + return Math.min(max, Math.max(min, value)); +}; + +const nowIso = () => new Date().toISOString(); + +const hasImportAdminKey = Boolean(process.env.PLANT_IMPORT_ADMIN_KEY); +const isAuthorizedImport = (request) => { + if (!hasImportAdminKey) return true; + const provided = request.header('x-admin-key'); + return provided === process.env.PLANT_IMPORT_ADMIN_KEY; +}; + +const normalizeLanguage = (value) => { + return value === 'de' || value === 'en' || value === 'es' ? value : 'en'; +}; + +const resolveUserId = (request) => { + // 1. Bearer JWT (preferred — server-side auth) + const authHeader = request.header('authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.slice(7); + const payload = verifyJwt(token); + if (payload && payload.sub) return String(payload.sub); + } + // 2. Legacy X-User-Id header (kept for backward compat) + const headerUserId = request.header('x-user-id'); + if (typeof headerUserId === 'string' && headerUserId.trim()) return headerUserId.trim(); + const bodyUserId = typeof request.body?.userId === 'string' ? request.body.userId.trim() : ''; + if (bodyUserId) return bodyUserId; + return ''; +}; + +const resolveIdempotencyKey = (request) => { + const header = request.header('idempotency-key'); + if (typeof header === 'string' && header.trim()) return header.trim(); + return ''; +}; + +const toPlantResult = (entry, confidence) => { + return { + name: entry.name, + botanicalName: entry.botanicalName, + confidence: clamp(confidence, 0.05, 0.99), + description: entry.description || `${entry.name} identified from the plant catalog.`, + careInfo: { + waterIntervalDays: Math.max(1, Number(entry.careInfo?.waterIntervalDays) || 7), + light: entry.careInfo?.light || 'Unknown', + temp: entry.careInfo?.temp || 'Unknown', + }, + }; +}; + +const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) => { + if (!Array.isArray(entries) || entries.length === 0) return null; + const baseHash = hashString(`${imageUri || ''}|${entries.length}`); + const index = baseHash % entries.length; + const confidence = preferHighConfidence + ? 0.84 + ((baseHash % 7) / 100) + : 0.62 + ((baseHash % 18) / 100); + return toPlantResult(entries[index], confidence); +}; + +const findCatalogMatch = (aiResult, entries) => { + if (!aiResult || !Array.isArray(entries) || entries.length === 0) return null; + const aiBotanical = normalizeText(aiResult.botanicalName); + const aiName = normalizeText(aiResult.name); + if (!aiBotanical && !aiName) return null; + + const byExactBotanical = entries.find((entry) => normalizeText(entry.botanicalName) === aiBotanical); + if (byExactBotanical) return byExactBotanical; + + const byExactName = entries.find((entry) => normalizeText(entry.name) === aiName); + if (byExactName) return byExactName; + + if (aiBotanical) { + const aiGenus = aiBotanical.split(' ')[0]; + if (aiGenus) { + const byGenus = entries.find((entry) => normalizeText(entry.botanicalName).startsWith(`${aiGenus} `)); + if (byGenus) return byGenus; + } + } + + const byContains = entries.find((entry) => { + const plantName = normalizeText(entry.name); + const botanical = normalizeText(entry.botanicalName); + return (aiName && (plantName.includes(aiName) || aiName.includes(plantName))) + || (aiBotanical && (botanical.includes(aiBotanical) || aiBotanical.includes(botanical))); + }); + if (byContains) return byContains; + + return null; +}; + +const applyCatalogGrounding = (aiResult, catalogEntries) => { + const matchedEntry = findCatalogMatch(aiResult, catalogEntries); + if (!matchedEntry) { + return { grounded: false, result: aiResult }; + } + + return { + grounded: true, + result: { + name: matchedEntry.name || aiResult.name, + botanicalName: matchedEntry.botanicalName || aiResult.botanicalName, + confidence: clamp(Math.max(aiResult.confidence || 0.6, 0.78), 0.05, 0.99), + description: aiResult.description || matchedEntry.description || '', + careInfo: { + waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7), + light: matchedEntry.careInfo?.light || aiResult.careInfo?.light || 'Unknown', + temp: matchedEntry.careInfo?.temp || aiResult.careInfo?.temp || 'Unknown', + }, + }, + }; +}; + +const toImportErrorPayload = (error) => { + if (error instanceof PlantImportValidationError) { + return { + status: 422, + body: { + code: 'IMPORT_VALIDATION_ERROR', + message: error.message, + details: error.details || [], + }, + }; + } + + return { + status: 500, + body: { + code: 'INTERNAL_ERROR', + message: error instanceof Error ? error.message : String(error), + }, + }; +}; + +const toApiErrorPayload = (error) => { + if (error && typeof error === 'object' && error.code === 'BAD_REQUEST') { + return { + status: 400, + body: { code: 'BAD_REQUEST', message: error.message || 'Invalid request.' }, + }; + } + + if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') { + return { + status: 401, + body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' }, + }; + } + + if (isInsufficientCreditsError(error)) { + return { + status: 402, + body: { + code: 'INSUFFICIENT_CREDITS', + message: error.message || 'Insufficient credits.', + details: error.metadata || undefined, + }, + }; + } + + if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') { + return { + status: 502, + body: { code: 'PROVIDER_ERROR', message: error.message || 'Provider request failed.' }, + }; + } + + if (error && typeof error === 'object' && error.code === 'TIMEOUT') { + return { + status: 504, + body: { code: 'TIMEOUT', message: error.message || 'Provider timed out.' }, + }; + } + + return { + status: 500, + body: { + code: 'PROVIDER_ERROR', + message: error instanceof Error ? error.message : String(error), + }, + }; +}; + +const ensureRequestAuth = (request) => { + const userId = resolveUserId(request); + if (!userId) { + const error = new Error('Missing X-User-Id header.'); + error.code = 'UNAUTHORIZED'; + throw error; + } + return userId; +}; + +const ensureNonEmptyString = (value, fieldName) => { + if (typeof value === 'string' && value.trim()) return value.trim(); + const error = new Error(`${fieldName} is required.`); + error.code = 'BAD_REQUEST'; + throw error; +}; + +const seedBootstrapCatalogIfNeeded = async () => { + const existing = await getPlants(db, { limit: 1 }); + if (existing.length > 0) return; + + await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, { + source: 'bootstrap', + preserveExistingIds: false, + enforceUniqueImages: false, + }); +}; + +app.use(cors()); + +// Webhook must be BEFORE express.json() to get the raw body +app.post('/api/webhook', express.raw({ type: 'application/json' }), (request, response) => { + const signature = request.headers['stripe-signature']; + let event; + + try { + event = stripe.webhooks.constructEvent( + request.body, + signature, + process.env.STRIPE_WEBHOOK_SECRET, + ); + } catch (error) { + console.error(`Webhook Error: ${error.message}`); + response.status(400).send(`Webhook Error: ${error.message}`); + return; + } + + switch (event.type) { + case 'payment_intent.succeeded': + console.log('PaymentIntent succeeded.'); + break; + default: + console.log(`Unhandled event type: ${event.type}`); + break; + } + + response.json({ received: true }); +}); + +app.use(express.json({ limit: '10mb' })); + +app.get('/', (_request, response) => { + response.status(200).json({ + service: 'greenlns-api', + status: 'ok', + endpoints: [ + 'GET /health', + 'POST /api/payment-sheet', + 'GET /api/plants', + 'POST /api/plants/rebuild', + 'POST /auth/signup', + 'POST /auth/login', + 'GET /v1/billing/summary', + 'POST /v1/scan', + 'POST /v1/search/semantic', + 'POST /v1/health-check', + 'POST /v1/billing/simulate-purchase', + 'POST /v1/billing/simulate-webhook', + ], + }); +}); + +app.get('/health', (_request, response) => { + const stripeSecret = (process.env.STRIPE_SECRET_KEY || '').trim(); + response.status(200).json({ + ok: true, + uptimeSec: Math.round(process.uptime()), + timestamp: new Date().toISOString(), + openAiConfigured: isOpenAiConfigured(), + dbReady: Boolean(db), + dbPath: getDefaultDbPath(), + stripeConfigured: Boolean(stripeSecret), + stripeMode: getStripeSecretMode(), + stripePublishableMode: getStripePublishableMode(), + scanModel: getScanModel(), + healthModel: getHealthModel(), + }); +}); + +app.get('/api/plants', async (request, response) => { + try { + const query = typeof request.query.q === 'string' ? request.query.q : ''; + const category = typeof request.query.category === 'string' ? request.query.category : ''; + const limit = request.query.limit; + const results = await getPlants(db, { + query, + category, + limit: typeof limit === 'string' ? Number(limit) : undefined, + }); + response.json(results); + } catch (error) { + const payload = toImportErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + +app.get('/api/plants/diagnostics', async (_request, response) => { + try { + const diagnostics = await getPlantDiagnostics(db); + response.json(diagnostics); + } catch (error) { + const payload = toImportErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + +app.post('/api/plants/rebuild', async (request, response) => { + if (!isAuthorizedImport(request)) { + response.status(401).json({ + code: 'UNAUTHORIZED', + message: 'Invalid or missing x-admin-key.', + }); + return; + } + + const payloadEntries = Array.isArray(request.body) + ? request.body + : request.body?.entries; + const source = typeof request.body?.source === 'string' && request.body.source.trim() + ? request.body.source.trim() + : 'api_rebuild'; + const preserveExistingIds = parseBoolean(request.body?.preserveExistingIds, true); + const enforceUniqueImages = parseBoolean(request.body?.enforceUniqueImages, true); + + try { + const summary = await rebuildPlantsCatalog(db, payloadEntries, { + source, + preserveExistingIds, + enforceUniqueImages, + }); + response.status(200).json(summary); + } catch (error) { + const payload = toImportErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + +app.post('/api/payment-sheet', async (request, response) => { + try { + const amount = Number(request.body?.amount || 500); + const currency = request.body?.currency || 'usd'; + + const paymentIntent = await stripe.paymentIntents.create({ + amount, + currency, + automatic_payment_methods: { enabled: true }, + }); + + const customer = await stripe.customers.create(); + const ephemeralKey = await stripe.ephemeralKeys.create( + { customer: customer.id }, + { apiVersion: '2023-10-16' }, + ); + + response.json({ + paymentIntent: paymentIntent.client_secret, + ephemeralKey: ephemeralKey.secret, + customer: customer.id, + publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_mock_key', + }); + } catch (error) { + response.status(400).json({ + code: 'PAYMENT_SHEET_ERROR', + message: error instanceof Error ? error.message : String(error), + }); + } +}); + +app.get('/v1/billing/summary', async (request, response) => { + try { + const userId = ensureRequestAuth(request); + const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = ?', [userId]); + if (!userExists) { + return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' }); + } + const summary = await getBillingSummary(db, userId); + response.status(200).json(summary); + } catch (error) { + const payload = toApiErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + +app.post('/v1/scan', async (request, response) => { + let userId = 'unknown'; + try { + userId = ensureRequestAuth(request); + const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); + const imageUri = ensureNonEmptyString(request.body?.imageUri, 'imageUri'); + const language = normalizeLanguage(request.body?.language); + const endpointId = endpointKey('scan', userId, idempotencyKey); + + const cached = await getEndpointResponse(db, endpointId); + if (cached) { + response.status(200).json(cached); + return; + } + + let creditsCharged = 0; + const modelPath = []; + let modelUsed = null; + let modelFallbackCount = 0; + creditsCharged += await consumeCreditsWithIdempotency( + db, + userId, + chargeKey('scan-primary', userId, idempotencyKey), + SCAN_PRIMARY_COST, + ); + + const catalogEntries = await getPlants(db, { limit: 500 }); + let result = pickCatalogFallback(catalogEntries, imageUri, false); + let usedOpenAi = false; + + if (isOpenAiConfigured()) { + console.log(`Starting OpenAI identification for user ${userId} using model ${getScanModel()}`); + const openAiPrimary = await identifyPlant({ + imageUri, + language, + mode: 'primary', + }); + modelFallbackCount = Math.max( + modelFallbackCount, + Math.max((openAiPrimary?.attemptedModels?.length || 0) - 1, 0), + ); + if (openAiPrimary?.result) { + console.log(`OpenAI primary identification successful for user ${userId}: ${openAiPrimary.result.name} (${openAiPrimary.result.confidence}) using ${openAiPrimary.modelUsed}`); + const grounded = applyCatalogGrounding(openAiPrimary.result, catalogEntries); + result = grounded.result; + if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) }; + usedOpenAi = true; + modelUsed = openAiPrimary.modelUsed || modelUsed; + modelPath.push('openai-primary'); + if (grounded.grounded) modelPath.push('catalog-grounded-primary'); + } else { + console.warn(`OpenAI primary identification returned null for user ${userId}`); + modelPath.push('openai-primary-failed'); + modelPath.push('catalog-primary-fallback'); + } + } else { + console.log(`OpenAI not configured, using catalog fallback for user ${userId}`); + modelPath.push('openai-not-configured'); + modelPath.push('catalog-primary-fallback'); + } + + if (!result) { + const error = new Error('Plant catalog is empty. Unable to produce identification fallback.'); + error.code = 'PROVIDER_ERROR'; + throw error; + } + + const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD; + const accountSnapshot = await getAccountSnapshot(db, userId); + if (shouldReview && accountSnapshot.plan === 'pro') { + console.log(`Starting AI review for user ${userId} (confidence ${result.confidence} < ${LOW_CONFIDENCE_REVIEW_THRESHOLD})`); + try { + creditsCharged += await consumeCreditsWithIdempotency( + db, + userId, + chargeKey('scan-review', userId, idempotencyKey), + SCAN_REVIEW_COST, + ); + + if (usedOpenAi) { + const openAiReview = await identifyPlant({ + imageUri, + language, + mode: 'review', + }); + modelFallbackCount = Math.max( + modelFallbackCount, + Math.max((openAiReview?.attemptedModels?.length || 0) - 1, 0), + ); + if (openAiReview?.result) { + console.log(`OpenAI review identification successful for user ${userId}: ${openAiReview.result.name} (${openAiReview.result.confidence}) using ${openAiReview.modelUsed}`); + const grounded = applyCatalogGrounding(openAiReview.result, catalogEntries); + result = grounded.result; + if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) }; + modelUsed = openAiReview.modelUsed || modelUsed; + modelPath.push('openai-review'); + if (grounded.grounded) modelPath.push('catalog-grounded-review'); + } else { + console.warn(`OpenAI review identification returned null for user ${userId}`); + modelPath.push('openai-review-failed'); + } + } else { + const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true); + if (reviewFallback) { + result = reviewFallback; + } + modelPath.push('catalog-review-fallback'); + } + } catch (error) { + if (isInsufficientCreditsError(error)) { + console.log(`Review skipped for user ${userId} due to insufficient credits`); + modelPath.push('review-skipped-insufficient-credits'); + } else { + throw error; + } + } + } else if (shouldReview) { + console.log(`Review skipped for user ${userId} (plan: ${accountSnapshot.plan})`); + modelPath.push('review-skipped-free-plan'); + } + + const payload = { + result, + creditsCharged, + modelPath, + modelUsed, + modelFallbackCount, + billing: await getBillingSummary(db, userId), + }; + + await storeEndpointResponse(db, endpointId, payload); + response.status(200).json(payload); + } catch (error) { + console.error(`Scan error for user ${userId}:`, error); + const payload = toApiErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + +app.post('/v1/search/semantic', async (request, response) => { + try { + const userId = ensureRequestAuth(request); + const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); + const query = typeof request.body?.query === 'string' ? request.body.query.trim() : ''; + const endpointId = endpointKey('semantic-search', userId, idempotencyKey); + + const cached = await getEndpointResponse(db, endpointId); + if (cached) { + response.status(200).json(cached); + return; + } + + if (!query) { + const payload = { + status: 'no_results', + results: [], + creditsCharged: 0, + billing: await getBillingSummary(db, userId), + }; + await storeEndpointResponse(db, endpointId, payload); + response.status(200).json(payload); + return; + } + + const creditsCharged = await consumeCreditsWithIdempotency( + db, + userId, + chargeKey('semantic-search', userId, idempotencyKey), + SEMANTIC_SEARCH_COST, + ); + + const results = await getPlants(db, { query, limit: 18 }); + const payload = { + status: results.length > 0 ? 'success' : 'no_results', + results, + creditsCharged, + billing: await getBillingSummary(db, userId), + }; + + await storeEndpointResponse(db, endpointId, payload); + response.status(200).json(payload); + } catch (error) { + const payload = toApiErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + +app.post('/v1/health-check', async (request, response) => { + try { + const userId = ensureRequestAuth(request); + const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); + const imageUri = ensureNonEmptyString(request.body?.imageUri, 'imageUri'); + const language = normalizeLanguage(request.body?.language); + const endpointId = endpointKey('health-check', userId, idempotencyKey); + + const cached = await getEndpointResponse(db, endpointId); + if (cached) { + response.status(200).json(cached); + return; + } + + if (!isOpenAiConfigured()) { + const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.'); + error.code = 'PROVIDER_ERROR'; + throw error; + } + + const analysisResponse = await analyzePlantHealth({ + imageUri, + language, + plantContext: request.body?.plantContext, + }); + const analysis = analysisResponse?.analysis; + if (!analysis) { + const error = new Error('OpenAI health check failed. Please verify API key, model, and network access.'); + error.code = 'PROVIDER_ERROR'; + throw error; + } + + const creditsCharged = await consumeCreditsWithIdempotency( + db, + userId, + chargeKey('health-check', userId, idempotencyKey), + HEALTH_CHECK_COST, + ); + + const healthCheck = { + generatedAt: nowIso(), + overallHealthScore: analysis.overallHealthScore, + status: analysis.status, + likelyIssues: analysis.likelyIssues, + actionsNow: analysis.actionsNow, + plan7Days: analysis.plan7Days, + creditsCharged, + imageUri, + }; + + const payload = { + healthCheck, + creditsCharged, + modelUsed: analysisResponse?.modelUsed || null, + modelFallbackCount: Math.max((analysisResponse?.attemptedModels?.length || 0) - 1, 0), + billing: await getBillingSummary(db, userId), + }; + + await storeEndpointResponse(db, endpointId, payload); + response.status(200).json(payload); + } catch (error) { + const payload = toApiErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + +app.post('/v1/billing/simulate-purchase', async (request, response) => { + try { + const userId = ensureRequestAuth(request); + const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); + const productId = ensureNonEmptyString(request.body?.productId, 'productId'); + const payload = await simulatePurchase(db, userId, idempotencyKey, productId); + response.status(200).json(payload); + } catch (error) { + const payload = toApiErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + +app.post('/v1/billing/simulate-webhook', async (request, response) => { + try { + const userId = ensureRequestAuth(request); + const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header'); + const event = ensureNonEmptyString(request.body?.event, 'event'); + const payload = await simulateWebhook(db, userId, idempotencyKey, event, request.body?.payload || {}); + response.status(200).json(payload); + } catch (error) { + const payload = toApiErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + +// ─── Auth endpoints ──────────────────────────────────────────────────────── + +app.post('/auth/signup', async (request, response) => { + try { + const { email, name, password } = request.body || {}; + if (!email || !name || !password) { + return response.status(400).json({ code: 'BAD_REQUEST', message: 'email, name and password are required.' }); + } + const user = await authSignUp(db, email, name, password); + const token = issueToken(user.id, user.email, user.name); + response.status(201).json({ userId: user.id, email: user.email, name: user.name, token }); + } catch (error) { + const status = error.status || 500; + response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); + } +}); + +app.post('/auth/login', async (request, response) => { + try { + const { email, password } = request.body || {}; + if (!email || !password) { + return response.status(400).json({ code: 'BAD_REQUEST', message: 'email and password are required.' }); + } + const user = await authLogin(db, email, password); + const token = issueToken(user.id, user.email, user.name); + response.status(200).json({ userId: user.id, email: user.email, name: user.name, token }); + } catch (error) { + const status = error.status || 500; + response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message }); + } +}); + +// ─── Startup ─────────────────────────────────────────────────────────────── + +const start = async () => { + db = await openDatabase(); + await ensurePlantSchema(db); + await ensureBillingSchema(db); + await ensureAuthSchema(db); + await seedBootstrapCatalogIfNeeded(); + + const stripeMode = getStripeSecretMode(); + const stripePublishableMode = getStripePublishableMode(); + console.log(`Stripe mode: ${stripeMode}`); + console.log(`Stripe publishable mode: ${stripePublishableMode}`); + + const server = app.listen(port, () => { + console.log(`GreenLns server listening at http://localhost:${port}`); + }); + + const gracefulShutdown = async () => { + try { + await closeDatabase(db); + } catch (error) { + console.error('Failed to close database', error); + } finally { + server.close(() => process.exit(0)); + } + }; + + process.on('SIGINT', gracefulShutdown); + process.on('SIGTERM', gracefulShutdown); +}; + +start().catch((error) => { + console.error('Failed to start GreenLns server', error); + process.exit(1); +}); diff --git a/server/lib/auth.js b/server/lib/auth.js new file mode 100644 index 0000000..53907b3 --- /dev/null +++ b/server/lib/auth.js @@ -0,0 +1,107 @@ +const crypto = require('crypto'); +const { get, run } = require('./sqlite'); + +const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod'; +const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year + +// ─── Minimal JWT (HS256, no external deps) ───────────────────────────────── + +const b64url = (input) => { + const str = typeof input === 'string' ? input : JSON.stringify(input); + return Buffer.from(str).toString('base64url'); +}; + +const b64urlDecode = (str) => Buffer.from(str, 'base64url').toString(); + +const signJwt = (payload) => { + const header = b64url({ alg: 'HS256', typ: 'JWT' }); + const body = b64url(payload); + const sig = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url'); + return `${header}.${body}.${sig}`; +}; + +const verifyJwt = (token) => { + if (!token || typeof token !== 'string') return null; + const parts = token.split('.'); + if (parts.length !== 3) return null; + const [header, body, sig] = parts; + const expected = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url'); + if (sig !== expected) return null; + try { + const payload = JSON.parse(b64urlDecode(body)); + if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) return null; + return payload; + } catch { + return null; + } +}; + +const issueToken = (userId, email, name) => + signJwt({ + sub: userId, + email, + name, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SECONDS, + }); + +// ─── Password hashing ────────────────────────────────────────────────────── + +const hashPassword = (password) => + crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex'); + +// ─── Schema ──────────────────────────────────────────────────────────────── + +const ensureAuthSchema = async (db) => { + await run( + db, + `CREATE TABLE IF NOT EXISTS auth_users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + name TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + ); +}; + +// ─── Operations ─────────────────────────────────────────────────────────── + +const signUp = async (db, email, name, password) => { + const normalizedEmail = email.trim().toLowerCase(); + const existing = await get(db, 'SELECT id FROM auth_users WHERE email = ?', [normalizedEmail]); + if (existing) { + const err = new Error('Email already in use.'); + err.code = 'EMAIL_TAKEN'; + err.status = 409; + throw err; + } + const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; + await run(db, 'INSERT INTO auth_users (id, email, name, password_hash) VALUES (?, ?, ?, ?)', [ + id, + normalizedEmail, + name.trim(), + hashPassword(password), + ]); + return { id, email: normalizedEmail, name: name.trim() }; +}; + +const login = async (db, email, password) => { + const normalizedEmail = email.trim().toLowerCase(); + const user = await get(db, 'SELECT id, email, name, password_hash FROM auth_users WHERE email = ?', [normalizedEmail]); + if (!user) { + const err = new Error('No account found for this email.'); + err.code = 'USER_NOT_FOUND'; + err.status = 401; + throw err; + } + if (user.password_hash !== hashPassword(password)) { + const err = new Error('Wrong password.'); + err.code = 'WRONG_PASSWORD'; + err.status = 401; + throw err; + } + return { id: user.id, email: user.email, name: user.name }; +}; + +module.exports = { ensureAuthSchema, signUp, login, issueToken, verifyJwt }; diff --git a/server/lib/billing.js b/server/lib/billing.js new file mode 100644 index 0000000..33d3a6a --- /dev/null +++ b/server/lib/billing.js @@ -0,0 +1,490 @@ +const { get, run } = require('./sqlite'); + +const FREE_MONTHLY_CREDITS = 15; +const PRO_MONTHLY_CREDITS = 50; +const TOPUP_DEFAULT_CREDITS = 60; + +const TOPUP_CREDITS_BY_PRODUCT = { + pro_monthly: 0, + topup_small: 50, + topup_medium: 120, + topup_large: 300, +}; + +const AVAILABLE_PRODUCTS = ['pro_monthly', 'topup_small', 'topup_medium', 'topup_large']; + +const nowIso = () => new Date().toISOString(); + +const startOfUtcMonth = (date) => { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0)); +}; + +const addUtcMonths = (date, months) => { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, 0, 0, 0, 0)); +}; + +const addDays = (date, days) => { + const result = new Date(date.getTime()); + result.setUTCDate(result.getUTCDate() + days); + return result; +}; + +const getCycleBounds = (now) => { + const cycleStartedAt = startOfUtcMonth(now); + const cycleEndsAt = addUtcMonths(cycleStartedAt, 1); + return { cycleStartedAt, cycleEndsAt }; +}; + +const getMonthlyAllowanceForPlan = (plan) => { + return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS; +}; + +const createInsufficientCreditsError = (required, available) => { + const error = new Error(`Insufficient credits. Required ${required}, available ${available}.`); + error.code = 'INSUFFICIENT_CREDITS'; + error.status = 402; + error.metadata = { required, available }; + return error; +}; + +const runInTransaction = async (db, worker) => { + await run(db, 'BEGIN IMMEDIATE TRANSACTION'); + try { + const result = await worker(); + await run(db, 'COMMIT'); + return result; + } catch (error) { + try { + await run(db, 'ROLLBACK'); + } catch (rollbackError) { + console.error('Failed to rollback SQLite transaction.', rollbackError); + } + throw error; + } +}; + +const normalizeAccountRow = (row) => { + if (!row) return null; + return { + userId: String(row.userId), + plan: row.plan === 'pro' ? 'pro' : 'free', + provider: typeof row.provider === 'string' && row.provider ? row.provider : 'stripe', + cycleStartedAt: String(row.cycleStartedAt), + cycleEndsAt: String(row.cycleEndsAt), + monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS, + usedThisCycle: Number(row.usedThisCycle) || 0, + topupBalance: Number(row.topupBalance) || 0, + renewsAt: row.renewsAt ? String(row.renewsAt) : null, + updatedAt: row.updatedAt ? String(row.updatedAt) : nowIso(), + }; +}; + +const buildDefaultAccount = (userId, now) => { + const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); + return { + userId, + plan: 'free', + provider: 'stripe', + cycleStartedAt: cycleStartedAt.toISOString(), + cycleEndsAt: cycleEndsAt.toISOString(), + monthlyAllowance: FREE_MONTHLY_CREDITS, + usedThisCycle: 0, + topupBalance: 0, + renewsAt: null, + updatedAt: nowIso(), + }; +}; + +const alignAccountToCurrentCycle = (account, now) => { + const next = { ...account }; + const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan); + if (next.monthlyAllowance !== expectedMonthlyAllowance) { + next.monthlyAllowance = expectedMonthlyAllowance; + } + + if (!next.renewsAt && next.plan === 'pro') { + next.renewsAt = addDays(now, 30).toISOString(); + } + + const cycleEndsAtMs = new Date(next.cycleEndsAt).getTime(); + if (Number.isNaN(cycleEndsAtMs) || now.getTime() >= cycleEndsAtMs) { + const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); + next.cycleStartedAt = cycleStartedAt.toISOString(); + next.cycleEndsAt = cycleEndsAt.toISOString(); + next.usedThisCycle = 0; + next.monthlyAllowance = expectedMonthlyAllowance; + } + + return next; +}; + +const accountChanged = (a, b) => { + return a.userId !== b.userId + || a.plan !== b.plan + || a.provider !== b.provider + || a.cycleStartedAt !== b.cycleStartedAt + || a.cycleEndsAt !== b.cycleEndsAt + || a.monthlyAllowance !== b.monthlyAllowance + || a.usedThisCycle !== b.usedThisCycle + || a.topupBalance !== b.topupBalance + || a.renewsAt !== b.renewsAt; +}; + +const upsertAccount = async (db, account) => { + await run( + db, + `INSERT INTO billing_accounts ( + userId, + plan, + provider, + cycleStartedAt, + cycleEndsAt, + monthlyAllowance, + usedThisCycle, + topupBalance, + renewsAt, + updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(userId) DO UPDATE SET + plan = excluded.plan, + provider = excluded.provider, + cycleStartedAt = excluded.cycleStartedAt, + cycleEndsAt = excluded.cycleEndsAt, + monthlyAllowance = excluded.monthlyAllowance, + usedThisCycle = excluded.usedThisCycle, + topupBalance = excluded.topupBalance, + renewsAt = excluded.renewsAt, + updatedAt = excluded.updatedAt`, + [ + account.userId, + account.plan, + account.provider, + account.cycleStartedAt, + account.cycleEndsAt, + account.monthlyAllowance, + account.usedThisCycle, + account.topupBalance, + account.renewsAt, + account.updatedAt, + ], + ); +}; + +const getOrCreateAccount = async (db, userId) => { + const row = await get( + db, + `SELECT + userId, + plan, + provider, + cycleStartedAt, + cycleEndsAt, + monthlyAllowance, + usedThisCycle, + topupBalance, + renewsAt, + updatedAt + FROM billing_accounts + WHERE userId = ?`, + [userId], + ); + + const now = new Date(); + if (!row) { + const created = buildDefaultAccount(userId, now); + await upsertAccount(db, created); + return created; + } + + const existing = normalizeAccountRow(row); + const aligned = alignAccountToCurrentCycle(existing, now); + if (accountChanged(existing, aligned)) { + aligned.updatedAt = nowIso(); + await upsertAccount(db, aligned); + } + return aligned; +}; + +const getAvailableCredits = (account) => { + const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle); + return monthlyRemaining + Math.max(0, account.topupBalance); +}; + +const buildBillingSummary = (account) => { + return { + entitlement: { + plan: account.plan, + provider: account.provider, + status: 'active', + renewsAt: account.renewsAt, + }, + credits: { + monthlyAllowance: account.monthlyAllowance, + usedThisCycle: account.usedThisCycle, + topupBalance: account.topupBalance, + available: getAvailableCredits(account), + cycleStartedAt: account.cycleStartedAt, + cycleEndsAt: account.cycleEndsAt, + }, + availableProducts: AVAILABLE_PRODUCTS, + }; +}; + +const consumeCredits = (account, cost) => { + if (cost <= 0) return 0; + + const available = getAvailableCredits(account); + if (available < cost) { + throw createInsufficientCreditsError(cost, available); + } + + let remaining = cost; + const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle); + if (monthlyRemaining > 0) { + const monthlyUsage = Math.min(monthlyRemaining, remaining); + account.usedThisCycle += monthlyUsage; + remaining -= monthlyUsage; + } + + if (remaining > 0 && account.topupBalance > 0) { + const topupUsage = Math.min(account.topupBalance, remaining); + account.topupBalance -= topupUsage; + remaining -= topupUsage; + } + + return cost; +}; + +const parseStoredJson = (raw) => { + if (!raw || typeof raw !== 'string') return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +}; + +const readIdempotentValue = async (db, key) => { + const row = await get(db, 'SELECT responseJson FROM billing_idempotency WHERE id = ?', [key]); + if (!row || typeof row.responseJson !== 'string') return null; + return parseStoredJson(row.responseJson); +}; + +const writeIdempotentValue = async (db, key, value) => { + await run( + db, + `INSERT INTO billing_idempotency (id, responseJson, createdAt) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + responseJson = excluded.responseJson, + createdAt = excluded.createdAt`, + [key, JSON.stringify(value), nowIso()], + ); +}; + +const endpointKey = (scope, userId, idempotencyKey) => { + return `endpoint:${scope}:${userId}:${idempotencyKey}`; +}; + +const chargeKey = (scope, userId, idempotencyKey) => { + return `charge:${scope}:${userId}:${idempotencyKey}`; +}; + +const consumeCreditsWithIdempotency = async (db, userId, key, cost) => { + return runInTransaction(db, async () => { + const existing = await readIdempotentValue(db, key); + if (existing && typeof existing.charged === 'number') return existing.charged; + + const account = await getOrCreateAccount(db, userId); + const charged = consumeCredits(account, cost); + account.updatedAt = nowIso(); + await upsertAccount(db, account); + await writeIdempotentValue(db, key, { charged }); + return charged; + }); +}; + +const getBillingSummary = async (db, userId) => { + return runInTransaction(db, async () => { + const account = await getOrCreateAccount(db, userId); + account.updatedAt = nowIso(); + await upsertAccount(db, account); + return buildBillingSummary(account); + }); +}; + +const getAccountSnapshot = async (db, userId) => { + return runInTransaction(db, async () => { + const account = await getOrCreateAccount(db, userId); + account.updatedAt = nowIso(); + await upsertAccount(db, account); + return account; + }); +}; + +const getEndpointResponse = async (db, key) => { + const cached = await readIdempotentValue(db, key); + return cached || null; +}; + +const storeEndpointResponse = async (db, key, response) => { + await writeIdempotentValue(db, key, response); +}; + +const simulatePurchase = async (db, userId, idempotencyKey, productId) => { + const endpointId = endpointKey('simulate-purchase', userId, idempotencyKey); + const cached = await getEndpointResponse(db, endpointId); + if (cached) return cached; + + const response = await runInTransaction(db, async () => { + const existingInsideTx = await readIdempotentValue(db, endpointId); + if (existingInsideTx) return existingInsideTx; + + const account = await getOrCreateAccount(db, userId); + + if (productId === 'pro_monthly') { + const now = new Date(); + const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); + account.plan = 'pro'; + account.provider = 'stripe'; + account.monthlyAllowance = PRO_MONTHLY_CREDITS; + account.usedThisCycle = 0; + account.cycleStartedAt = cycleStartedAt.toISOString(); + account.cycleEndsAt = cycleEndsAt.toISOString(); + account.renewsAt = addDays(now, 30).toISOString(); + } else { + const credits = TOPUP_CREDITS_BY_PRODUCT[productId]; + if (typeof credits !== 'number') { + const error = new Error(`Unsupported product: ${productId}`); + error.code = 'BAD_REQUEST'; + error.status = 400; + throw error; + } + account.topupBalance += credits; + } + + account.updatedAt = nowIso(); + await upsertAccount(db, account); + + const payload = { + appliedProduct: productId, + billing: buildBillingSummary(account), + }; + await storeEndpointResponse(db, endpointId, payload); + return payload; + }); + + return response; +}; + +const simulateWebhook = async (db, userId, idempotencyKey, event, payload = {}) => { + const endpointId = endpointKey('simulate-webhook', userId, idempotencyKey); + const cached = await getEndpointResponse(db, endpointId); + if (cached) return cached; + + const response = await runInTransaction(db, async () => { + const existingInsideTx = await readIdempotentValue(db, endpointId); + if (existingInsideTx) return existingInsideTx; + + const account = await getOrCreateAccount(db, userId); + + if (event === 'entitlement_granted') { + const now = new Date(); + const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); + account.plan = 'pro'; + account.provider = 'revenuecat'; + account.monthlyAllowance = PRO_MONTHLY_CREDITS; + account.usedThisCycle = 0; + account.cycleStartedAt = cycleStartedAt.toISOString(); + account.cycleEndsAt = cycleEndsAt.toISOString(); + account.renewsAt = addDays(now, 30).toISOString(); + } else if (event === 'entitlement_revoked') { + const now = new Date(); + const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now); + account.plan = 'free'; + account.provider = 'revenuecat'; + account.monthlyAllowance = FREE_MONTHLY_CREDITS; + account.usedThisCycle = 0; + account.cycleStartedAt = cycleStartedAt.toISOString(); + account.cycleEndsAt = cycleEndsAt.toISOString(); + account.renewsAt = null; + } else if (event === 'topup_granted') { + const credits = Math.max(1, Number(payload.credits) || TOPUP_DEFAULT_CREDITS); + account.topupBalance += credits; + } else if (event === 'credits_depleted') { + account.usedThisCycle = account.monthlyAllowance; + account.topupBalance = 0; + } else { + const error = new Error(`Unsupported webhook event: ${event}`); + error.code = 'BAD_REQUEST'; + error.status = 400; + throw error; + } + + account.updatedAt = nowIso(); + await upsertAccount(db, account); + + const payloadResponse = { + event, + billing: buildBillingSummary(account), + }; + await storeEndpointResponse(db, endpointId, payloadResponse); + return payloadResponse; + }); + + return response; +}; + +const ensureBillingSchema = async (db) => { + await run( + db, + `CREATE TABLE IF NOT EXISTS billing_accounts ( + userId TEXT PRIMARY KEY, + plan TEXT NOT NULL DEFAULT 'free', + provider TEXT NOT NULL DEFAULT 'stripe', + cycleStartedAt TEXT NOT NULL, + cycleEndsAt TEXT NOT NULL, + monthlyAllowance INTEGER NOT NULL DEFAULT 15, + usedThisCycle INTEGER NOT NULL DEFAULT 0, + topupBalance INTEGER NOT NULL DEFAULT 0, + renewsAt TEXT, + updatedAt TEXT NOT NULL + )`, + ); + + await run( + db, + `CREATE TABLE IF NOT EXISTS billing_idempotency ( + id TEXT PRIMARY KEY, + responseJson TEXT NOT NULL, + createdAt TEXT NOT NULL + )`, + ); + + await run( + db, + `CREATE INDEX IF NOT EXISTS idx_billing_idempotency_created_at + ON billing_idempotency(createdAt DESC)`, + ); +}; + +const isInsufficientCreditsError = (error) => { + return Boolean(error && typeof error === 'object' && error.code === 'INSUFFICIENT_CREDITS'); +}; + +module.exports = { + AVAILABLE_PRODUCTS, + chargeKey, + consumeCreditsWithIdempotency, + endpointKey, + ensureBillingSchema, + getAccountSnapshot, + getBillingSummary, + getEndpointResponse, + getMonthlyAllowanceForPlan, + isInsufficientCreditsError, + runInTransaction, + simulatePurchase, + simulateWebhook, + storeEndpointResponse, +}; diff --git a/server/lib/openai.js b/server/lib/openai.js new file mode 100644 index 0000000..0e8bfba --- /dev/null +++ b/server/lib/openai.js @@ -0,0 +1,446 @@ +const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim(); +const OPENAI_SCAN_MODEL = (process.env.OPENAI_SCAN_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(); +const OPENAI_HEALTH_MODEL = (process.env.OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || OPENAI_SCAN_MODEL).trim(); +const OPENAI_SCAN_FALLBACK_MODELS = (process.env.OPENAI_SCAN_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS || 'gpt-5-mini,gpt-4o-mini').trim(); +const OPENAI_HEALTH_FALLBACK_MODELS = (process.env.OPENAI_HEALTH_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim(); +const OPENAI_CHAT_COMPLETIONS_URL = (process.env.OPENAI_CHAT_COMPLETIONS_URL || 'https://api.openai.com/v1/chat/completions').trim(); +const OPENAI_TIMEOUT_MS = (() => { + const raw = (process.env.OPENAI_TIMEOUT_MS || process.env.EXPO_PUBLIC_OPENAI_TIMEOUT_MS || '45000').trim(); + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed >= 10000) return parsed; + return 45000; +})(); + +const parseModelChain = (primaryModel, fallbackModels) => { + const models = [primaryModel]; + for (const model of String(fallbackModels || '').split(',')) { + const normalized = model.trim(); + if (!normalized) continue; + models.push(normalized); + } + return [...new Set(models.filter(Boolean))]; +}; + +const OPENAI_SCAN_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_MODEL, OPENAI_SCAN_FALLBACK_MODELS); +const OPENAI_HEALTH_MODEL_CHAIN = parseModelChain(OPENAI_HEALTH_MODEL, OPENAI_HEALTH_FALLBACK_MODELS); + +const clamp = (value, min, max) => { + return Math.min(max, Math.max(min, value)); +}; + +const toErrorMessage = (error) => { + if (error instanceof Error) return error.message; + return String(error); +}; + +const summarizeImageUri = (imageUri) => { + const trimmed = typeof imageUri === 'string' ? imageUri.trim() : ''; + if (!trimmed) return 'empty'; + if (trimmed.startsWith('data:image')) return `data-uri(${Math.round(trimmed.length / 1024)}kb)`; + return trimmed.length > 120 ? `${trimmed.slice(0, 120)}...` : trimmed; +}; + +const toJsonString = (content) => { + const trimmed = typeof content === 'string' ? content.trim() : ''; + if (!trimmed) return ''; + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + if (fenced && fenced[1]) return fenced[1].trim(); + return trimmed; +}; + +const parseContentToJson = (content) => { + try { + const parsed = JSON.parse(toJsonString(content)); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + return null; + } catch { + return null; + } +}; + +const getString = (value) => { + return typeof value === 'string' ? value.trim() : ''; +}; + +const getNumber = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +}; + +const getStringArray = (value) => { + if (!Array.isArray(value)) return []; + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter(Boolean); +}; + +const getLanguageLabel = (language) => { + if (language === 'de') return 'German'; + if (language === 'es') return 'Spanish'; + return 'English'; +}; + +const normalizeIdentifyResult = (raw, language) => { + const name = getString(raw.name); + const botanicalName = getString(raw.botanicalName); + const description = getString(raw.description); + const confidenceRaw = getNumber(raw.confidence); + const careInfoRaw = raw.careInfo; + + if (!name || !botanicalName || !careInfoRaw || typeof careInfoRaw !== 'object' || Array.isArray(careInfoRaw)) { + return null; + } + + const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays); + const light = getString(careInfoRaw.light); + const temp = getString(careInfoRaw.temp); + if (waterIntervalRaw == null || !light || !temp) { + return null; + } + + const fallbackDescription = language === 'de' + ? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.` + : language === 'es' + ? `${name} se detecto con IA. Debajo veras recomendaciones de cuidado.` + : `${name} was identified with AI. Care guidance is shown below.`; + + return { + name, + botanicalName, + confidence: clamp(confidenceRaw == null ? 0.72 : confidenceRaw, 0.05, 0.99), + description: description || fallbackDescription, + careInfo: { + waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)), + light, + temp, + }, + }; +}; + +const normalizeHealthAnalysis = (raw, language) => { + const scoreRaw = getNumber(raw.overallHealthScore); + const statusRaw = getString(raw.status); + const issuesRaw = raw.likelyIssues; + const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8); + const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10); + + if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) { + return null; + } + + const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical' + ? statusRaw + : 'watch'; + + const likelyIssues = issuesRaw + .map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const title = getString(entry.title); + const details = getString(entry.details); + const confidenceRaw = getNumber(entry.confidence); + if (!title || !details || confidenceRaw == null) return null; + return { + title, + details, + confidence: clamp(confidenceRaw, 0.05, 0.99), + }; + }) + .filter(Boolean) + .slice(0, 4); + + if (likelyIssues.length === 0 || actionsNowRaw.length < 2 || plan7DaysRaw.length < 2) { + const fallbackIssue = language === 'de' + ? 'Die KI konnte keine stabilen Gesundheitsmerkmale extrahieren.' + : language === 'es' + ? 'La IA no pudo extraer senales de salud estables.' + : 'AI could not extract stable health signals.'; + return { + overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), + status, + likelyIssues: [ + { + title: language === 'de' + ? 'Analyse unsicher' + : language === 'es' + ? 'Analisis incierto' + : 'Uncertain analysis', + confidence: 0.35, + details: fallbackIssue, + }, + ], + actionsNow: actionsNowRaw.length > 0 + ? actionsNowRaw + : [language === 'de' ? 'Neues, schaerferes Foto aufnehmen.' : language === 'es' ? 'Tomar una foto nueva y mas nitida.' : 'Capture a new, sharper photo.'], + plan7Days: plan7DaysRaw.length > 0 + ? plan7DaysRaw + : [language === 'de' ? 'In 2 Tagen erneut pruefen.' : language === 'es' ? 'Volver a revisar en 2 dias.' : 'Re-check in 2 days.'], + }; + } + + return { + overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), + status, + likelyIssues, + actionsNow: actionsNowRaw, + plan7Days: plan7DaysRaw, + }; +}; + +const buildIdentifyPrompt = (language, mode) => { + const reviewInstruction = mode === 'review' + ? 'Re-check your first hypothesis with stricter botanical accuracy and correct any mismatch.' + : 'Identify the most likely houseplant species from this image with conservative confidence.'; + + return [ + `${reviewInstruction}`, + 'Return strict JSON only in this shape:', + '{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}', + 'Rules:', + `- "name", "description", and "careInfo.light" must be written in ${getLanguageLabel(language)}.`, + '- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.', + '- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").', + '- "confidence" must be between 0 and 1.', + '- Keep confidence <= 0.55 when the image is ambiguous, blurred, or partially visible.', + '- "waterIntervalDays" must be an integer between 1 and 45.', + '- Do not include markdown, explanations, or extra keys.', + ].join('\n'); +}; + +const buildHealthPrompt = (language, plantContext) => { + const contextLines = plantContext + ? [ + 'Plant context:', + `- name: ${plantContext.name || 'n/a'}`, + `- botanicalName: ${plantContext.botanicalName || 'n/a'}`, + `- care.light: ${plantContext.careInfo?.light || 'n/a'}`, + `- care.temp: ${plantContext.careInfo?.temp || 'n/a'}`, + `- care.waterIntervalDays: ${plantContext.careInfo?.waterIntervalDays || 'n/a'}`, + `- description: ${plantContext.description || 'n/a'}`, + ] + : ['Plant context: not provided']; + + return [ + `You are an expert botanist and plant health diagnostician. Carefully examine every visible detail of this plant photo and produce a thorough, professional health assessment written in ${getLanguageLabel(language)}.`, + '', + 'Inspect the following in detail: leaf color (yellowing, browning, bleaching, dark spots, necrosis), leaf texture (wilting, crispy edges, curling, drooping), stem condition (rot, soft spots, discoloration), soil surface (dry cracks, mold, pests, waterlogging signs), visible pests (spider mites, fungus gnats, scale insects, aphids, mealybugs), root health (if visible), pot size and drainage.', + '', + 'Return strict JSON only in this exact shape:', + '{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}', + '', + 'Rules:', + '- "overallHealthScore": integer 0–100. 100=perfect health, 80–99=minor cosmetic only, 60–79=noticeable issues needing attention, 40–59=significant stress, below 40=severe/critical.', + '- "status": exactly one of "healthy" (score>=80, no active threats), "watch" (score 50–79, needs monitoring), "critical" (score<50, urgent action needed).', + '- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:', + ' - "title": concise issue name (e.g. "Overwatering / Root Rot Risk")', + ' - "confidence": float 0.05–0.99 reflecting visual certainty', + ' - "details": 2–4 sentence detailed explanation of what you observe visually, what causes it, and what happens if untreated. Be specific — mention leaf color, location, pattern.', + `- "actionsNow": 5 to 8 specific, actionable steps for the next 24–48 hours. Each step must be a complete sentence with concrete instructions (e.g. amounts, durations, techniques). Written in ${getLanguageLabel(language)}.`, + `- "plan7Days": 7 to 10 day-by-day or milestone care steps for the coming week. Each step should specify timing and expected outcome. Written in ${getLanguageLabel(language)}.`, + '- All text fields must be written in the specified language. No markdown, no extra keys.', + ...contextLines, + ].join('\n'); +}; + +const extractMessageContent = (payload) => { + const content = payload?.choices?.[0]?.message?.content; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((chunk) => (chunk && chunk.type === 'text' ? chunk.text || '' : '')) + .join('') + .trim(); + } + return ''; +}; + +const postChatCompletion = async ({ modelChain, messages, imageUri, temperature }) => { + if (!OPENAI_API_KEY) return null; + if (typeof fetch !== 'function') { + throw new Error('Global fetch is not available in this Node runtime.'); + } + + const attemptedModels = []; + + for (const model of modelChain) { + attemptedModels.push(model); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS); + + try { + const body = { + model, + response_format: { type: 'json_object' }, + messages, + }; + if (typeof temperature === 'number') body.temperature = temperature; + + const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${OPENAI_API_KEY}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!response.ok) { + const body = await response.text(); + console.warn('OpenAI request HTTP error.', { + status: response.status, + model, + endpoint: OPENAI_CHAT_COMPLETIONS_URL, + image: summarizeImageUri(imageUri), + bodyPreview: body.slice(0, 300), + }); + continue; + } + + const payload = await response.json(); + return { payload, modelUsed: model, attemptedModels }; + } catch (error) { + const isTimeoutAbort = error instanceof Error && error.name === 'AbortError'; + console.warn('OpenAI request failed.', { + model, + endpoint: OPENAI_CHAT_COMPLETIONS_URL, + timeoutMs: OPENAI_TIMEOUT_MS, + aborted: isTimeoutAbort, + error: toErrorMessage(error), + image: summarizeImageUri(imageUri), + }); + continue; + } finally { + clearTimeout(timeout); + } + } + + return { payload: null, modelUsed: null, attemptedModels }; +}; + +const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => { + if (!OPENAI_API_KEY) return { result: null, modelUsed: null, attemptedModels: [] }; + const completion = await postChatCompletion({ + modelChain: OPENAI_SCAN_MODEL_CHAIN, + imageUri, + messages: [ + { + role: 'system', + content: 'You are a plant identification assistant. Return strict JSON only.', + }, + { + role: 'user', + content: [ + { type: 'text', text: buildIdentifyPrompt(language, mode) }, + { type: 'image_url', image_url: { url: imageUri } }, + ], + }, + ], + }); + + if (!completion?.payload) { + return { + result: null, + modelUsed: completion?.modelUsed || null, + attemptedModels: completion?.attemptedModels || [], + }; + } + + const content = extractMessageContent(completion.payload); + if (!content) { + console.warn('OpenAI identify returned empty content.', { + model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0], + mode, + image: summarizeImageUri(imageUri), + }); + return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels }; + } + + const parsed = parseContentToJson(content); + if (!parsed) { + console.warn('OpenAI identify returned non-JSON content.', { + model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0], + mode, + preview: content.slice(0, 220), + }); + return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels }; + } + + const normalized = normalizeIdentifyResult(parsed, language); + if (!normalized) { + console.warn('OpenAI identify JSON did not match schema.', { + model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0], + mode, + keys: Object.keys(parsed), + }); + } + return { result: normalized, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels }; +}; + +const analyzePlantHealth = async ({ imageUri, language, plantContext }) => { + if (!OPENAI_API_KEY) return { analysis: null, modelUsed: null, attemptedModels: [] }; + const completion = await postChatCompletion({ + modelChain: OPENAI_HEALTH_MODEL_CHAIN, + imageUri, + temperature: 0, + messages: [ + { + role: 'system', + content: 'You are a plant health diagnosis assistant. Return strict JSON only.', + }, + { + role: 'user', + content: [ + { type: 'text', text: buildHealthPrompt(language, plantContext) }, + { type: 'image_url', image_url: { url: imageUri } }, + ], + }, + ], + }); + + if (!completion?.payload) { + return { + analysis: null, + modelUsed: completion?.modelUsed || null, + attemptedModels: completion?.attemptedModels || [], + }; + } + + const content = extractMessageContent(completion.payload); + if (!content) { + console.warn('OpenAI health returned empty content.', { + model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0], + image: summarizeImageUri(imageUri), + }); + return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels }; + } + + const parsed = parseContentToJson(content); + if (!parsed) { + console.warn('OpenAI health returned non-JSON content.', { + model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0], + preview: content.slice(0, 220), + }); + return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels }; + } + + return { + analysis: normalizeHealthAnalysis(parsed, language), + modelUsed: completion.modelUsed, + attemptedModels: completion.attemptedModels, + }; +}; + +module.exports = { + analyzePlantHealth, + getHealthModel: () => OPENAI_HEALTH_MODEL_CHAIN[0], + getScanModel: () => OPENAI_SCAN_MODEL_CHAIN[0], + identifyPlant, + isConfigured: () => Boolean(OPENAI_API_KEY), +}; diff --git a/server/lib/plants.js b/server/lib/plants.js new file mode 100644 index 0000000..71a1020 --- /dev/null +++ b/server/lib/plants.js @@ -0,0 +1,652 @@ +const crypto = require('crypto'); +const { all, get, run } = require('./sqlite'); + +const DEFAULT_LIMIT = 60; +const MAX_LIMIT = 500; +const MAX_AUDIT_DETAILS = 80; +const WIKIMEDIA_FILEPATH_SEGMENT = 'Special:FilePath/'; +const WIKIMEDIA_REDIRECT_BASE = 'https://commons.wikimedia.org/wiki/Special:FilePath/'; + +class PlantImportValidationError extends Error { + constructor(message, details) { + super(message); + this.name = 'PlantImportValidationError'; + this.details = details; + } +} + +const normalizeWhitespace = (value) => { + return value.trim().replace(/\s+/g, ' '); +}; + +const normalizeKey = (value) => { + return normalizeWhitespace(value) + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); +}; + +const unwrapMarkdownLink = (value) => { + const markdownMatch = value.match(/^\[[^\]]+]\((https?:\/\/[^)]+)\)(.*)$/i); + if (!markdownMatch) return value; + const [, url, suffix] = markdownMatch; + return `${url}${suffix || ''}`; +}; + +const tryDecode = (value) => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const convertWikimediaFilePathUrl = (value) => { + const segmentIndex = value.indexOf(WIKIMEDIA_FILEPATH_SEGMENT); + if (segmentIndex < 0) return null; + + const fileNameStart = segmentIndex + WIKIMEDIA_FILEPATH_SEGMENT.length; + const rawFileName = value.slice(fileNameStart).split(/[?#]/)[0].trim(); + if (!rawFileName) return null; + + const decodedFileName = tryDecode(rawFileName).replace(/\s+/g, ' ').trim(); + if (!decodedFileName) return null; + const encodedFileName = encodeURIComponent(decodedFileName).replace(/%2F/g, '/'); + return `${WIKIMEDIA_REDIRECT_BASE}${encodedFileName}`; +}; + +const normalizeImageUri = (rawUri) => { + if (typeof rawUri !== 'string') return null; + + const trimmed = rawUri.trim(); + if (!trimmed) return null; + + const normalized = unwrapMarkdownLink(trimmed); + const converted = convertWikimediaFilePathUrl(normalized); + const candidate = (converted || normalized).replace(/^http:\/\//i, 'https://'); + + let parsedUrl; + try { + parsedUrl = new URL(candidate); + } catch { + return null; + } + + const protocol = parsedUrl.protocol.toLowerCase(); + if (protocol !== 'https:' && protocol !== 'http:') return null; + if (!parsedUrl.hostname) return null; + + parsedUrl.protocol = 'https:'; + return parsedUrl.toString(); +}; + +const toArrayOfStrings = (value) => { + if (!Array.isArray(value)) return []; + const normalized = value + .map((item) => (typeof item === 'string' ? normalizeWhitespace(item) : '')) + .filter(Boolean); + return [...new Set(normalized)]; +}; + +const parseNumber = (value, fallback) => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return parsed; +}; + +const buildStablePlantId = (botanicalName) => { + const hash = crypto + .createHash('sha1') + .update(normalizeKey(botanicalName)) + .digest('hex') + .slice(0, 16); + return `plant_${hash}`; +}; + +const parseExistingIdMap = (rows) => { + const botanicalToId = new Map(); + rows.forEach((row) => { + if (!row || typeof row.botanicalName !== 'string' || typeof row.id !== 'string') return; + botanicalToId.set(normalizeKey(row.botanicalName), row.id); + }); + return botanicalToId; +}; + +const prepareEntry = (rawEntry, index, existingIdMap, preserveExistingIds) => { + const errors = []; + + const name = typeof rawEntry?.name === 'string' ? normalizeWhitespace(rawEntry.name) : ''; + const botanicalName = typeof rawEntry?.botanicalName === 'string' + ? normalizeWhitespace(rawEntry.botanicalName) + : ''; + + if (!name) { + errors.push({ index, field: 'name', message: 'name is required.' }); + } + if (!botanicalName) { + errors.push({ index, field: 'botanicalName', message: 'botanicalName is required.' }); + } + + const normalizedBotanicalKey = botanicalName ? normalizeKey(botanicalName) : ''; + const existingId = preserveExistingIds ? existingIdMap.get(normalizedBotanicalKey) : null; + + const incomingId = typeof rawEntry?.id === 'string' ? normalizeWhitespace(rawEntry.id) : ''; + const id = incomingId || existingId || (botanicalName ? buildStablePlantId(botanicalName) : ''); + + if (!id) { + errors.push({ index, field: 'id', message: 'Could not derive stable plant id.' }); + } + + const imageUri = normalizeImageUri(rawEntry?.imageUri); + if (!imageUri) { + errors.push({ + index, + field: 'imageUri', + message: 'imageUri is missing or invalid. A valid http(s) URL is required.', + value: rawEntry?.imageUri ?? null, + }); + } + + const categories = toArrayOfStrings(rawEntry?.categories); + const confidence = parseNumber(rawEntry?.confidence, 1); + const clampedConfidence = Math.max(0, Math.min(1, Number(confidence.toFixed(4)))); + const description = typeof rawEntry?.description === 'string' ? rawEntry.description.trim() : ''; + const careInfoRaw = rawEntry?.careInfo || {}; + const careInfo = { + waterIntervalDays: Math.max(1, Math.round(parseNumber(careInfoRaw.waterIntervalDays, 7))), + light: typeof careInfoRaw.light === 'string' && careInfoRaw.light.trim() + ? normalizeWhitespace(careInfoRaw.light) + : 'Unknown', + temp: typeof careInfoRaw.temp === 'string' && careInfoRaw.temp.trim() + ? normalizeWhitespace(careInfoRaw.temp) + : 'Unknown', + }; + + return { + entry: { + id, + name, + botanicalName, + imageUri, + imageStatus: 'ok', + description, + categories, + careInfo, + confidence: clampedConfidence, + }, + errors, + }; +}; + +const collectDuplicateErrors = (entries, getKey, fieldName, message) => { + const counts = new Map(); + entries.forEach((entry, index) => { + const key = getKey(entry); + if (!key) return; + const existing = counts.get(key) || []; + existing.push(index); + counts.set(key, existing); + }); + + const duplicateErrors = []; + counts.forEach((indices, key) => { + if (indices.length <= 1) return; + indices.forEach((index) => { + duplicateErrors.push({ + index, + field: fieldName, + message, + value: key, + }); + }); + }); + return duplicateErrors; +}; + +const assertValidPreparedEntries = (entries, enforceUniqueImages) => { + const duplicateErrors = []; + duplicateErrors.push( + ...collectDuplicateErrors( + entries, + (entry) => entry.id, + 'id', + 'Duplicate plant id detected in import payload.', + ), + ); + duplicateErrors.push( + ...collectDuplicateErrors( + entries, + (entry) => normalizeKey(entry.botanicalName), + 'botanicalName', + 'Duplicate botanicalName detected in import payload.', + ), + ); + + if (enforceUniqueImages) { + duplicateErrors.push( + ...collectDuplicateErrors( + entries, + (entry) => entry.imageUri, + 'imageUri', + 'Duplicate imageUri detected across multiple plants.', + ), + ); + } + + if (duplicateErrors.length > 0) { + throw new PlantImportValidationError( + 'Import payload contains duplicate keys.', + duplicateErrors.slice(0, MAX_AUDIT_DETAILS), + ); + } +}; + +const ensureColumn = async (db, tableName, columnName, definitionSql) => { + const columns = await all(db, `PRAGMA table_info(${tableName})`); + const hasColumn = columns.some((column) => column.name === columnName); + if (hasColumn) return; + await run(db, `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definitionSql}`); +}; + +const ensurePlantSchema = async (db) => { + await run( + db, + `CREATE TABLE IF NOT EXISTS plants ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + botanicalName TEXT NOT NULL, + imageUri TEXT NOT NULL, + description TEXT, + categories TEXT NOT NULL, + careInfo TEXT NOT NULL, + confidence REAL NOT NULL + )`, + ); + + await ensureColumn(db, 'plants', 'imageStatus', `TEXT NOT NULL DEFAULT 'ok'`); + await ensureColumn(db, 'plants', 'createdAt', `TEXT`); + await ensureColumn(db, 'plants', 'updatedAt', `TEXT`); + + await run( + db, + `CREATE TABLE IF NOT EXISTS plant_import_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + importedCount INTEGER NOT NULL DEFAULT 0, + preservedIds INTEGER NOT NULL DEFAULT 0, + duplicateImageCount INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + details TEXT, + backupTable TEXT, + startedAt TEXT NOT NULL, + completedAt TEXT NOT NULL + )`, + ); + + await run( + db, + `CREATE INDEX IF NOT EXISTS idx_plants_name ON plants(name COLLATE NOCASE)`, + ); + await run( + db, + `CREATE INDEX IF NOT EXISTS idx_plants_botanical_name ON plants(botanicalName COLLATE NOCASE)`, + ); + await run( + db, + `CREATE INDEX IF NOT EXISTS idx_plant_import_audit_started_at ON plant_import_audit(startedAt DESC)`, + ); + + await run( + db, + `UPDATE plants SET imageStatus = COALESCE(NULLIF(imageStatus, ''), 'ok')`, + ); + await run( + db, + `UPDATE plants SET createdAt = COALESCE(createdAt, datetime('now'))`, + ); + await run( + db, + `UPDATE plants SET updatedAt = COALESCE(updatedAt, datetime('now'))`, + ); +}; + +const parseJsonArray = (value) => { + if (!value) return []; + if (Array.isArray(value)) return value; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +const parseJsonObject = (value) => { + if (!value) return {}; + if (typeof value === 'object') return value; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +}; + +const toApiPlant = (row) => { + const categories = parseJsonArray(row.categories); + const careInfo = parseJsonObject(row.careInfo); + return { + id: row.id, + name: row.name, + botanicalName: row.botanicalName, + imageUri: row.imageUri, + imageStatus: row.imageStatus || 'ok', + description: row.description || '', + categories, + careInfo, + confidence: Number(row.confidence) || 0, + }; +}; + +const getPlants = async (db, options = {}) => { + const query = typeof options.query === 'string' ? options.query.trim().toLowerCase() : ''; + const category = typeof options.category === 'string' ? options.category.trim() : ''; + const limitRaw = Number(options.limit); + const limit = Number.isFinite(limitRaw) + ? Math.max(1, Math.min(MAX_LIMIT, Math.round(limitRaw))) + : DEFAULT_LIMIT; + + let sql = `SELECT + id, + name, + botanicalName, + imageUri, + imageStatus, + description, + categories, + careInfo, + confidence + FROM plants`; + const params = []; + if (query) { + sql += ` WHERE ( + LOWER(name) LIKE ? + OR LOWER(botanicalName) LIKE ? + OR LOWER(COALESCE(description, '')) LIKE ? + )`; + const likePattern = `%${query}%`; + params.push(likePattern, likePattern, likePattern); + } + sql += ' ORDER BY name COLLATE NOCASE ASC'; + + const rows = await all(db, sql, params); + let results = rows.map(toApiPlant); + + if (category) { + results = results.filter((plant) => plant.categories.includes(category)); + } + + return results.slice(0, limit); +}; + +const getPlantDiagnostics = async (db) => { + const totals = await get( + db, + `SELECT + COUNT(*) AS totalCount, + SUM(CASE WHEN imageUri IS NULL OR TRIM(imageUri) = '' THEN 1 ELSE 0 END) AS missingImageCount, + SUM(CASE WHEN COALESCE(imageStatus, 'ok') <> 'ok' THEN 1 ELSE 0 END) AS nonOkImageStatusCount + FROM plants`, + ); + + const duplicateImages = await all( + db, + `SELECT imageUri, COUNT(*) AS count + FROM plants + WHERE imageUri IS NOT NULL AND TRIM(imageUri) <> '' + GROUP BY imageUri + HAVING COUNT(*) > 1 + ORDER BY count DESC, imageUri ASC + LIMIT 200`, + ); + + const duplicateBotanicalNames = await all( + db, + `SELECT botanicalName, COUNT(*) AS count + FROM plants + WHERE botanicalName IS NOT NULL AND TRIM(botanicalName) <> '' + GROUP BY LOWER(botanicalName) + HAVING COUNT(*) > 1 + ORDER BY count DESC, botanicalName ASC + LIMIT 200`, + ); + + const recentAudits = await all( + db, + `SELECT + id, + source, + importedCount, + preservedIds, + duplicateImageCount, + status, + details, + backupTable, + startedAt, + completedAt + FROM plant_import_audit + ORDER BY startedAt DESC + LIMIT 20`, + ); + + return { + totalCount: Number(totals?.totalCount || 0), + missingImageCount: Number(totals?.missingImageCount || 0), + nonOkImageStatusCount: Number(totals?.nonOkImageStatusCount || 0), + duplicateImageCount: duplicateImages.length, + duplicateImages, + duplicateBotanicalNameCount: duplicateBotanicalNames.length, + duplicateBotanicalNames, + recentAudits: recentAudits.map((audit) => ({ + ...audit, + details: audit.details ? parseJsonObject(audit.details) : null, + })), + }; +}; + +const writeAuditRow = async (db, audit) => { + await run( + db, + `INSERT INTO plant_import_audit ( + source, + importedCount, + preservedIds, + duplicateImageCount, + status, + details, + backupTable, + startedAt, + completedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + audit.source, + audit.importedCount, + audit.preservedIds, + audit.duplicateImageCount, + audit.status, + JSON.stringify(audit.details || {}), + audit.backupTable || null, + audit.startedAt, + audit.completedAt, + ], + ); +}; + +const sanitizeIdentifier = (value) => { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + throw new Error(`Invalid SQL identifier: ${value}`); + } + return value; +}; + +const rebuildPlantsCatalog = async (db, rawEntries, options = {}) => { + if (!Array.isArray(rawEntries)) { + throw new PlantImportValidationError('Import payload must be an array of entries.', [ + { field: 'entries', message: 'Expected an array of plant objects.' }, + ]); + } + + const source = typeof options.source === 'string' && options.source.trim() + ? options.source.trim() + : 'manual'; + const preserveExistingIds = options.preserveExistingIds !== false; + const enforceUniqueImages = options.enforceUniqueImages !== false; + const startedAtIso = new Date().toISOString(); + + const existingRows = await all(db, 'SELECT id, botanicalName FROM plants'); + const existingIdMap = parseExistingIdMap(existingRows); + + const validationErrors = []; + const preparedEntries = rawEntries.map((rawEntry, index) => { + const prepared = prepareEntry(rawEntry, index, existingIdMap, preserveExistingIds); + if (prepared.errors.length > 0) { + validationErrors.push(...prepared.errors); + } + return prepared.entry; + }); + + if (validationErrors.length > 0) { + throw new PlantImportValidationError( + 'Import payload failed validation checks.', + validationErrors.slice(0, MAX_AUDIT_DETAILS), + ); + } + + assertValidPreparedEntries(preparedEntries, enforceUniqueImages); + + const preservedIds = preparedEntries.reduce((count, entry) => { + if (existingIdMap.get(normalizeKey(entry.botanicalName)) === entry.id) return count + 1; + return count; + }, 0); + + const timestamp = startedAtIso.replace(/[-:.TZ]/g, '').slice(0, 14); + const backupTable = sanitizeIdentifier(`plants_backup_${timestamp}`); + const details = { + enforceUniqueImages, + preserveExistingIds, + inputCount: rawEntries.length, + preparedCount: preparedEntries.length, + }; + + try { + await run(db, 'BEGIN IMMEDIATE TRANSACTION'); + await run(db, `DROP TABLE IF EXISTS ${backupTable}`); + await run(db, `CREATE TABLE ${backupTable} AS SELECT * FROM plants`); + await run(db, 'DELETE FROM plants'); + + for (const entry of preparedEntries) { + await run( + db, + `INSERT INTO plants ( + id, + name, + botanicalName, + imageUri, + imageStatus, + description, + categories, + careInfo, + confidence, + createdAt, + updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + entry.id, + entry.name, + entry.botanicalName, + entry.imageUri, + 'ok', + entry.description, + JSON.stringify(entry.categories), + JSON.stringify(entry.careInfo), + entry.confidence, + startedAtIso, + startedAtIso, + ], + ); + } + + await run( + db, + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_plants_botanical_name_unique ON plants(botanicalName)', + ); + if (enforceUniqueImages) { + await run( + db, + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_plants_image_uri_unique ON plants(imageUri)', + ); + } else { + await run(db, 'DROP INDEX IF EXISTS idx_plants_image_uri_unique'); + } + + await run(db, 'COMMIT'); + } catch (error) { + await run(db, 'ROLLBACK'); + const completedAtIso = new Date().toISOString(); + await writeAuditRow(db, { + source, + importedCount: 0, + preservedIds: 0, + duplicateImageCount: 0, + status: 'failed', + details: { + ...details, + error: error instanceof Error ? error.message : String(error), + }, + backupTable: null, + startedAt: startedAtIso, + completedAt: completedAtIso, + }); + throw error; + } + + const duplicateImages = await all( + db, + `SELECT imageUri, COUNT(*) AS count + FROM plants + GROUP BY imageUri + HAVING COUNT(*) > 1`, + ); + + const completedAtIso = new Date().toISOString(); + await writeAuditRow(db, { + source, + importedCount: preparedEntries.length, + preservedIds, + duplicateImageCount: duplicateImages.length, + status: 'success', + details, + backupTable, + startedAt: startedAtIso, + completedAt: completedAtIso, + }); + + return { + source, + importedCount: preparedEntries.length, + preservedIds, + duplicateImageCount: duplicateImages.length, + backupTable, + startedAt: startedAtIso, + completedAt: completedAtIso, + }; +}; + +module.exports = { + PlantImportValidationError, + ensurePlantSchema, + getPlantDiagnostics, + getPlants, + normalizeImageUri, + rebuildPlantsCatalog, +}; diff --git a/server/lib/sqlite.js b/server/lib/sqlite.js new file mode 100644 index 0000000..9d7013f --- /dev/null +++ b/server/lib/sqlite.js @@ -0,0 +1,86 @@ +const fs = require('fs'); +const path = require('path'); +const sqlite3 = require('sqlite3').verbose(); + +const getDefaultDbPath = () => { + return process.env.PLANT_DB_PATH || path.join(__dirname, '..', 'data', 'greenlns.sqlite'); +}; + +const ensureDbDirectory = (dbPath) => { + const directory = path.dirname(dbPath); + fs.mkdirSync(directory, { recursive: true }); +}; + +const openDatabase = (dbPath = getDefaultDbPath()) => { + ensureDbDirectory(dbPath); + + return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath, (error) => { + if (error) { + reject(error); + return; + } + resolve(db); + }); + }); +}; + +const closeDatabase = (db) => { + return new Promise((resolve, reject) => { + db.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +}; + +const run = (db, sql, params = []) => { + return new Promise((resolve, reject) => { + db.run(sql, params, function onRun(error) { + if (error) { + reject(error); + return; + } + resolve({ + lastId: this.lastID, + changes: this.changes, + }); + }); + }); +}; + +const get = (db, sql, params = []) => { + return new Promise((resolve, reject) => { + db.get(sql, params, (error, row) => { + if (error) { + reject(error); + return; + } + resolve(row || null); + }); + }); +}; + +const all = (db, sql, params = []) => { + return new Promise((resolve, reject) => { + db.all(sql, params, (error, rows) => { + if (error) { + reject(error); + return; + } + resolve(rows || []); + }); + }); +}; + +module.exports = { + all, + closeDatabase, + get, + getDefaultDbPath, + openDatabase, + run, +}; diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..1191b3c --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,2273 @@ +{ + "name": "server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "cors": "^2.8.6", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "sqlite3": "^5.1.7", + "stripe": "^20.3.1" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stripe": { + "version": "20.3.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz", + "integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..25b4040 --- /dev/null +++ b/server/package.json @@ -0,0 +1,22 @@ +{ + "name": "server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "node index.js", + "rebuild:batches": "node scripts/rebuild-from-batches.js", + "diagnostics": "node scripts/plant-diagnostics.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "cors": "^2.8.6", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "sqlite3": "^5.1.7", + "stripe": "^20.3.1" + } +} diff --git a/server/scripts/plant-diagnostics.js b/server/scripts/plant-diagnostics.js new file mode 100644 index 0000000..509d173 --- /dev/null +++ b/server/scripts/plant-diagnostics.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +require('dotenv').config(); + +const { closeDatabase, openDatabase } = require('../lib/sqlite'); +const { ensurePlantSchema, getPlantDiagnostics } = require('../lib/plants'); + +const main = async () => { + const db = await openDatabase(); + try { + await ensurePlantSchema(db); + const diagnostics = await getPlantDiagnostics(db); + console.log(JSON.stringify(diagnostics, null, 2)); + } finally { + await closeDatabase(db); + } +}; + +main().catch((error) => { + console.error('Failed to read plant diagnostics.'); + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/server/scripts/rebuild-from-batches.js b/server/scripts/rebuild-from-batches.js new file mode 100644 index 0000000..62f9aaf --- /dev/null +++ b/server/scripts/rebuild-from-batches.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +require('dotenv').config(); + +const { closeDatabase, openDatabase } = require('../lib/sqlite'); +const { ensurePlantSchema, rebuildPlantsCatalog } = require('../lib/plants'); + +let ts; +try { + ts = require('typescript'); +} catch (error) { + console.error('The rebuild script needs the "typescript" package in node_modules.'); + console.error('Install dependencies in the repository root before running this script.'); + process.exit(1); +} + +const ROOT_DIR = path.resolve(__dirname, '..', '..'); +const BATCH_1_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch1.ts'); +const BATCH_2_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch2.ts'); + +const resolveTsFilePath = (fromFile, specifier) => { + if (!specifier.startsWith('.')) return null; + const fromDirectory = path.dirname(fromFile); + const absoluteBase = path.resolve(fromDirectory, specifier); + const candidates = [ + absoluteBase, + `${absoluteBase}.ts`, + `${absoluteBase}.tsx`, + path.join(absoluteBase, 'index.ts'), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate; + } + } + return null; +}; + +const loadTsModule = (absolutePath, cache = new Map()) => { + if (cache.has(absolutePath)) return cache.get(absolutePath); + + const source = fs.readFileSync(absolutePath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + jsx: ts.JsxEmit.ReactJSX, + }, + fileName: absolutePath, + reportDiagnostics: false, + }).outputText; + + const module = { exports: {} }; + cache.set(absolutePath, module.exports); + + const localRequire = (specifier) => { + const resolvedTsPath = resolveTsFilePath(absolutePath, specifier); + if (resolvedTsPath) { + return loadTsModule(resolvedTsPath, cache); + } + return require(specifier); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + __dirname: path.dirname(absolutePath), + __filename: absolutePath, + console, + process, + Buffer, + setTimeout, + clearTimeout, + }; + + vm.runInNewContext(transpiled, sandbox, { filename: absolutePath }); + cache.set(absolutePath, module.exports); + return module.exports; +}; + +const loadBatchEntries = () => { + const batch1Module = loadTsModule(BATCH_1_PATH); + const batch2Module = loadTsModule(BATCH_2_PATH); + + const batch1Entries = batch1Module.LEXICON_BATCH_1_ENTRIES; + const batch2Entries = batch2Module.LEXICON_BATCH_2_ENTRIES; + + if (!Array.isArray(batch1Entries) || !Array.isArray(batch2Entries)) { + throw new Error('Could not load lexicon batch entries from TypeScript constants.'); + } + + return [...batch1Entries, ...batch2Entries]; +}; + +const main = async () => { + const entries = loadBatchEntries(); + const db = await openDatabase(); + + try { + await ensurePlantSchema(db); + const summary = await rebuildPlantsCatalog(db, entries, { + source: 'local_batch_script', + preserveExistingIds: true, + enforceUniqueImages: true, + }); + + console.log('Rebuild finished successfully.'); + console.log(JSON.stringify(summary, null, 2)); + } finally { + await closeDatabase(db); + } +}; + +main().catch((error) => { + console.error('Failed to rebuild plants from local batches.'); + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/services/authService.ts b/services/authService.ts new file mode 100644 index 0000000..04dd32d --- /dev/null +++ b/services/authService.ts @@ -0,0 +1,121 @@ +import * as SecureStore from 'expo-secure-store'; +import { AuthDb } from './database'; + +const SESSION_KEY = 'greenlens_session_v3'; +const BACKEND_URL = ( + process.env.EXPO_PUBLIC_BACKEND_URL || + process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL || + '' +).trim(); + +export interface AuthSession { + userId: number; // local SQLite id (for plants/settings queries) + serverUserId: string; // server-side user id (in JWT) + email: string; + name: string; + token: string; // JWT from server + loggedInAt: string; +} + +// ─── Internal helpers ────────────────────────────────────────────────────── + +const clearStoredSession = async (): Promise => { + await SecureStore.deleteItemAsync(SESSION_KEY); +}; + +const authPost = async (path: string, body: object): Promise<{ userId: string; email: string; name: string; token: string }> => { + const hasBackendUrl = Boolean(BACKEND_URL); + const url = hasBackendUrl ? `${BACKEND_URL}${path}` : path; + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (e) { + if (!hasBackendUrl) { + throw new Error('BACKEND_URL_MISSING'); + } + throw new Error('NETWORK_ERROR'); + } + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const code = (data as any).code || 'AUTH_ERROR'; + const msg = (data as any).message || ''; + console.warn(`[Auth] ${path} failed:`, response.status, code, msg); + throw new Error(code); + } + return data as any; +}; + +const buildSession = (data: { userId: string; email: string; name: string; token: string }): AuthSession => { + const localUser = AuthDb.ensureLocalUser(data.email, data.name); + return { + userId: localUser.id, + serverUserId: data.userId, + email: data.email, + name: data.name, + token: data.token, + loggedInAt: new Date().toISOString(), + }; +}; + +// ─── AuthService ─────────────────────────────────────────────────────────── + +export const AuthService = { + async getSession(): Promise { + try { + const raw = await SecureStore.getItemAsync(SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (!parsed.token || !parsed.serverUserId || !parsed.userId) { + await clearStoredSession(); + return null; + } + return parsed as AuthSession; + } catch { + await clearStoredSession(); + return null; + } + }, + + async signUp(email: string, name: string, password: string): Promise { + const data = await authPost('/auth/signup', { email, name, password }); + const session = buildSession(data); + await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session)); + return session; + }, + + async login(email: string, password: string): Promise { + const data = await authPost('/auth/login', { email, password }); + const session = buildSession(data); + await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session)); + return session; + }, + + async logout(): Promise { + await clearStoredSession(); + }, + + async updateSessionName(name: string): Promise { + const session = await this.getSession(); + if (!session) return; + await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify({ ...session, name })); + }, + + async validateWithServer(): Promise<'valid' | 'invalid' | 'unreachable'> { + const session = await this.getSession(); + if (!session) return 'invalid'; + if (!BACKEND_URL) return 'unreachable'; + try { + const response = await fetch(`${BACKEND_URL}/v1/billing/summary`, { + headers: { Authorization: `Bearer ${session.token}` }, + }); + if (response.status === 401 || response.status === 403) return 'invalid'; + return 'valid'; + } catch { + return 'unreachable'; + } + }, +};