diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json index dda1cfe6..d4e93f12 100644 --- a/apps/web/messages/ar.json +++ b/apps/web/messages/ar.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "أكّد كلمة المرور الجديدة", "submit": "إعادة تعيين كلمة المرور", "submitting": "جارٍ إعادة التعيين..." + }, + "coinpay": { + "signIn": "تسجيل الدخول باستخدام CoinPay", + "signUp": "إنشاء حساب باستخدام CoinPay", + "orEmail": "أو استخدم بريدك الإلكتروني", + "errors": { + "retry": "لم يكتمل تسجيل الدخول عبر CoinPay. حاول مرة أخرى.", + "unavailable": "تسجيل الدخول عبر CoinPay غير متاح الآن.", + "unconfirmed": "أكّد البريد الإلكتروني لهذا الحساب قبل تسجيل الدخول عبر CoinPay.", + "linkedElsewhere": "حساب CoinPay هذا مرتبط بالفعل بحساب PairUX آخر.", + "alreadyConnected": "هذا الحساب مرتبط بالفعل بحساب CoinPay مختلف. افصله أولاً." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "متصل: {channel}", "disconnect": "قطع الاتصال", "connect": "ربط YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "سجّل الدخول إلى PairUX بحساب CoinPay.", + "connected": "مرتبط", + "connectedAs": "مرتبط باسم {email}", + "connect": "ربط CoinPay", + "disconnect": "فصل", + "onlyWayIn": "CoinPay هو الطريقة الوحيدة لتسجيل الدخول إلى هذا الحساب، لذا لا يمكن فصله.", + "notConfigured": "ربط CoinPay غير متاح الآن.", + "justConnected": "تم ربط CoinPay." } }, "replay": { diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index e6cdc8f2..873e5f3e 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Neues Passwort erneut eingeben", "submit": "Passwort zurücksetzen", "submitting": "Wird zurückgesetzt…" + }, + "coinpay": { + "signIn": "Mit CoinPay anmelden", + "signUp": "Mit CoinPay registrieren", + "orEmail": "oder mit deiner E-Mail", + "errors": { + "retry": "Die CoinPay-Anmeldung wurde nicht abgeschlossen. Versuche es erneut.", + "unavailable": "Die Anmeldung mit CoinPay ist gerade nicht verfügbar.", + "unconfirmed": "Bestätige die E-Mail dieses Kontos, bevor du dich mit CoinPay anmeldest.", + "linkedElsewhere": "Dieses CoinPay-Konto ist bereits mit einem anderen PairUX-Konto verbunden.", + "alreadyConnected": "Dieses Konto ist bereits mit einem anderen CoinPay-Konto verbunden. Trenne es zuerst." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Verbunden – {channel}", "disconnect": "Trennen", "connect": "YouTube verbinden" + }, + "coinpay": { + "title": "CoinPay", + "description": "Melde dich mit deinem CoinPay-Konto bei PairUX an.", + "connected": "Verbunden", + "connectedAs": "Verbunden als {email}", + "connect": "CoinPay verbinden", + "disconnect": "Trennen", + "onlyWayIn": "CoinPay ist der einzige Weg in dieses Konto und kann daher nicht getrennt werden.", + "notConfigured": "CoinPay kann gerade nicht verbunden werden.", + "justConnected": "CoinPay ist verbunden." } }, "replay": { diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 3a44f468..d828e64c 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Confirm your new password", "submit": "Reset password", "submitting": "Resetting..." + }, + "coinpay": { + "signIn": "Sign in with CoinPay", + "signUp": "Sign up with CoinPay", + "orEmail": "or use your email", + "errors": { + "retry": "CoinPay sign-in did not complete. Try again.", + "unavailable": "Sign in with CoinPay is not available right now.", + "unconfirmed": "Confirm this account's email before signing in with CoinPay.", + "linkedElsewhere": "That CoinPay account is already connected to another PairUX account.", + "alreadyConnected": "This account is already connected to a different CoinPay account. Disconnect it first." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Connected — {channel}", "disconnect": "Disconnect", "connect": "Connect YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "Sign in to PairUX with your CoinPay account.", + "connected": "Connected", + "connectedAs": "Connected as {email}", + "connect": "Connect CoinPay", + "disconnect": "Disconnect", + "onlyWayIn": "CoinPay is the only way to sign in to this account, so it cannot be disconnected.", + "notConfigured": "Connecting CoinPay is not available right now.", + "justConnected": "CoinPay is connected." } }, "replay": { diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index e3fe1ad0..36a563a4 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Confirma tu nueva contraseña", "submit": "Restablecer contraseña", "submitting": "Restableciendo..." + }, + "coinpay": { + "signIn": "Iniciar sesión con CoinPay", + "signUp": "Registrarse con CoinPay", + "orEmail": "o usa tu correo", + "errors": { + "retry": "El inicio de sesión con CoinPay no se completó. Inténtalo de nuevo.", + "unavailable": "El inicio de sesión con CoinPay no está disponible ahora.", + "unconfirmed": "Confirma el correo de esta cuenta antes de iniciar sesión con CoinPay.", + "linkedElsewhere": "Esa cuenta de CoinPay ya está conectada a otra cuenta de PairUX.", + "alreadyConnected": "Esta cuenta ya está conectada a otra cuenta de CoinPay. Desconéctala primero." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Conectado: {channel}", "disconnect": "Desconectar", "connect": "Conectar YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "Inicia sesión en PairUX con tu cuenta de CoinPay.", + "connected": "Conectado", + "connectedAs": "Conectado como {email}", + "connect": "Conectar CoinPay", + "disconnect": "Desconectar", + "onlyWayIn": "CoinPay es la única forma de entrar a esta cuenta, así que no se puede desconectar.", + "notConfigured": "Conectar CoinPay no está disponible ahora.", + "justConnected": "CoinPay está conectado." } }, "replay": { diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 281d2610..c354346c 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Confirmez votre nouveau mot de passe", "submit": "Réinitialiser le mot de passe", "submitting": "Réinitialisation…" + }, + "coinpay": { + "signIn": "Se connecter avec CoinPay", + "signUp": "S'inscrire avec CoinPay", + "orEmail": "ou utilisez votre e-mail", + "errors": { + "retry": "La connexion CoinPay n'a pas abouti. Réessayez.", + "unavailable": "La connexion avec CoinPay n'est pas disponible pour le moment.", + "unconfirmed": "Confirmez l'e-mail de ce compte avant de vous connecter avec CoinPay.", + "linkedElsewhere": "Ce compte CoinPay est déjà connecté à un autre compte PairUX.", + "alreadyConnected": "Ce compte est déjà connecté à un autre compte CoinPay. Déconnectez-le d'abord." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Connecté : {channel}", "disconnect": "Déconnecter", "connect": "Connecter YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "Connectez-vous à PairUX avec votre compte CoinPay.", + "connected": "Connecté", + "connectedAs": "Connecté en tant que {email}", + "connect": "Connecter CoinPay", + "disconnect": "Déconnecter", + "onlyWayIn": "CoinPay est le seul moyen d'accéder à ce compte : il ne peut pas être déconnecté.", + "notConfigured": "La connexion de CoinPay n'est pas disponible pour le moment.", + "justConnected": "CoinPay est connecté." } }, "replay": { diff --git a/apps/web/messages/hi.json b/apps/web/messages/hi.json index 085a64b9..731815c1 100644 --- a/apps/web/messages/hi.json +++ b/apps/web/messages/hi.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "अपना नया पासवर्ड दोबारा डालें", "submit": "पासवर्ड रीसेट करें", "submitting": "रीसेट हो रहा है..." + }, + "coinpay": { + "signIn": "CoinPay से साइन इन करें", + "signUp": "CoinPay से साइन अप करें", + "orEmail": "या अपना ईमेल इस्तेमाल करें", + "errors": { + "retry": "CoinPay साइन-इन पूरा नहीं हुआ। फिर से कोशिश करें।", + "unavailable": "CoinPay से साइन इन अभी उपलब्ध नहीं है।", + "unconfirmed": "CoinPay से साइन इन करने से पहले इस खाते का ईमेल पुष्टि करें।", + "linkedElsewhere": "वह CoinPay खाता पहले से किसी दूसरे PairUX खाते से जुड़ा है।", + "alreadyConnected": "यह खाता पहले से किसी दूसरे CoinPay खाते से जुड़ा है। पहले उसे डिस्कनेक्ट करें।" + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "कनेक्टेड — {channel}", "disconnect": "डिस्कनेक्ट करें", "connect": "YouTube कनेक्ट करें" + }, + "coinpay": { + "title": "CoinPay", + "description": "अपने CoinPay खाते से PairUX में साइन इन करें।", + "connected": "जुड़ा हुआ", + "connectedAs": "{email} के रूप में जुड़ा", + "connect": "CoinPay जोड़ें", + "disconnect": "डिस्कनेक्ट करें", + "onlyWayIn": "इस खाते में साइन इन करने का एकमात्र तरीका CoinPay है, इसलिए इसे डिस्कनेक्ट नहीं किया जा सकता।", + "notConfigured": "CoinPay जोड़ना अभी उपलब्ध नहीं है।", + "justConnected": "CoinPay जुड़ गया।" } }, "replay": { diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json index 7351147b..c7f92db9 100644 --- a/apps/web/messages/id.json +++ b/apps/web/messages/id.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Konfirmasi kata sandi baru Anda", "submit": "Atur ulang kata sandi", "submitting": "Mengatur ulang..." + }, + "coinpay": { + "signIn": "Masuk dengan CoinPay", + "signUp": "Daftar dengan CoinPay", + "orEmail": "atau gunakan email Anda", + "errors": { + "retry": "Masuk dengan CoinPay tidak selesai. Coba lagi.", + "unavailable": "Masuk dengan CoinPay sedang tidak tersedia.", + "unconfirmed": "Konfirmasi email akun ini sebelum masuk dengan CoinPay.", + "linkedElsewhere": "Akun CoinPay itu sudah terhubung ke akun PairUX lain.", + "alreadyConnected": "Akun ini sudah terhubung ke akun CoinPay lain. Putuskan dulu." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Terhubung — {channel}", "disconnect": "Putuskan", "connect": "Hubungkan YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "Masuk ke PairUX dengan akun CoinPay Anda.", + "connected": "Terhubung", + "connectedAs": "Terhubung sebagai {email}", + "connect": "Hubungkan CoinPay", + "disconnect": "Putuskan", + "onlyWayIn": "CoinPay adalah satu-satunya cara masuk ke akun ini, jadi tidak bisa diputuskan.", + "notConfigured": "Menghubungkan CoinPay sedang tidak tersedia.", + "justConnected": "CoinPay terhubung." } }, "replay": { diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index b42f50b6..9a2b41df 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Conferma la nuova password", "submit": "Reimposta password", "submitting": "Reimpostazione..." + }, + "coinpay": { + "signIn": "Accedi con CoinPay", + "signUp": "Registrati con CoinPay", + "orEmail": "oppure usa la tua email", + "errors": { + "retry": "L'accesso con CoinPay non è stato completato. Riprova.", + "unavailable": "L'accesso con CoinPay non è disponibile al momento.", + "unconfirmed": "Conferma l'email di questo account prima di accedere con CoinPay.", + "linkedElsewhere": "Questo account CoinPay è già collegato a un altro account PairUX.", + "alreadyConnected": "Questo account è già collegato a un altro account CoinPay. Scollegalo prima." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Connesso: {channel}", "disconnect": "Disconnetti", "connect": "Connetti YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "Accedi a PairUX con il tuo account CoinPay.", + "connected": "Collegato", + "connectedAs": "Collegato come {email}", + "connect": "Collega CoinPay", + "disconnect": "Scollega", + "onlyWayIn": "CoinPay è l'unico modo per accedere a questo account, quindi non può essere scollegato.", + "notConfigured": "Il collegamento di CoinPay non è disponibile al momento.", + "justConnected": "CoinPay è collegato." } }, "replay": { diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 52eb8cf6..a135a4ca 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "新しいパスワードをもう一度入力", "submit": "パスワードをリセット", "submitting": "リセット中..." + }, + "coinpay": { + "signIn": "CoinPayでサインイン", + "signUp": "CoinPayで登録", + "orEmail": "またはメールアドレスを使う", + "errors": { + "retry": "CoinPayでのサインインが完了しませんでした。もう一度お試しください。", + "unavailable": "現在CoinPayでのサインインはご利用いただけません。", + "unconfirmed": "CoinPayでサインインする前に、このアカウントのメールアドレスを確認してください。", + "linkedElsewhere": "そのCoinPayアカウントは別のPairUXアカウントに接続済みです。", + "alreadyConnected": "このアカウントは別のCoinPayアカウントに接続済みです。先に接続を解除してください。" + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "接続済み — {channel}", "disconnect": "接続を解除", "connect": "YouTube に接続" + }, + "coinpay": { + "title": "CoinPay", + "description": "CoinPayアカウントでPairUXにサインインします。", + "connected": "接続済み", + "connectedAs": "{email} として接続済み", + "connect": "CoinPayを接続", + "disconnect": "接続を解除", + "onlyWayIn": "CoinPayがこのアカウントへの唯一のサインイン方法のため、接続を解除できません。", + "notConfigured": "現在CoinPayを接続できません。", + "justConnected": "CoinPayを接続しました。" } }, "replay": { diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index ba5fa53a..ef3c7b82 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "새 비밀번호를 다시 입력하세요", "submit": "비밀번호 재설정", "submitting": "재설정 중..." + }, + "coinpay": { + "signIn": "CoinPay로 로그인", + "signUp": "CoinPay로 가입", + "orEmail": "또는 이메일 사용", + "errors": { + "retry": "CoinPay 로그인이 완료되지 않았습니다. 다시 시도하세요.", + "unavailable": "지금은 CoinPay 로그인을 사용할 수 없습니다.", + "unconfirmed": "CoinPay로 로그인하기 전에 이 계정의 이메일을 인증하세요.", + "linkedElsewhere": "해당 CoinPay 계정은 이미 다른 PairUX 계정에 연결되어 있습니다.", + "alreadyConnected": "이 계정은 이미 다른 CoinPay 계정에 연결되어 있습니다. 먼저 연결을 해제하세요." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "연결됨 — {channel}", "disconnect": "연결 해제", "connect": "YouTube 연결" + }, + "coinpay": { + "title": "CoinPay", + "description": "CoinPay 계정으로 PairUX에 로그인하세요.", + "connected": "연결됨", + "connectedAs": "{email}(으)로 연결됨", + "connect": "CoinPay 연결", + "disconnect": "연결 해제", + "onlyWayIn": "CoinPay가 이 계정에 로그인하는 유일한 방법이라 연결을 해제할 수 없습니다.", + "notConfigured": "지금은 CoinPay를 연결할 수 없습니다.", + "justConnected": "CoinPay가 연결되었습니다." } }, "replay": { diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json index 7f765a9b..22ed5ec9 100644 --- a/apps/web/messages/nl.json +++ b/apps/web/messages/nl.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Bevestig je nieuwe wachtwoord", "submit": "Wachtwoord opnieuw instellen", "submitting": "Opnieuw instellen..." + }, + "coinpay": { + "signIn": "Inloggen met CoinPay", + "signUp": "Registreren met CoinPay", + "orEmail": "of gebruik je e-mail", + "errors": { + "retry": "Inloggen met CoinPay is niet voltooid. Probeer het opnieuw.", + "unavailable": "Inloggen met CoinPay is nu niet beschikbaar.", + "unconfirmed": "Bevestig het e-mailadres van dit account voordat je inlogt met CoinPay.", + "linkedElsewhere": "Dat CoinPay-account is al gekoppeld aan een ander PairUX-account.", + "alreadyConnected": "Dit account is al gekoppeld aan een ander CoinPay-account. Ontkoppel het eerst." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Verbonden: {channel}", "disconnect": "Verbinding verbreken", "connect": "YouTube koppelen" + }, + "coinpay": { + "title": "CoinPay", + "description": "Log in bij PairUX met je CoinPay-account.", + "connected": "Gekoppeld", + "connectedAs": "Gekoppeld als {email}", + "connect": "CoinPay koppelen", + "disconnect": "Ontkoppelen", + "onlyWayIn": "CoinPay is de enige manier om in dit account te komen, dus het kan niet worden ontkoppeld.", + "notConfigured": "CoinPay koppelen is nu niet beschikbaar.", + "justConnected": "CoinPay is gekoppeld." } }, "replay": { diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json index 757a1fae..7f407bc9 100644 --- a/apps/web/messages/pl.json +++ b/apps/web/messages/pl.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Wpisz nowe hasło ponownie", "submit": "Zresetuj hasło", "submitting": "Resetowanie..." + }, + "coinpay": { + "signIn": "Zaloguj się przez CoinPay", + "signUp": "Zarejestruj się przez CoinPay", + "orEmail": "lub użyj adresu e-mail", + "errors": { + "retry": "Logowanie przez CoinPay nie zostało ukończone. Spróbuj ponownie.", + "unavailable": "Logowanie przez CoinPay jest teraz niedostępne.", + "unconfirmed": "Potwierdź adres e-mail tego konta przed zalogowaniem przez CoinPay.", + "linkedElsewhere": "To konto CoinPay jest już połączone z innym kontem PairUX.", + "alreadyConnected": "To konto jest już połączone z innym kontem CoinPay. Najpierw je odłącz." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Połączono – {channel}", "disconnect": "Rozłącz", "connect": "Połącz z YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "Loguj się do PairUX kontem CoinPay.", + "connected": "Połączono", + "connectedAs": "Połączono jako {email}", + "connect": "Połącz CoinPay", + "disconnect": "Odłącz", + "onlyWayIn": "CoinPay to jedyny sposób logowania na to konto, więc nie można go odłączyć.", + "notConfigured": "Łączenie CoinPay jest teraz niedostępne.", + "justConnected": "CoinPay jest połączony." } }, "replay": { diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json index ff2d3e66..e8c98fb2 100644 --- a/apps/web/messages/pt.json +++ b/apps/web/messages/pt.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Confirme sua nova senha", "submit": "Redefinir senha", "submitting": "Redefinindo..." + }, + "coinpay": { + "signIn": "Entrar com CoinPay", + "signUp": "Cadastrar-se com CoinPay", + "orEmail": "ou use seu e-mail", + "errors": { + "retry": "O login com CoinPay não foi concluído. Tente novamente.", + "unavailable": "O login com CoinPay não está disponível agora.", + "unconfirmed": "Confirme o e-mail desta conta antes de entrar com CoinPay.", + "linkedElsewhere": "Essa conta CoinPay já está conectada a outra conta PairUX.", + "alreadyConnected": "Esta conta já está conectada a outra conta CoinPay. Desconecte-a primeiro." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Conectado: {channel}", "disconnect": "Desconectar", "connect": "Conectar o YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "Entre no PairUX com sua conta CoinPay.", + "connected": "Conectado", + "connectedAs": "Conectado como {email}", + "connect": "Conectar CoinPay", + "disconnect": "Desconectar", + "onlyWayIn": "CoinPay é a única forma de entrar nesta conta, então não pode ser desconectado.", + "notConfigured": "Conectar o CoinPay não está disponível agora.", + "justConnected": "CoinPay está conectado." } }, "replay": { diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 713db320..46f4f212 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Повторите новый пароль", "submit": "Сбросить пароль", "submitting": "Сброс..." + }, + "coinpay": { + "signIn": "Войти через CoinPay", + "signUp": "Зарегистрироваться через CoinPay", + "orEmail": "или используйте email", + "errors": { + "retry": "Вход через CoinPay не завершён. Попробуйте ещё раз.", + "unavailable": "Вход через CoinPay сейчас недоступен.", + "unconfirmed": "Подтвердите email этого аккаунта, прежде чем входить через CoinPay.", + "linkedElsewhere": "Этот аккаунт CoinPay уже привязан к другому аккаунту PairUX.", + "alreadyConnected": "Этот аккаунт уже привязан к другому аккаунту CoinPay. Сначала отвяжите его." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Подключено — {channel}", "disconnect": "Отключить", "connect": "Подключить YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "Входите в PairUX с аккаунтом CoinPay.", + "connected": "Привязан", + "connectedAs": "Привязан как {email}", + "connect": "Привязать CoinPay", + "disconnect": "Отвязать", + "onlyWayIn": "CoinPay — единственный способ войти в этот аккаунт, поэтому его нельзя отвязать.", + "notConfigured": "Привязка CoinPay сейчас недоступна.", + "justConnected": "CoinPay привязан." } }, "replay": { diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json index 4fba802b..7377544f 100644 --- a/apps/web/messages/sv.json +++ b/apps/web/messages/sv.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Bekräfta ditt nya lösenord", "submit": "Återställ lösenord", "submitting": "Återställer…" + }, + "coinpay": { + "signIn": "Logga in med CoinPay", + "signUp": "Registrera dig med CoinPay", + "orEmail": "eller använd din e-post", + "errors": { + "retry": "Inloggningen med CoinPay slutfördes inte. Försök igen.", + "unavailable": "Inloggning med CoinPay är inte tillgänglig just nu.", + "unconfirmed": "Bekräfta kontots e-post innan du loggar in med CoinPay.", + "linkedElsewhere": "Det CoinPay-kontot är redan kopplat till ett annat PairUX-konto.", + "alreadyConnected": "Det här kontot är redan kopplat till ett annat CoinPay-konto. Koppla från det först." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Ansluten – {channel}", "disconnect": "Koppla från", "connect": "Anslut YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "Logga in på PairUX med ditt CoinPay-konto.", + "connected": "Kopplat", + "connectedAs": "Kopplat som {email}", + "connect": "Koppla CoinPay", + "disconnect": "Koppla från", + "onlyWayIn": "CoinPay är det enda sättet att logga in på kontot, så det kan inte kopplas från.", + "notConfigured": "Det går inte att koppla CoinPay just nu.", + "justConnected": "CoinPay är kopplat." } }, "replay": { diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json index 83cb9e97..a5f55660 100644 --- a/apps/web/messages/tr.json +++ b/apps/web/messages/tr.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Yeni şifrenizi tekrar girin", "submit": "Şifreyi sıfırla", "submitting": "Sıfırlanıyor..." + }, + "coinpay": { + "signIn": "CoinPay ile giriş yap", + "signUp": "CoinPay ile kaydol", + "orEmail": "veya e-postanı kullan", + "errors": { + "retry": "CoinPay girişi tamamlanmadı. Tekrar dene.", + "unavailable": "CoinPay ile giriş şu anda kullanılamıyor.", + "unconfirmed": "CoinPay ile giriş yapmadan önce bu hesabın e-postasını doğrula.", + "linkedElsewhere": "Bu CoinPay hesabı zaten başka bir PairUX hesabına bağlı.", + "alreadyConnected": "Bu hesap zaten başka bir CoinPay hesabına bağlı. Önce bağlantısını kaldır." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Bağlı: {channel}", "disconnect": "Bağlantıyı kes", "connect": "YouTube’u bağla" + }, + "coinpay": { + "title": "CoinPay", + "description": "CoinPay hesabınla PairUX'a giriş yap.", + "connected": "Bağlı", + "connectedAs": "{email} olarak bağlı", + "connect": "CoinPay'i bağla", + "disconnect": "Bağlantıyı kes", + "onlyWayIn": "CoinPay bu hesaba girmenin tek yolu, bu yüzden bağlantısı kesilemez.", + "notConfigured": "CoinPay bağlama şu anda kullanılamıyor.", + "justConnected": "CoinPay bağlandı." } }, "replay": { diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index 92bca5fc..e79c8173 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Повторіть новий пароль", "submit": "Скинути пароль", "submitting": "Скидання..." + }, + "coinpay": { + "signIn": "Увійти через CoinPay", + "signUp": "Зареєструватися через CoinPay", + "orEmail": "або скористайтеся email", + "errors": { + "retry": "Вхід через CoinPay не завершено. Спробуйте ще раз.", + "unavailable": "Вхід через CoinPay зараз недоступний.", + "unconfirmed": "Підтвердьте email цього акаунта, перш ніж входити через CoinPay.", + "linkedElsewhere": "Цей акаунт CoinPay уже прив’язано до іншого акаунта PairUX.", + "alreadyConnected": "Цей акаунт уже прив’язано до іншого акаунта CoinPay. Спершу відв’яжіть його." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Підключено — {channel}", "disconnect": "Відключити", "connect": "Підключити YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "Входьте в PairUX з акаунтом CoinPay.", + "connected": "Прив’язано", + "connectedAs": "Прив’язано як {email}", + "connect": "Прив’язати CoinPay", + "disconnect": "Відв’язати", + "onlyWayIn": "CoinPay — єдиний спосіб увійти в цей акаунт, тому його не можна відв’язати.", + "notConfigured": "Прив’язка CoinPay зараз недоступна.", + "justConnected": "CoinPay прив’язано." } }, "replay": { diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index 49f10ae5..2f35f225 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "Nhập lại mật khẩu mới", "submit": "Đặt lại mật khẩu", "submitting": "Đang đặt lại..." + }, + "coinpay": { + "signIn": "Đăng nhập bằng CoinPay", + "signUp": "Đăng ký bằng CoinPay", + "orEmail": "hoặc dùng email của bạn", + "errors": { + "retry": "Đăng nhập bằng CoinPay chưa hoàn tất. Hãy thử lại.", + "unavailable": "Hiện không thể đăng nhập bằng CoinPay.", + "unconfirmed": "Hãy xác nhận email của tài khoản này trước khi đăng nhập bằng CoinPay.", + "linkedElsewhere": "Tài khoản CoinPay đó đã được liên kết với một tài khoản PairUX khác.", + "alreadyConnected": "Tài khoản này đã liên kết với một tài khoản CoinPay khác. Hãy ngắt liên kết trước." + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "Đã kết nối — {channel}", "disconnect": "Ngắt kết nối", "connect": "Kết nối YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "Đăng nhập PairUX bằng tài khoản CoinPay của bạn.", + "connected": "Đã liên kết", + "connectedAs": "Đã liên kết với {email}", + "connect": "Liên kết CoinPay", + "disconnect": "Ngắt liên kết", + "onlyWayIn": "CoinPay là cách duy nhất để đăng nhập tài khoản này nên không thể ngắt liên kết.", + "notConfigured": "Hiện không thể liên kết CoinPay.", + "justConnected": "Đã liên kết CoinPay." } }, "replay": { diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index e9745cd8..516ad9e9 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -159,6 +159,18 @@ "confirmNewPasswordPlaceholder": "再次输入新密码", "submit": "重置密码", "submitting": "正在重置..." + }, + "coinpay": { + "signIn": "使用 CoinPay 登录", + "signUp": "使用 CoinPay 注册", + "orEmail": "或使用电子邮箱", + "errors": { + "retry": "CoinPay 登录未完成,请重试。", + "unavailable": "CoinPay 登录暂不可用。", + "unconfirmed": "使用 CoinPay 登录前,请先验证此账户的电子邮箱。", + "linkedElsewhere": "该 CoinPay 账户已关联到另一个 PairUX 账户。", + "alreadyConnected": "此账户已关联到另一个 CoinPay 账户,请先解除关联。" + } } }, "cliAuthorize": { @@ -2035,6 +2047,17 @@ "connectedTo": "已连接 —— {channel}", "disconnect": "断开连接", "connect": "连接 YouTube" + }, + "coinpay": { + "title": "CoinPay", + "description": "使用你的 CoinPay 账户登录 PairUX。", + "connected": "已关联", + "connectedAs": "已关联为 {email}", + "connect": "关联 CoinPay", + "disconnect": "解除关联", + "onlyWayIn": "CoinPay 是登录此账户的唯一方式,因此无法解除关联。", + "notConfigured": "暂时无法关联 CoinPay。", + "justConnected": "CoinPay 已关联。" } }, "replay": { diff --git a/apps/web/package.json b/apps/web/package.json index b824f2d5..c4da4105 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@pairux/meeting-translation": "workspace:*", "@profullstack/affiliates": "^0.2.0", "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/emailer": "^1.0.3", "@profullstack/footer": "latest", "@profullstack/live-translate": "^0.3.0", diff --git a/apps/web/src/app/api/v1/coinpay/disconnect/route.ts b/apps/web/src/app/api/v1/coinpay/disconnect/route.ts new file mode 100644 index 00000000..5e32511d --- /dev/null +++ b/apps/web/src/app/api/v1/coinpay/disconnect/route.ts @@ -0,0 +1,26 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import { createClient } from '@/lib/supabase/server'; +import { serviceClient } from '@/lib/supabase/service'; +import { canDisconnect } from '@/lib/coinpay-signin'; +import { supabaseCoinpayStore } from '@/lib/coinpay-store'; + +export const dynamic = 'force-dynamic'; + +/** POST from Settings (same-origin fetch; the session cookie is SameSite=Lax). */ +export async function POST() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 }); + if (!canDisconnect(user.email)) { + return Response.json( + { + error: 'CoinPay is the only way to sign in to this account, so it cannot be disconnected.', + }, + { status: 409 } + ); + } + await supabaseCoinpayStore(serviceClient() as unknown as SupabaseClient).unlink(user.id); + return Response.json({ data: { connected: false } }); +} diff --git a/apps/web/src/app/api/v1/coinpay/oauth/callback/route.ts b/apps/web/src/app/api/v1/coinpay/oauth/callback/route.ts new file mode 100644 index 00000000..05d27d42 --- /dev/null +++ b/apps/web/src/app/api/v1/coinpay/oauth/callback/route.ts @@ -0,0 +1,127 @@ +import { createServerClient, type CookieOptions } from '@supabase/ssr'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import { NextResponse, type NextRequest } from 'next/server'; +import { finishSignIn, sameState } from '@profullstack/coinpay-onboard/oauth'; +import { createClient } from '@/lib/supabase/server'; +import { serviceClient } from '@/lib/supabase/service'; +import { + PENDING_COOKIE, + PENDING_COOKIE_PATH, + claimsFrom, + coinpayConfig, + decodePending, + redirectUri, + resolveCoinpayUser, +} from '@/lib/coinpay-signin'; +import { mintMagicLinkHash, supabaseCoinpayStore } from '@/lib/coinpay-store'; +import { INVITE_COOKIE, cookieFrom, onSignedIn, refFromRequest } from '@/lib/credits'; + +export const dynamic = 'force-dynamic'; + +/** CoinPay redirects here with ?code&state (or ?error). */ +export async function GET(request: NextRequest) { + const params = request.nextUrl.searchParams; + const cfg = coinpayConfig(); + const site = cfg?.siteUrl ?? process.env.NEXT_PUBLIC_APP_URL ?? 'https://pairux.com'; + const pending = cfg + ? decodePending(request.cookies.get(PENDING_COOKIE)?.value, cfg.clientSecret) + : null; + + const go = (path: string) => { + const res = NextResponse.redirect(new URL(path, site)); + res.headers.set('cache-control', 'no-store'); + // Single use: the pending sign-in is spent whatever happens next. + res.cookies.set(PENDING_COOKIE, '', { path: PENDING_COOKIE_PATH, maxAge: 0 }); + return res; + }; + const fail = (code: string) => + go(pending?.mode === 'connect' ? `/settings?coinpay=${code}` : `/login?coinpay=${code}`); + + if (!cfg) return fail('unavailable'); + if (!pending) return fail('expired'); + if (!sameState(pending.state, params.get('state'))) return fail('state'); + const code = params.get('code'); + if (params.get('error') || !code) return fail('denied'); + + let claims; + try { + const r = await finishSignIn({ + code, + verifier: pending.verifier, + nonce: pending.nonce, + clientId: cfg.clientId, + clientSecret: cfg.clientSecret, + redirectUri: redirectUri(cfg), + base: cfg.base, + }); + claims = claimsFrom(r); + } catch (err) { + console.error('[coinpay] sign-in failed:', err instanceof Error ? err.message : err); + return fail('failed'); + } + + const admin = serviceClient() as unknown as SupabaseClient; + const store = supabaseCoinpayStore(admin); + + let currentUserId: string | null = null; + if (pending.mode === 'connect') { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + currentUserId = user?.id ?? null; + } + + let result; + try { + result = await resolveCoinpayUser({ mode: pending.mode, currentUserId, claims }, store); + } catch (err) { + console.error('[coinpay] resolve failed:', err instanceof Error ? err.message : err); + return fail('failed'); + } + if (!result.ok) return fail(result.code); + if (pending.mode === 'connect') return go('/settings?coinpay=connected'); + + const minted = await mintMagicLinkHash(admin, result.userId); + if (!minted.ok) return fail(minted.code); + + const response = go(pending.next); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL ?? '', + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) { + cookiesToSet.forEach(({ name, value, options }) => { + response.cookies.set({ name, value, ...options }); + }); + }, + }, + } + ); + const { error } = await supabase.auth.verifyOtp({ + token_hash: minted.tokenHash, + type: 'magiclink', + }); + if (error) { + console.error('[coinpay] session mint failed:', error.message); + return fail('session'); + } + + // Same post-sign-in step as /auth/callback: a pending /i/ invite and a + // ?ref= attribution. The invite bonus stays pending for CoinPay-made + // accounts (credits.ts treats the placeholder address as unconfirmed). + try { + await onSignedIn(result.userId, { + inviteCode: cookieFrom(request, INVITE_COOKIE), + refCode: refFromRequest(request), + }); + if (request.cookies.get(INVITE_COOKIE)) response.cookies.delete(INVITE_COOKIE); + } catch (err) { + console.error('[coinpay] invite settle failed', err); + } + return response; +} diff --git a/apps/web/src/app/api/v1/coinpay/oauth/start/route.ts b/apps/web/src/app/api/v1/coinpay/oauth/start/route.ts new file mode 100644 index 00000000..3302a287 --- /dev/null +++ b/apps/web/src/app/api/v1/coinpay/oauth/start/route.ts @@ -0,0 +1,49 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + PENDING_COOKIE, + PENDING_COOKIE_PATH, + PENDING_TTL, + beginSignIn, + coinpayConfig, + encodePending, +} from '@/lib/coinpay-signin'; + +export const dynamic = 'force-dynamic'; + +/** GET ?mode=login|connect&next=/path -> 302 to CoinPay's authorize page. */ +export async function GET(request: NextRequest) { + const params = request.nextUrl.searchParams; + const mode = params.get('mode') === 'connect' ? 'connect' : 'login'; + const cfg = coinpayConfig(); + const site = cfg?.siteUrl ?? process.env.NEXT_PUBLIC_APP_URL ?? 'https://pairux.com'; + if (!cfg) { + return NextResponse.redirect( + new URL( + mode === 'connect' ? '/settings?coinpay=unavailable' : '/login?coinpay=unavailable', + site + ) + ); + } + // Connecting attaches CoinPay to the account you are in; there must be one. + if (mode === 'connect') { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return NextResponse.redirect(new URL('/login?redirect=/settings', site)); + } + + const { pending, url } = beginSignIn(cfg, mode, params.get('next')); + const response = NextResponse.redirect(url); + response.headers.set('cache-control', 'no-store'); + response.cookies.set(PENDING_COOKIE, encodePending(pending, cfg.clientSecret), { + httpOnly: true, + // lax: CoinPay's redirect back is a top-level GET navigation, which lax allows. + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: PENDING_COOKIE_PATH, + maxAge: PENDING_TTL, + }); + return response; +} diff --git a/apps/web/src/app/api/v1/coinpay/status/route.ts b/apps/web/src/app/api/v1/coinpay/status/route.ts new file mode 100644 index 00000000..e8130c3e --- /dev/null +++ b/apps/web/src/app/api/v1/coinpay/status/route.ts @@ -0,0 +1,30 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import { createClient } from '@/lib/supabase/server'; +import { serviceClient } from '@/lib/supabase/service'; +import { canDisconnect, coinpayConfig } from '@/lib/coinpay-signin'; +import { supabaseCoinpayStore } from '@/lib/coinpay-store'; + +export const dynamic = 'force-dynamic'; + +/** The signed-in user's CoinPay link, for the Settings card. */ +export async function GET() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 }); + const identity = await supabaseCoinpayStore( + serviceClient() as unknown as SupabaseClient + ).identityByUser(user.id); + return Response.json( + { + data: { + configured: coinpayConfig() !== null, + connected: identity !== null, + email: identity?.coinpay_email ?? null, + canDisconnect: canDisconnect(user.email), + }, + }, + { headers: { 'cache-control': 'no-store' } } + ); +} diff --git a/apps/web/src/app/settings/coinpay-connect.tsx b/apps/web/src/app/settings/coinpay-connect.tsx new file mode 100644 index 00000000..c54b6d8c --- /dev/null +++ b/apps/web/src/app/settings/coinpay-connect.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Wallet, Check, Loader2 } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { coinpayErrorKey } from '@/components/auth/CoinpaySignIn'; + +interface CpStatus { + configured: boolean; + connected: boolean; + email: string | null; + canDisconnect: boolean; +} + +/** + * Connect / Disconnect CoinPay (the house master account) so it can sign in to + * this PairUX account. Connecting needs this session; disconnecting is refused + * when CoinPay is the only way in. + */ +export function CoinpayConnect() { + const t = useTranslations('settings.coinpay'); + const tAuth = useTranslations('auth.coinpay'); + const searchParams = useSearchParams(); + const result = searchParams.get('coinpay'); + const errorKey = coinpayErrorKey(result); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + const res = await fetch('/api/v1/coinpay/status'); + if (res.ok) { + const json = (await res.json()) as { data: CpStatus }; + setStatus(json.data); + } + } catch { + // leave status null + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const disconnect = useCallback(async () => { + setBusy(true); + setError(null); + try { + const res = await fetch('/api/v1/coinpay/disconnect', { method: 'POST' }); + if (!res.ok) setError(t('onlyWayIn')); + await load(); + } finally { + setBusy(false); + } + }, [load, t]); + + return ( +
+
+
+
+ +
+
+

{t('title')}

+

{t('description')}

+
+
+
+
+ {result === 'connected' &&

{t('justConnected')}

} + {errorKey &&

{tAuth(`errors.${errorKey}`)}

} + {error &&

{error}

} + {status === null ? ( + + ) : status.connected ? ( +
+ + + {status.email ? t('connectedAs', { email: status.email }) : t('connected')} + + {status.canDisconnect ? ( + + ) : ( + {t('onlyWayIn')} + )} +
+ ) : !status.configured ? ( +

{t('notConfigured')}

+ ) : ( + + {t('connect')} + + )} +
+
+ ); +} diff --git a/apps/web/src/app/settings/settings-content.tsx b/apps/web/src/app/settings/settings-content.tsx index 1bb82af9..5c23a5c0 100644 --- a/apps/web/src/app/settings/settings-content.tsx +++ b/apps/web/src/app/settings/settings-content.tsx @@ -8,6 +8,7 @@ import { HeaderClient } from '@/components/header-client'; import { LanguageSwitcher } from '@/components/LanguageSwitcher'; import { NotificationPreferences } from '@/components/notifications/NotificationPreferences'; import { YouTubeConnect } from './youtube-connect'; +import { CoinpayConnect } from './coinpay-connect'; import { ProfileCard } from '@/components/profile/ProfileCard'; import type { UserData } from '@/components/header'; import type { RecordingQuality } from '@/hooks/useRecording'; @@ -118,6 +119,9 @@ export function SettingsContent({
+ {/* Sign in with CoinPay (the house master account) */} + {user && } + {/* YouTube auto go-live */} diff --git a/apps/web/src/components/auth/CoinpaySignIn.tsx b/apps/web/src/components/auth/CoinpaySignIn.tsx new file mode 100644 index 00000000..fbac0fbd --- /dev/null +++ b/apps/web/src/components/auth/CoinpaySignIn.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useTranslations } from 'next-intl'; + +/** The `?coinpay=` result codes the CoinPay routes redirect with, as message keys. */ +export function coinpayErrorKey( + code: string | null | undefined +): 'retry' | 'unavailable' | 'unconfirmed' | 'linkedElsewhere' | 'alreadyConnected' | null { + if (!code || code === 'connected') return null; + switch (code) { + case 'unavailable': + return 'unavailable'; + case 'unconfirmed': + return 'unconfirmed'; + case 'linked_elsewhere': + return 'linkedElsewhere'; + case 'already_connected': + return 'alreadyConnected'; + default: + return 'retry'; + } +} + +/** + * "Sign in with CoinPay": CoinPay is the house master account, so it comes + * first. A plain link, deliberately: the start route sets the PKCE/state cookie + * and 302s to CoinPay, which needs a full navigation. + */ +export function CoinpaySignIn({ mode, next }: { mode: 'login' | 'signup'; next?: string | null }) { + const t = useTranslations('auth'); + const href = `/api/v1/coinpay/oauth/start?mode=login${next ? `&next=${encodeURIComponent(next)}` : ''}`; + return ( +
+ + {mode === 'signup' ? t('coinpay.signUp') : t('coinpay.signIn')} + +
+
+
+
+
+ {t('coinpay.orEmail')} +
+
+
+ ); +} diff --git a/apps/web/src/components/auth/LoginForm.tsx b/apps/web/src/components/auth/LoginForm.tsx index b167ea5a..7115ebff 100644 --- a/apps/web/src/components/auth/LoginForm.tsx +++ b/apps/web/src/components/auth/LoginForm.tsx @@ -8,6 +8,7 @@ import { Mail, Lock, Eye, EyeOff, Loader2, AlertCircle, Key } from 'lucide-react import { useTranslations } from 'next-intl'; import { trackLogin } from '@/lib/analytics'; import { sameOriginPath } from '@/lib/safe-redirect'; +import { CoinpaySignIn, coinpayErrorKey } from './CoinpaySignIn'; export function safeRedirectPath(redirect: string | null): string { return sameOriginPath(redirect) ?? '/dashboard'; @@ -22,7 +23,10 @@ export function LoginForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); - const [error, setError] = useState(''); + const coinpayError = coinpayErrorKey(searchParams.get('coinpay')); + const [error, setError] = useState(() => + coinpayError ? t(`coinpay.errors.${coinpayError}`) : '' + ); const [loading, setLoading] = useState(false); const [rememberMe, setRememberMe] = useState(() => { if (typeof window === 'undefined') return false; @@ -79,6 +83,8 @@ export function LoginForm() { return (
+ + {error && (
diff --git a/apps/web/src/components/auth/SignupForm.tsx b/apps/web/src/components/auth/SignupForm.tsx index c229691c..807317d2 100644 --- a/apps/web/src/components/auth/SignupForm.tsx +++ b/apps/web/src/components/auth/SignupForm.tsx @@ -8,6 +8,7 @@ import { useTranslations } from 'next-intl'; import { ProfileFields } from '@profullstack/profile/react'; import type { ProfileField } from '@profullstack/profile'; import { trackSignup } from '@/lib/analytics'; +import { CoinpaySignIn } from './CoinpaySignIn'; // Email has its own input below; the shared package renders the other five. const PROFILE_FIELDS: ProfileField[] = [ @@ -95,6 +96,8 @@ export function SignupForm() { return ( + + {error && (
diff --git a/apps/web/src/components/auth/index.ts b/apps/web/src/components/auth/index.ts index d292f981..6167eb04 100644 --- a/apps/web/src/components/auth/index.ts +++ b/apps/web/src/components/auth/index.ts @@ -2,3 +2,4 @@ export { LoginForm } from './LoginForm'; export { SignupForm } from './SignupForm'; export { ForgotPasswordForm } from './ForgotPasswordForm'; export { ResetPasswordForm } from './ResetPasswordForm'; +export { CoinpaySignIn } from './CoinpaySignIn'; diff --git a/apps/web/src/lib/coinpay-signin.test.ts b/apps/web/src/lib/coinpay-signin.test.ts new file mode 100644 index 00000000..704003ad --- /dev/null +++ b/apps/web/src/lib/coinpay-signin.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { checkIdToken, sameState } from '@profullstack/coinpay-onboard/oauth'; +import { + beginSignIn, + canDisconnect, + decodePending, + encodePending, + isPlaceholderEmail, + resolveCoinpayUser, + type CoinpayStore, + type IdentityRow, +} from './coinpay-signin'; + +const cfg = { + clientId: 'cp_test', + clientSecret: 'secret-for-tests', + base: 'https://coinpayportal.com', + siteUrl: 'https://pairux.com', +}; + +function memStore() { + const users = new Map([ + ['victim', { id: 'victim', email: 'victim@example.com' }], + ]); + const ids = new Map(); + let n = 0; + const store: CoinpayStore = { + identityBySub: async (sub) => ids.get(sub) ?? null, + identityByUser: async (userId) => [...ids.values()].find((r) => r.user_id === userId) ?? null, + link: async (row) => { + if (ids.has(row.coinpay_sub) || [...ids.values()].some((r) => r.user_id === row.user_id)) + return 'conflict'; + ids.set(row.coinpay_sub, row); + return 'ok'; + }, + unlink: async (userId) => { + for (const [k, r] of ids) if (r.user_id === userId) ids.delete(k); + }, + touch: async () => undefined, + createUser: async ({ email }) => { + const id = `u${String(++n)}`; + users.set(id, { id, email }); + return id; + }, + deleteUser: async (id) => { + users.delete(id); + }, + }; + return { store, users, ids }; +} + +describe('Sign in with CoinPay', () => { + it('authorize URL carries PKCE S256, state, nonce and the exact redirect', () => { + const { pending, url } = beginSignIn(cfg, 'login', '/dashboard'); + const u = new URL(url); + expect(u.origin + u.pathname).toBe('https://coinpayportal.com/api/oauth/authorize'); + expect(u.searchParams.get('code_challenge_method')).toBe('S256'); + expect(u.searchParams.get('code_challenge')).toBeTruthy(); + expect(u.searchParams.get('state')).toBe(pending.state); + expect(u.searchParams.get('nonce')).toBe(pending.nonce); + expect(u.searchParams.get('redirect_uri')).toBe( + 'https://pairux.com/api/v1/coinpay/oauth/callback' + ); + expect(u.searchParams.get('scope')).toBe('openid profile email'); + }); + + it('pending cookie round-trips; forged, re-keyed, edited or expired ones are refused', () => { + const { pending } = beginSignIn(cfg, 'connect', '//evil.com'); + expect(pending.next).toBe('/settings'); + const sealed = encodePending(pending, cfg.clientSecret); + expect(decodePending(sealed, cfg.clientSecret)).toEqual(pending); + expect(decodePending(sealed, 'another-key')).toBeNull(); + const sig = sealed.split('.')[1]; + const edited = Buffer.from(JSON.stringify({ ...pending, state: 'evil' })).toString('base64url'); + expect(decodePending(`${edited}.${sig ?? ''}`, cfg.clientSecret)).toBeNull(); + expect( + decodePending(encodePending({ ...pending, exp: 1 }, cfg.clientSecret), cfg.clientSecret) + ).toBeNull(); + expect(decodePending(undefined, cfg.clientSecret)).toBeNull(); + }); + + it('a CoinPay login carrying an existing user email gets a NEW account, never theirs', async () => { + const { store, users } = memStore(); + const claims = { sub: 'attacker', email: 'victim@example.com', name: null }; + const r = await resolveCoinpayUser({ mode: 'login', currentUserId: null, claims }, store); + expect(r).toMatchObject({ ok: true, action: 'created' }); + if (!r.ok) throw new Error('unreachable'); + expect(r.userId).not.toBe('victim'); + expect(isPlaceholderEmail(users.get(r.userId)?.email)).toBe(true); + const again = await resolveCoinpayUser({ mode: 'login', currentUserId: null, claims }, store); + expect(again).toEqual({ ok: true, userId: r.userId, action: 'login' }); + }); + + it('connecting needs a signed-in session', async () => { + const { store, ids } = memStore(); + const r = await resolveCoinpayUser( + { mode: 'connect', currentUserId: null, claims: { sub: 's1', email: null, name: null } }, + store + ); + expect(r).toEqual({ ok: false, code: 'not_signed_in' }); + expect(ids.size).toBe(0); + }); + + it('connect links the signed-in account; a sub bound elsewhere cannot be taken', async () => { + const { store } = memStore(); + const claims = { sub: 's1', email: 'x@y.z', name: null }; + expect( + await resolveCoinpayUser({ mode: 'connect', currentUserId: 'victim', claims }, store) + ).toEqual({ ok: true, userId: 'victim', action: 'linked' }); + expect(await resolveCoinpayUser({ mode: 'login', currentUserId: null, claims }, store)).toEqual( + { + ok: true, + userId: 'victim', + action: 'login', + } + ); + expect( + await resolveCoinpayUser({ mode: 'connect', currentUserId: 'other', claims }, store) + ).toEqual({ ok: false, code: 'linked_elsewhere' }); + }); + + it('bad state and nonce are rejected', () => { + expect(sameState('abc', 'abd')).toBe(false); + expect(sameState('abc', null)).toBe(false); + const part = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url'); + const good = { + iss: 'https://coinpayportal.com', + sub: 's', + aud: 'cp_test', + exp: Math.floor(Date.now() / 1000) + 300, + nonce: 'n1', + }; + const tok = `${part({ alg: 'RS256' })}.${part(good)}.sig`; + expect(checkIdToken({ idToken: tok, clientId: 'cp_test', nonce: 'n1' }).sub).toBe('s'); + expect(() => checkIdToken({ idToken: tok, clientId: 'cp_test', nonce: 'n2' })).toThrow(); + }); + + it('disconnect is refused when CoinPay is the only way in', () => { + expect(canDisconnect('cp-ab@coinpay.pairux.invalid')).toBe(false); + expect(canDisconnect(null)).toBe(false); + expect(canDisconnect('real@example.com')).toBe(true); + }); +}); diff --git a/apps/web/src/lib/coinpay-signin.ts b/apps/web/src/lib/coinpay-signin.ts new file mode 100644 index 00000000..695c5b46 --- /dev/null +++ b/apps/web/src/lib/coinpay-signin.ts @@ -0,0 +1,266 @@ +// Sign in with CoinPay (CoinPay is the house master account). +// +// Confidential OIDC client: authorization code + PKCE S256 + state + nonce, +// redirect https://pairux.com/api/v1/coinpay/oauth/callback, scopes +// "openid profile email". The protocol half (authorize URL, code exchange, +// id_token checks, userinfo) is @profullstack/coinpay-onboard/oauth. +// +// STANDING security rule: a CoinPay login binds to the CoinPay `sub` in +// public.coinpay_identities. It never signs in to an existing account because +// the CoinPay email matches (CoinPay never verifies emails), never stores that +// email as the account's auth email, and never grants anything from it. +// Linking CoinPay to an existing account happens only from Settings while +// signed in. A first CoinPay login with no link creates a NEW PairUX account +// whose auth email is an undeliverable placeholder; the CoinPay email is kept +// on the identity row as unverified contact info. +// +// The decision logic is written against the CoinpayStore interface so tests run +// in memory; coinpay-store.ts is the Supabase one. +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { + authorizeUrl as cpAuthorizeUrl, + pkce, + randomToken, + SIGN_IN_SCOPES, +} from '@profullstack/coinpay-onboard/oauth'; +import { sameOriginPath } from '@/lib/safe-redirect'; + +export const COINPAY_BASE = 'https://coinpayportal.com'; +export const CALLBACK_PATH = '/api/v1/coinpay/oauth/callback'; +export const PENDING_COOKIE = 'pux_cp_oauth'; +export const PENDING_COOKIE_PATH = '/api/v1/coinpay/oauth'; +export const PENDING_TTL = 600; +/** Auth emails of accounts a CoinPay login created. `.invalid` never receives mail (RFC 2606). */ +export const PLACEHOLDER_EMAIL_DOMAIN = 'coinpay.pairux.invalid'; + +// Read at request time through a variable key: Next inlines `process.env.NAME` +// at build time, server code included, which would bake a missing secret in. +function runtimeEnv(name: string): string { + const v = (process.env as Record)[name]; + return typeof v === 'string' ? v.trim() : ''; +} + +export interface CoinpayConfig { + clientId: string; + clientSecret: string; + base: string; + siteUrl: string; +} + +export function coinpayConfig(): CoinpayConfig | null { + const clientId = runtimeEnv('COINPAY_OAUTH_CLIENT_ID'); + const clientSecret = runtimeEnv('COINPAY_OAUTH_CLIENT_SECRET'); + if (!clientId || !clientSecret) return null; + const base = (runtimeEnv('COINPAY_OAUTH_ISSUER') || COINPAY_BASE).replace(/\/+$/, ''); + // The redirect URI must equal the registration, so it is the canonical host, + // never whatever Host header the request carried. + const siteUrl = (runtimeEnv('NEXT_PUBLIC_APP_URL') || 'https://pairux.com').replace(/\/+$/, ''); + return { clientId, clientSecret, base, siteUrl }; +} + +export const redirectUri = (cfg: Pick) => cfg.siteUrl + CALLBACK_PATH; + +export type OAuthMode = 'login' | 'connect'; + +export interface PendingAuth { + state: string; + verifier: string; + nonce: string; + mode: OAuthMode; + next: string; + exp: number; +} + +const nowSec = () => Math.floor(Date.now() / 1000); + +export function safeNext(next: string | null | undefined, mode: OAuthMode = 'login'): string { + return sameOriginPath(next) ?? (mode === 'connect' ? '/settings' : '/dashboard'); +} + +/** A fresh pending sign-in plus the authorize URL that starts it. */ +export function beginSignIn( + cfg: CoinpayConfig, + mode: OAuthMode, + next: string | null +): { pending: PendingAuth; url: string } { + const { verifier, challenge } = pkce(); + const pending: PendingAuth = { + state: randomToken(), + verifier, + nonce: randomToken(), + mode, + next: safeNext(next, mode), + exp: nowSec() + PENDING_TTL, + }; + const url = cpAuthorizeUrl({ + clientId: cfg.clientId, + redirectUri: redirectUri(cfg), + state: pending.state, + nonce: pending.nonce, + challenge, + scopes: SIGN_IN_SCOPES, + base: cfg.base, + }); + return { pending, url }; +} + +function mac(value: string, key: string): string { + return createHmac('sha256', `pairux-coinpay-pending:${key}`).update(value).digest('base64url'); +} + +/** HMAC-sealed under the client secret, so a browser can hold it but not forge or edit it. */ +export function encodePending(p: PendingAuth, key: string): string { + const body = Buffer.from(JSON.stringify(p)).toString('base64url'); + return `${body}.${mac(body, key)}`; +} + +export function decodePending(raw: string | null | undefined, key: string): PendingAuth | null { + if (!raw) return null; + const dot = raw.lastIndexOf('.'); + if (dot < 1) return null; + const body = raw.slice(0, dot); + const sig = Buffer.from(raw.slice(dot + 1)); + const want = Buffer.from(mac(body, key)); + if (sig.length !== want.length || !timingSafeEqual(sig, want)) return null; + try { + const o = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Partial; + if ( + typeof o.state !== 'string' || + typeof o.verifier !== 'string' || + typeof o.nonce !== 'string' + ) + return null; + if (o.mode !== 'login' && o.mode !== 'connect') return null; + if (typeof o.exp !== 'number' || o.exp < nowSec()) return null; + return { + state: o.state, + verifier: o.verifier, + nonce: o.nonce, + mode: o.mode, + next: safeNext(o.next, o.mode), + exp: o.exp, + }; + } catch { + return null; + } +} + +export interface CoinpayClaims { + sub: string; + email: string | null; + name: string | null; +} + +/** Trim what CoinPay returned to what is stored; the email is contact info only. */ +export function claimsFrom(r: { + sub: string; + email: string | null; + name: string | null; +}): CoinpayClaims { + const email = typeof r.email === 'string' && r.email.includes('@') ? r.email.slice(0, 320) : null; + const name = typeof r.name === 'string' && r.name.trim() ? r.name.trim().slice(0, 200) : null; + return { sub: r.sub, email, name }; +} + +export interface IdentityRow { + user_id: string; + coinpay_sub: string; + coinpay_email: string | null; + created_via: 'connect' | 'signup'; +} + +export interface CoinpayStore { + identityBySub(sub: string): Promise; + identityByUser(userId: string): Promise; + /** Insert; "conflict" when the sub or the user is already bound. */ + link(row: IdentityRow & { coinpay_name: string | null }): Promise<'ok' | 'conflict'>; + unlink(userId: string): Promise; + touch(sub: string): Promise; + /** A brand-new auth user with a placeholder email (confirmed: the address is ours). */ + createUser(input: { + email: string; + name: string | null; + contactEmail: string | null; + }): Promise; + deleteUser(userId: string): Promise; +} + +export function placeholderEmail(): string { + return `cp-${randomBytes(12).toString('hex')}@${PLACEHOLDER_EMAIL_DOMAIN}`; +} + +export function isPlaceholderEmail(email: string | null | undefined): boolean { + return !!email && email.toLowerCase().endsWith('@' + PLACEHOLDER_EMAIL_DOMAIN); +} + +export type ResolutionError = 'not_signed_in' | 'linked_elsewhere' | 'already_connected'; +export type Resolution = + | { ok: true; userId: string; action: 'login' | 'created' | 'linked' } + | { ok: false; code: ResolutionError }; + +/** + * Which PairUX user a verified CoinPay sub belongs to. Never looks at the + * email: a CoinPay login carrying an existing user's address gets a NEW + * account (or the one already bound to its sub), never that user's. + */ +export async function resolveCoinpayUser( + input: { mode: OAuthMode; currentUserId: string | null; claims: CoinpayClaims }, + store: CoinpayStore +): Promise { + const { claims } = input; + const bound = await store.identityBySub(claims.sub); + + if (input.mode === 'connect') { + if (!input.currentUserId) return { ok: false, code: 'not_signed_in' }; + if (bound) { + return bound.user_id === input.currentUserId + ? { ok: true, userId: bound.user_id, action: 'linked' } + : { ok: false, code: 'linked_elsewhere' }; + } + if (await store.identityByUser(input.currentUserId)) + return { ok: false, code: 'already_connected' }; + const r = await store.link({ + user_id: input.currentUserId, + coinpay_sub: claims.sub, + coinpay_email: claims.email, + coinpay_name: claims.name, + created_via: 'connect', + }); + if (r === 'conflict') return { ok: false, code: 'linked_elsewhere' }; + return { ok: true, userId: input.currentUserId, action: 'linked' }; + } + + if (bound) { + await store.touch(claims.sub); + return { ok: true, userId: bound.user_id, action: 'login' }; + } + + const userId = await store.createUser({ + email: placeholderEmail(), + name: claims.name, + contactEmail: claims.email, + }); + const r = await store.link({ + user_id: userId, + coinpay_sub: claims.sub, + coinpay_email: claims.email, + coinpay_name: claims.name, + created_via: 'signup', + }); + if (r === 'conflict') { + // Lost a race with a parallel first login for the same sub: keep theirs. + await store.deleteUser(userId).catch(() => undefined); + const again = await store.identityBySub(claims.sub); + if (again) return { ok: true, userId: again.user_id, action: 'login' }; + return { ok: false, code: 'linked_elsewhere' }; + } + return { ok: true, userId, action: 'created' }; +} + +/** + * Disconnect is refused when CoinPay is the only way in: a placeholder auth + * email has no password and cannot receive a reset link. + */ +export function canDisconnect(authEmail: string | null | undefined): boolean { + return !!authEmail && !isPlaceholderEmail(authEmail); +} diff --git a/apps/web/src/lib/coinpay-store.ts b/apps/web/src/lib/coinpay-store.ts new file mode 100644 index 00000000..1829ddc0 --- /dev/null +++ b/apps/web/src/lib/coinpay-store.ts @@ -0,0 +1,81 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { CoinpayStore, IdentityRow } from './coinpay-signin'; + +/** public.coinpay_identities through the service role (RLS on, no policies). */ +export function supabaseCoinpayStore(sb: SupabaseClient): CoinpayStore { + const cols = 'user_id, coinpay_sub, coinpay_email, created_via'; + const table = () => sb.from('coinpay_identities'); + return { + async identityBySub(sub) { + const r = await table().select(cols).eq('coinpay_sub', sub).maybeSingle(); + if (r.error) throw new Error(r.error.message); + return (r.data as IdentityRow | null) ?? null; + }, + async identityByUser(userId) { + const r = await table().select(cols).eq('user_id', userId).maybeSingle(); + if (r.error) throw new Error(r.error.message); + return (r.data as IdentityRow | null) ?? null; + }, + async link(row) { + const r = await table().insert({ ...row, last_login_at: new Date().toISOString() }); + if (r.error) { + if (r.error.code === '23505') return 'conflict'; + throw new Error(r.error.message); + } + return 'ok'; + }, + async unlink(userId) { + const r = await table().delete().eq('user_id', userId); + if (r.error) throw new Error(r.error.message); + }, + async touch(sub) { + await table().update({ last_login_at: new Date().toISOString() }).eq('coinpay_sub', sub); + }, + async createUser({ email, name, contactEmail }) { + const [first, ...rest] = (name ?? '').split(/\s+/).filter(Boolean); + const r = await sb.auth.admin.createUser({ + email, + // The placeholder is ours and undeliverable; confirming it only lets the + // server mint a session for it. Email-gated features check the domain + // (isPlaceholderEmail) and treat it as unverified. + email_confirm: true, + user_metadata: { + display_name: name ?? 'CoinPay user', + ...(first ? { first_name: first } : {}), + ...(rest.length ? { last_name: rest.join(' ') } : {}), + signup_via: 'coinpay', + // Unverified contact info from CoinPay. Never an identity. + ...(contactEmail ? { coinpay_contact_email: contactEmail } : {}), + }, + app_metadata: { signup_via: 'coinpay' }, + }); + if (r.error) throw new Error(r.error.message); + return r.data.user.id; + }, + async deleteUser(userId) { + await sb.auth.admin.deleteUser(userId); + }, + }; +} + +/** + * A magic-link token hash for the user's OWN auth email, minted server side + * (nothing is emailed). The callback redeems it with verifyOtp on the cookie + * client, which writes the normal Supabase session cookies. Refuses an account + * whose email is not confirmed: redeeming the link would confirm it. + */ +export async function mintMagicLinkHash( + sb: SupabaseClient, + userId: string +): Promise< + { ok: true; tokenHash: string } | { ok: false; code: 'no_account' | 'unconfirmed' | 'session' } +> { + const u = await sb.auth.admin.getUserById(userId); + const user = u.data.user; + if (u.error || !user?.email) return { ok: false, code: 'no_account' }; + if (!user.email_confirmed_at) return { ok: false, code: 'unconfirmed' }; + const link = await sb.auth.admin.generateLink({ type: 'magiclink', email: user.email }); + const tokenHash = link.data.properties?.hashed_token; + if (link.error || !tokenHash) return { ok: false, code: 'session' }; + return { ok: true, tokenHash }; +} diff --git a/apps/web/src/lib/credits.test.ts b/apps/web/src/lib/credits.test.ts index b756ab01..4e69b470 100644 --- a/apps/web/src/lib/credits.test.ts +++ b/apps/web/src/lib/credits.test.ts @@ -251,6 +251,17 @@ describe('invites', () => { expect(daysLeft('alice')).toBeCloseTo(INVITE_BONUS_PRO_DAYS, 0); }); + it('a CoinPay-made account (placeholder address) never counts as confirmed', async () => { + const inv = await createInvite('alice', '', deps); + addUser('cpuser', { ageDays: 0, email: 'cp-0123abcd@coinpay.pairux.invalid' }); + expect(await acceptInvite('cpuser', inv.code, deps)).toMatchObject({ + ok: true, + bonus: 'pending', + }); + expect(profile('cpuser').plan).toBe('free'); + expect(profile('alice').plan).toBe('free'); + }); + it('pays nothing when both addresses are the same mailbox', async () => { env.users.get('alice')!.email = 'alice@gmail.com'; const inv = await createInvite('alice', '', deps); diff --git a/apps/web/src/lib/credits.ts b/apps/web/src/lib/credits.ts index 83d19bce..3eba9f46 100644 --- a/apps/web/src/lib/credits.ts +++ b/apps/web/src/lib/credits.ts @@ -27,6 +27,7 @@ * Clients are built lazily so env vars are read at runtime, not inlined at build. */ import type { SupabaseClient } from '@supabase/supabase-js'; +import { isPlaceholderEmail } from './coinpay-signin'; import { Credits, supabaseStore as creditStore, type Grant } from '@profullstack/redeem-credits'; import { Affiliates, supabaseStore as affiliateStore } from '@profullstack/affiliates'; import type { Plan } from '@pairux/shared-types'; @@ -437,7 +438,12 @@ async function emailOf( if (error) return { email: null, confirmed: false }; const u = data.user as { email?: string | null; email_confirmed_at?: string | null } | null; if (!u) return { email: null, confirmed: false }; - return { email: u.email ?? null, confirmed: Boolean(u.email_confirmed_at) }; + // A CoinPay-made account's address is our own undeliverable placeholder, + // confirmed only so a session can be minted for it: not a verified email. + return { + email: u.email ?? null, + confirmed: Boolean(u.email_confirmed_at) && !isPlaceholderEmail(u.email), + }; } /** Claim a (invite, side) decision; false when one was already recorded. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ba2bfeb..90c2fcdc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -391,12 +391,15 @@ importers: '@profullstack/autoblog': specifier: github:profullstack/autoblog#75e54af version: https://codeload.github.com/profullstack/autoblog/tar.gz/75e54af + '@profullstack/coinpay-onboard': + specifier: 0.3.1 + version: 0.3.1 '@profullstack/emailer': specifier: ^1.0.3 version: 1.0.3 '@profullstack/footer': specifier: latest - version: 0.4.0(react@19.3.1) + version: 0.5.0(react@19.3.1) '@profullstack/live-translate': specifier: ^0.3.0 version: 0.3.0 @@ -2140,7 +2143,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==} @@ -2295,6 +2298,12 @@ packages: '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4.12.4 + '@hono/node-server@2.1.5': resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} engines: {node: '>=20'} @@ -2720,6 +2729,16 @@ packages: resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==} engines: {node: '>=20.0.0'} + '@modelcontextprotocol/sdk@1.32.2': + resolution: {integrity: sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@next/env@16.4.3': resolution: {integrity: sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ==} @@ -2813,16 +2832,19 @@ packages: '@nut-tree-fork/libnut-darwin@2.7.5': resolution: {integrity: sha512-LbqtPtMPTJUcg4XoPP2jsU1wc8flBcGyKTerKsIfK9cD7nBHROnO0QksbrsbSWEpLym8T8fRtuU7XEY83l6Z2Q==} engines: {node: '>=10.15.3'} + cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut-linux@2.7.5': resolution: {integrity: sha512-uxaXEcRKnFObAljsoR6tLOBUU1dJ2sctloG6gFgCBGN7+k6Jdv6jZfOuNjd/fpdq2C5WPMm0rtn9EE7h5J3Jcg==} engines: {node: '>=10.15.3'} + cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut-win32@2.7.5': resolution: {integrity: sha512-yqC87zvmFcDPwFrRU40DYhN0xmEVM3aSkOuyF0IX+y1x+HWSu/i0PNklATpPBhGid3QVb/TOHuVoaraMrUFCNw==} engines: {node: '>=10.15.3'} + cpu: [x64, arm64] os: [darwin, linux, win32] '@nut-tree-fork/libnut@4.2.6': @@ -2836,6 +2858,7 @@ packages: '@nut-tree-fork/nut-js@4.2.6': resolution: {integrity: sha512-aI/WCX7gE1HFGPH3EZP/UWqpNMM1NMoM/EkXqp7pKMgXFCi8e5+o5p+jd/QOYpmALv9bQg7+s69nI7FONbMqDg==} engines: {node: '>=16'} + cpu: [x64, arm64] os: [linux, darwin, win32] '@nut-tree-fork/provider-interfaces@4.2.6': @@ -2966,6 +2989,11 @@ packages: version: 0.4.0 engines: {node: '>=18'} + '@profullstack/coinpay-onboard@0.3.1': + resolution: {integrity: sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ==} + engines: {node: '>=20'} + hasBin: true + '@profullstack/emailer@1.0.3': resolution: {integrity: sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw==} @@ -2979,6 +3007,16 @@ packages: react: optional: true + '@profullstack/footer@0.5.0': + resolution: {integrity: sha512-TrllS87nK7J0c/UgYckaFIWVhLOtr6m73XXFSHIuSfOg7NfuWUdwjtsfMXGaZiiUnmMYj3zhGcfzW0DicDwl8w==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + react: '>=18' + peerDependenciesMeta: + react: + optional: true + '@profullstack/form-guard@0.1.1': resolution: {integrity: sha512-RGLGfQhjq40f+JqEhSmsJnS0I5sFWjYdrYx4VYtkUqz8xvioN2yyR+6uFK0mnDAStKrYytlngg3meUsrChm/OQ==} engines: {node: '>=20'} @@ -4015,6 +4053,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -4054,6 +4096,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -4064,9 +4114,6 @@ packages: peerDependencies: ajv: ^8.8.2 - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -4410,6 +4457,10 @@ packages: bmp-js@0.1.0: resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} + body-parser@2.4.0: + resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==} + engines: {node: '>=18'} + boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -4766,9 +4817,29 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -4785,6 +4856,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cosmiconfig@5.2.1: resolution: {integrity: sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==} engines: {node: '>=4'} @@ -5294,6 +5369,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + exec-async@2.2.0: resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==} @@ -5480,6 +5563,16 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -5579,6 +5672,10 @@ packages: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-cache-dir@2.1.0: resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} engines: {node: '>=6'} @@ -5648,6 +5745,10 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -5659,6 +5760,10 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + from@0.1.7: resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} @@ -5969,6 +6074,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + icu-minify@4.14.9: resolution: {integrity: sha512-PmO+fNnC3K3mfPKTnm3RrPERrhQZnjm1AlpyZQ0ShbXi8Y/kYZ3YOde8TEjCLz9k3P2DmzkBn8lykGrCAi1Ajw==} @@ -6182,6 +6291,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -6447,6 +6559,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -6966,9 +7081,17 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -7042,10 +7165,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -7555,6 +7686,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -7619,6 +7753,10 @@ packages: resolution: {integrity: sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==} hasBin: true + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-dir@3.0.0: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} engines: {node: '>=6'} @@ -7847,6 +7985,10 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} @@ -7868,6 +8010,10 @@ packages: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true + qs@6.17.1: + resolution: {integrity: sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==} + engines: {node: '>=0.6'} + qs@6.5.5: resolution: {integrity: sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==} engines: {node: '>=0.6'} @@ -7893,6 +8039,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -8220,6 +8370,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + rrweb-cssom@0.7.1: resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} @@ -8318,6 +8472,10 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@2.1.0: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} @@ -8330,6 +8488,10 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} @@ -8409,6 +8571,10 @@ packages: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + side-channel-map@1.0.1: resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} engines: {node: '>= 0.4'} @@ -8421,6 +8587,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -8938,6 +9108,10 @@ packages: resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} engines: {node: '>=20'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -9482,6 +9656,11 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -11604,6 +11783,10 @@ snapshots: '@gar/promisify@1.1.3': {} + '@hono/node-server@1.19.17(hono@4.12.25)': + dependencies: + hono: 4.12.25 + '@hono/node-server@2.1.5(hono@4.12.25)': dependencies: hono: 4.12.25 @@ -12081,6 +12264,28 @@ snapshots: tinyglobby: 0.2.15 yaml: 2.9.1 + '@modelcontextprotocol/sdk@1.32.2(zod@4.6.7)': + dependencies: + '@hono/node-server': 1.19.17(hono@4.12.25) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 8.7.1(express@5.3.0) + hono: 4.12.25 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) + transitivePeerDependencies: + - supports-color + '@next/env@16.4.3': {} '@next/eslint-plugin-next@16.4.3(eslint@9.39.5(jiti@2.7.0))': @@ -12312,12 +12517,24 @@ snapshots: '@profullstack/autoblog@https://codeload.github.com/profullstack/autoblog/tar.gz/75e54af': {} + '@profullstack/coinpay-onboard@0.3.1': + dependencies: + '@modelcontextprotocol/sdk': 1.32.2(zod@4.6.7) + zod: 4.6.7 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + '@profullstack/emailer@1.0.3': {} '@profullstack/footer@0.4.0(react@19.3.1)': optionalDependencies: react: 19.3.1 + '@profullstack/footer@0.5.0(react@19.3.1)': + optionalDependencies: + react: 19.3.1 + '@profullstack/form-guard@0.1.1': {} '@profullstack/keys@0.1.0': {} @@ -13389,6 +13606,11 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.19.0): dependencies: acorn: 8.19.0 @@ -13418,6 +13640,10 @@ snapshots: optionalDependencies: ajv: 8.17.1 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-keywords@3.5.2(ajv@6.15.0): dependencies: ajv: 6.15.0 @@ -13427,14 +13653,6 @@ snapshots: ajv: 8.17.1 fast-deep-equal: 3.1.3 - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - optional: true - ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -13936,6 +14154,20 @@ snapshots: bmp-js@0.1.0: {} + body-parser@2.4.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.17.1 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolean@3.2.0: optional: true @@ -14377,8 +14609,18 @@ snapshots: console-control-strings@1.1.0: {} + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} core-js-compat@3.49.0: @@ -14392,6 +14634,11 @@ snapshots: core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cosmiconfig@5.2.1: dependencies: import-fresh: 2.0.0 @@ -15063,6 +15310,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + exec-async@2.2.0: {} execa@1.0.0: @@ -15305,6 +15558,47 @@ snapshots: exponential-backoff@3.1.3: {} + express-rate-limit@8.7.1(express@5.3.0): + dependencies: + debug: 4.4.3 + express: 5.3.0 + ip-address: 10.2.0 + transitivePeerDependencies: + - supports-color + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.4.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.17.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: optional: true @@ -15420,6 +15714,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-cache-dir@2.1.0: dependencies: commondir: 1.0.1 @@ -15501,12 +15806,16 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + forwarded@0.2.0: {} + fraction.js@5.3.4: {} freeport-async@2.0.0: {} fresh@0.5.2: {} + fresh@2.0.0: {} + from@0.1.7: {} fs-constants@1.0.0: {} @@ -15746,7 +16055,7 @@ snapshots: har-validator@5.1.5: dependencies: - ajv: 6.12.6 + ajv: 6.15.0 har-schema: 2.0.0 optional: true @@ -15885,6 +16194,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + icu-minify@4.14.9: dependencies: '@formatjs/icu-messageformat-parser': 3.5.21 @@ -16077,6 +16390,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -16424,6 +16739,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: optional: true @@ -16879,8 +17196,12 @@ snapshots: mdn-data@2.27.1: {} + media-typer@1.1.1: {} + memoize-one@5.2.1: {} + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -17064,10 +17385,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@2.6.0: {} @@ -17592,6 +17919,8 @@ snapshots: lru-cache: 11.5.3 minipass: 7.1.3 + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} pathe@1.1.2: {} @@ -17635,6 +17964,8 @@ snapshots: dependencies: pngjs: 3.4.0 + pkce-challenge@5.0.1: {} + pkg-dir@3.0.0: dependencies: find-up: 3.0.0 @@ -17798,6 +18129,11 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + psl@1.15.0: dependencies: punycode: 2.3.1 @@ -17818,6 +18154,11 @@ snapshots: qrcode-terminal@0.11.0: {} + qs@6.17.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + qs@6.5.5: optional: true @@ -17840,6 +18181,13 @@ snapshots: range-parser@1.2.1: {} + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -18299,6 +18647,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.56.0 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + rrweb-cssom@0.7.1: {} rrweb-cssom@0.8.0: {} @@ -18404,6 +18762,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + 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 + transitivePeerDependencies: + - supports-color + serialize-error@2.1.0: {} serialize-error@7.0.1: @@ -18420,6 +18794,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + server-only@0.0.1: {} serwist@9.5.13(browserslist@4.29.4)(typescript@5.9.3): @@ -18521,6 +18904,11 @@ snapshots: es-errors: 1.3.0 object-inspect: 1.13.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-map@1.0.1: dependencies: call-bound: 1.0.4 @@ -18544,6 +18932,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -19096,6 +19492,12 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -19702,6 +20104,10 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 + zod-to-json-schema@3.25.2(zod@4.6.7): + dependencies: + zod: 4.6.7 + zod@3.25.76: {} zod@4.6.5: {} diff --git a/supabase/migrations/20261029060000_coinpay_identities.sql b/supabase/migrations/20261029060000_coinpay_identities.sql new file mode 100644 index 00000000..59113dec --- /dev/null +++ b/supabase/migrations/20261029060000_coinpay_identities.sql @@ -0,0 +1,19 @@ +-- Sign in with CoinPay (apps/web/src/lib/coinpay-signin.ts). +-- +-- One row binds a PairUX auth user to a CoinPay `sub`. Binding is by sub only, +-- never by email: coinpay_email is unverified contact information from CoinPay +-- and must never be used to find or merge an account. Server-only: RLS is on +-- with no policies, so only the service role reads or writes it. +CREATE TABLE IF NOT EXISTS public.coinpay_identities ( + coinpay_sub text PRIMARY KEY, + user_id uuid NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, + coinpay_email text, + coinpay_name text, + created_via text NOT NULL CHECK (created_via IN ('connect', 'signup')), + created_at timestamptz NOT NULL DEFAULT now(), + last_login_at timestamptz +); + +ALTER TABLE public.coinpay_identities ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.coinpay_identities FROM anon, authenticated; +GRANT ALL ON public.coinpay_identities TO service_role;