diff --git a/client/src/components/invite-modal.riot b/client/src/components/invite-modal.riot index 89f5427..9f72e9d 100644 --- a/client/src/components/invite-modal.riot +++ b/client/src/components/invite-modal.riot @@ -10,7 +10,18 @@ -
+
Already have an account?
{ e.preventDefault(); props.cbSwitch() }}>Sign in
@@ -117,6 +128,47 @@
margin-bottom: var(--space-sm);
}
+ .nostr-divider {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ margin: var(--space-lg) 0;
+ color: var(--text-muted);
+ font-size: var(--text-sm);
+ }
+
+ .nostr-divider::before,
+ .nostr-divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--border);
+ }
+
+ .btn-nostr {
+ background: #8B5CF6;
+ color: white;
+ border: none;
+ padding: var(--space-sm) var(--space-md);
+ border-radius: var(--radius-md);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ cursor: pointer;
+ transition: background var(--transition-fast);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .btn-nostr:hover:not(:disabled) {
+ background: #7C3AED;
+ }
+
+ .btn-nostr:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
.auth-footer {
text-align: center;
margin-top: var(--space-lg);
@@ -127,6 +179,7 @@
diff --git a/client/src/services/api.js b/client/src/services/api.js
index 2148d7a..96894e2 100644
--- a/client/src/services/api.js
+++ b/client/src/services/api.js
@@ -33,7 +33,7 @@ async function request(method, path, body) {
if (!res.ok) {
// Auto-logout on 401 for any authenticated request (not login/register)
- if (res.status === 401 && path !== '/auth/login' && path !== '/auth/register') {
+ if (res.status === 401 && path !== '/auth/login' && path !== '/auth/register' && path !== '/auth/nostr/verify') {
if (onUnauthorized) onUnauthorized()
throw new Error('Session expired — please log in again')
}
@@ -110,6 +110,11 @@ export const api = {
// Invites
createInvite: (data) => request('POST', '/invites', data),
acceptInvite: (token) => request('POST', `/invites/${token}/accept`),
+ inviteByNostr: (data) => request('POST', '/invites/nostr', data),
+
+ // Nostr auth
+ nostrChallenge: () => request('GET', '/auth/nostr/challenge'),
+ nostrVerify: (data) => request('POST', '/auth/nostr/verify', data),
}
export function saveAuth(token, user) {
diff --git a/client/src/services/nostr.js b/client/src/services/nostr.js
new file mode 100644
index 0000000..b525513
--- /dev/null
+++ b/client/src/services/nostr.js
@@ -0,0 +1,75 @@
+/**
+ * NIP-07 browser extension helpers + relay profile fetch
+ */
+
+export function hasNostrExtension() {
+ return typeof window !== 'undefined' && !!window.nostr
+}
+
+export async function getPublicKey() {
+ return window.nostr.getPublicKey()
+}
+
+export async function signEvent(event) {
+ return window.nostr.signEvent(event)
+}
+
+/**
+ * Fetch a Nostr kind:0 profile from a relay via WebSocket.
+ * Returns { name, picture } or null on timeout/error.
+ */
+export function fetchNostrProfile(pubkeyHex, timeoutMs = 5000) {
+ return new Promise((resolve) => {
+ const relay = 'wss://relay.damus.io'
+ let ws
+ let timer
+
+ const cleanup = () => {
+ clearTimeout(timer)
+ try { ws?.close() } catch {}
+ }
+
+ timer = setTimeout(() => {
+ cleanup()
+ resolve(null)
+ }, timeoutMs)
+
+ try {
+ ws = new WebSocket(relay)
+ } catch {
+ cleanup()
+ resolve(null)
+ return
+ }
+
+ ws.onopen = () => {
+ const subId = 'profile_' + Math.random().toString(36).slice(2, 8)
+ ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pubkeyHex], limit: 1 }]))
+ }
+
+ ws.onmessage = (msg) => {
+ try {
+ const data = JSON.parse(msg.data)
+ if (data[0] === 'EVENT' && data[2]?.kind === 0) {
+ const profile = JSON.parse(data[2].content)
+ cleanup()
+ resolve({
+ name: profile.name || profile.display_name || null,
+ picture: profile.picture || null,
+ })
+ } else if (data[0] === 'EOSE') {
+ // End of stored events — no profile found
+ cleanup()
+ resolve(null)
+ }
+ } catch {
+ // ignore parse errors, wait for timeout
+ }
+ }
+
+ ws.onerror = () => {
+ cleanup()
+ resolve(null)
+ }
+ })
+}
diff --git a/server/Cargo.lock b/server/Cargo.lock
index 644ace4..2714a0a 100644
--- a/server/Cargo.lock
+++ b/server/Cargo.lock
@@ -8,6 +8,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
[[package]]
name = "ahash"
version = "0.8.12"
@@ -78,6 +88,12 @@ dependencies = [
"password-hash",
]
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
[[package]]
name = "async-compression"
version = "0.4.41"
@@ -235,6 +251,40 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+[[package]]
+name = "bech32"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
+
+[[package]]
+name = "bip39"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
+dependencies = [
+ "bitcoin_hashes",
+ "serde",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "bitcoin-io"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953"
+
+[[package]]
+name = "bitcoin_hashes"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
+dependencies = [
+ "bitcoin-io",
+ "hex-conservative",
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -262,6 +312,15 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "block-padding"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+dependencies = [
+ "generic-array",
+]
+
[[package]]
name = "brotli"
version = "8.0.2"
@@ -301,6 +360,15 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+[[package]]
+name = "cbc"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "cc"
version = "1.2.56"
@@ -317,6 +385,30 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
[[package]]
name = "chrono"
version = "0.4.44"
@@ -331,6 +423,17 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+ "zeroize",
+]
+
[[package]]
name = "compression-codecs"
version = "0.4.37"
@@ -436,6 +539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
+ "rand_core",
"typenum",
]
@@ -858,6 +962,7 @@ dependencies = [
"futures",
"jsonwebtoken",
"md-5",
+ "nostr",
"rand",
"reqwest",
"scraper",
@@ -971,6 +1076,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+[[package]]
+name = "hex-conservative"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
+dependencies = [
+ "arrayvec",
+]
+
[[package]]
name = "hkdf"
version = "0.12.4"
@@ -1285,6 +1399,28 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "block-padding",
+ "generic-array",
+]
+
+[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -1564,6 +1700,30 @@ dependencies = [
"minimal-lexical",
]
+[[package]]
+name = "nostr"
+version = "0.44.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
+dependencies = [
+ "base64 0.22.1",
+ "bech32",
+ "bip39",
+ "bitcoin_hashes",
+ "cbc",
+ "chacha20",
+ "chacha20poly1305",
+ "getrandom 0.2.17",
+ "hex",
+ "instant",
+ "scrypt",
+ "secp256k1",
+ "serde",
+ "serde_json",
+ "unicode-normalization",
+ "url",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1641,6 +1801,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
[[package]]
name = "openssl"
version = "0.10.75"
@@ -1725,6 +1891,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+[[package]]
+name = "pbkdf2"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
+dependencies = [
+ "digest",
+ "hmac",
+]
+
[[package]]
name = "pem"
version = "3.0.6"
@@ -1847,6 +2023,17 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2117,6 +2304,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "salsa20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
+dependencies = [
+ "cipher",
+]
+
[[package]]
name = "schannel"
version = "0.1.28"
@@ -2147,6 +2343,38 @@ dependencies = [
"tendril",
]
+[[package]]
+name = "scrypt"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
+dependencies = [
+ "password-hash",
+ "pbkdf2",
+ "salsa20",
+ "sha2",
+]
+
+[[package]]
+name = "secp256k1"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
+dependencies = [
+ "rand",
+ "secp256k1-sys",
+ "serde",
+]
+
+[[package]]
+name = "secp256k1-sys"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
+dependencies = [
+ "cc",
+]
+
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -3164,6 +3392,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -3180,6 +3418,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
+ "serde_derive",
]
[[package]]
diff --git a/server/Cargo.toml b/server/Cargo.toml
index b8046ce..7067a3a 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -27,3 +27,4 @@ scraper = "0.22"
md-5 = "0.10"
sha2 = "0.10"
base64 = "0.22"
+nostr = { version = "0.44", default-features = false, features = ["std"] }
diff --git a/server/migrations/008_nostr.sql b/server/migrations/008_nostr.sql
new file mode 100644
index 0000000..eb98676
--- /dev/null
+++ b/server/migrations/008_nostr.sql
@@ -0,0 +1 @@
+ALTER TABLE users ADD COLUMN nostr_pubkey TEXT UNIQUE;
diff --git a/server/src/handlers/auth.rs b/server/src/handlers/auth.rs
index 49e0d05..18338c0 100644
--- a/server/src/handlers/auth.rs
+++ b/server/src/handlers/auth.rs
@@ -8,7 +8,7 @@ use uuid::Uuid;
use crate::{
middleware::auth::{create_token, AuthUser},
- models::{AuthResponse, LoginRequest, RegisterRequest, UserPublic},
+ models::{self, AuthResponse, LoginRequest, RegisterRequest, UserPublic},
AppState,
};
@@ -61,7 +61,7 @@ pub async fn register(
token,
user: UserPublic {
id: user_id,
- email,
+ email: models::public_email(&email),
display_name,
avatar_url: None,
},
@@ -100,7 +100,7 @@ pub async fn login(
token,
user: UserPublic {
id: user_id,
- email,
+ email: models::public_email(&email),
display_name,
avatar_url,
},
@@ -121,7 +121,7 @@ pub async fn me(
Ok(Json(UserPublic {
id: auth.user_id,
- email: auth.email,
+ email: models::public_email(&auth.email),
display_name: auth.display_name,
avatar_url,
}))
diff --git a/server/src/handlers/invites.rs b/server/src/handlers/invites.rs
index f7e24c9..157e883 100644
--- a/server/src/handlers/invites.rs
+++ b/server/src/handlers/invites.rs
@@ -9,7 +9,7 @@ use uuid::Uuid;
use crate::{
middleware::auth::AuthUser,
- models::CreateInviteRequest,
+ models::{CreateInviteRequest, NostrInviteRequest},
AppState,
};
@@ -118,3 +118,73 @@ pub async fn accept_invite(
Ok(Json(AcceptInviteResponse { room_id }))
}
+
+#[derive(serde::Serialize)]
+pub struct NostrInviteResponse {
+ pub status: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub display_name: Option