Compare commits
No commits in common. "main" and "web-push" have entirely different histories.
@ -1,37 +0,0 @@
|
|||||||
# Keep the docker build context tiny — buildx serializes everything
|
|
||||||
# in here over the wire to the BuildKit container, so excluding
|
|
||||||
# generated trees is both a speedup AND a correctness guard (an
|
|
||||||
# earlier version had the buildx cache nested under kez-chat/deploy/
|
|
||||||
# and the cache grew with every build because it was being copied
|
|
||||||
# into itself).
|
|
||||||
|
|
||||||
# Rust target dirs
|
|
||||||
**/target/
|
|
||||||
|
|
||||||
# Node
|
|
||||||
**/node_modules/
|
|
||||||
**/.npm/
|
|
||||||
|
|
||||||
# SPA build artifacts (the Dockerfile rebuilds these inside the image)
|
|
||||||
kez-chat/web/dist/
|
|
||||||
|
|
||||||
# Buildx cache + prebuilt artifacts from the fast-deploy path —
|
|
||||||
# these are produced BY the build; they must not be inputs.
|
|
||||||
kez-chat/deploy/.buildx-cache/
|
|
||||||
kez-chat/deploy/prebuilt/
|
|
||||||
|
|
||||||
# Local dev / OS / editor cruft
|
|
||||||
.DS_Store
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*~
|
|
||||||
|
|
||||||
# Git internals — Dockerfile doesn't need history
|
|
||||||
.git/
|
|
||||||
|
|
||||||
# Local SQLite databases (and journals)
|
|
||||||
**/*.db
|
|
||||||
**/*.db-shm
|
|
||||||
**/*.db-wal
|
|
||||||
**/*.db-journal
|
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@ -34,9 +34,6 @@ kez-sigchains.db
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# Claude Code harness state (per-session scratch, not project code)
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
# Cross-test artifacts
|
# Cross-test artifacts
|
||||||
/tmp/
|
/tmp/
|
||||||
|
|
||||||
@ -46,14 +43,3 @@ kez-chat/deploy/deploy.local.sh
|
|||||||
kez-chat/deploy/*.local.sh
|
kez-chat/deploy/*.local.sh
|
||||||
kez-chat/deploy/.env
|
kez-chat/deploy/.env
|
||||||
kez-chat/deploy/.env.local
|
kez-chat/deploy/.env.local
|
||||||
|
|
||||||
# Prebuilt artifacts staged by deploy-fast.local.sh (binary + SPA dist).
|
|
||||||
# Regenerated on every run; nothing in here is source of truth.
|
|
||||||
kez-chat/deploy/prebuilt/
|
|
||||||
|
|
||||||
# Buildx local cache used by deploy-fast.local.sh to keep rust target/
|
|
||||||
# and node_modules warm between runs. Now lives at
|
|
||||||
# ~/.cache/kez-chat-buildx (outside the repo, see deploy-fast.local.sh);
|
|
||||||
# the path below is just a historical fence-post in case anyone has the
|
|
||||||
# old in-repo cache lying around from before that move.
|
|
||||||
kez-chat/deploy/.buildx-cache/
|
|
||||||
|
|||||||
522
kez-chat/Cargo.lock
generated
522
kez-chat/Cargo.lock
generated
@ -2,27 +2,6 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
[[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 = "aes"
|
|
||||||
version = "0.8.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cipher",
|
|
||||||
"cpufeatures",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@ -44,12 +23,6 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "allocator-api2"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -144,46 +117,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-utility"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a349201d80b4aa18d17a34a182bdd7f8ddf845e9e57d2ea130a12e10ef1e3a47"
|
|
||||||
dependencies = [
|
|
||||||
"futures-util",
|
|
||||||
"gloo-timers",
|
|
||||||
"tokio",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-wsocket"
|
|
||||||
version = "0.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8d50cb541e6d09e119e717c64c46ed33f49be7fa592fa805d56c11d6a7ff093c"
|
|
||||||
dependencies = [
|
|
||||||
"async-utility",
|
|
||||||
"futures",
|
|
||||||
"futures-util",
|
|
||||||
"js-sys",
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tokio-socks",
|
|
||||||
"tokio-tungstenite",
|
|
||||||
"url",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"web-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "atomic-destructor"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7d919cb60ba95c87ba42777e9e246c4e8d658057299b437b7512531ce0a09a23"
|
|
||||||
dependencies = [
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@ -257,15 +190,6 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base58ck"
|
|
||||||
version = "0.1.100"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ec5dc7e09f7bb15f0062da7c03086d6b71a2c84e0af4fccbbc7d8c6559847816"
|
|
||||||
dependencies = [
|
|
||||||
"bitcoin_hashes",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@ -296,12 +220,6 @@ version = "0.9.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445"
|
checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bech32"
|
|
||||||
version = "0.11.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "binstring"
|
name = "binstring"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@ -321,47 +239,13 @@ dependencies = [
|
|||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitcoin"
|
|
||||||
version = "0.32.100"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "39581299241111285f3268ba75ddf372746fd041620918b145c1af9d75e91b6c"
|
|
||||||
dependencies = [
|
|
||||||
"base58ck",
|
|
||||||
"bech32 0.11.1",
|
|
||||||
"bitcoin-io",
|
|
||||||
"bitcoin-units",
|
|
||||||
"bitcoin_hashes",
|
|
||||||
"hex-conservative",
|
|
||||||
"hex_lit",
|
|
||||||
"secp256k1",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitcoin-io"
|
|
||||||
version = "0.1.100"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitcoin-units"
|
|
||||||
version = "0.1.100"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "57bad157b78d0d1b22c4cbb6a35a566211fc4d14866a37f2c780652b50f3b845"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitcoin_hashes"
|
name = "bitcoin_hashes"
|
||||||
version = "0.14.100"
|
version = "0.14.100"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f"
|
checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitcoin-io",
|
|
||||||
"hex-conservative",
|
"hex-conservative",
|
||||||
"serde",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -379,15 +263,6 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@ -415,15 +290,6 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cbc"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
|
||||||
dependencies = [
|
|
||||||
"cipher",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.62"
|
version = "1.2.62"
|
||||||
@ -448,30 +314,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@ -486,17 +328,6 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.1"
|
version = "4.6.1"
|
||||||
@ -615,7 +446,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"rand_core 0.6.4",
|
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -683,12 +513,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "data-encoding"
|
|
||||||
version = "2.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.4.5"
|
version = "0.4.5"
|
||||||
@ -823,12 +647,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "either"
|
|
||||||
version = "1.16.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "elliptic-curve"
|
name = "elliptic-curve"
|
||||||
version = "0.13.8"
|
version = "0.13.8"
|
||||||
@ -1126,18 +944,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gloo-timers"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c"
|
|
||||||
dependencies = [
|
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "group"
|
name = "group"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@ -1164,8 +970,6 @@ version = "0.15.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"allocator-api2",
|
|
||||||
"equivalent",
|
|
||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1211,12 +1015,6 @@ dependencies = [
|
|||||||
"arrayvec",
|
"arrayvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hex_lit"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hkdf"
|
name = "hkdf"
|
||||||
version = "0.12.4"
|
version = "0.12.4"
|
||||||
@ -1355,7 +1153,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots 1.0.7",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1526,28 +1324,6 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@ -1665,9 +1441,7 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
"hkdf",
|
|
||||||
"kez-core",
|
"kez-core",
|
||||||
"nostr-sdk",
|
|
||||||
"p256",
|
"p256",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@ -1690,7 +1464,7 @@ name = "kez-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bech32 0.9.1",
|
"bech32",
|
||||||
"bip39",
|
"bip39",
|
||||||
"chrono",
|
"chrono",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
@ -1777,33 +1551,12 @@ version = "0.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lnurl-pay"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "536e7c782167a2d48346ca0b2677fad19eaef20f19a4ab868e4d5b96ca879def"
|
|
||||||
dependencies = [
|
|
||||||
"bech32 0.11.1",
|
|
||||||
"reqwest",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lru"
|
|
||||||
version = "0.12.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
|
||||||
dependencies = [
|
|
||||||
"hashbrown 0.15.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@ -1858,113 +1611,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "negentropy"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e664971378a3987224f7a0e10059782035e89899ae403718ee07de85bec42afe"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "negentropy"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "43a88da9dd148bbcdce323dd6ac47d369b4769d4a3b78c6c52389b9269f77932"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nostr"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "14ad56c1d9a59f4edc46b17bc64a217b38b99baefddc0080f85ad98a0855336d"
|
|
||||||
dependencies = [
|
|
||||||
"aes",
|
|
||||||
"async-trait",
|
|
||||||
"base64 0.22.1",
|
|
||||||
"bech32 0.11.1",
|
|
||||||
"bip39",
|
|
||||||
"bitcoin",
|
|
||||||
"cbc",
|
|
||||||
"chacha20",
|
|
||||||
"chacha20poly1305",
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"instant",
|
|
||||||
"js-sys",
|
|
||||||
"negentropy 0.3.1",
|
|
||||||
"negentropy 0.4.3",
|
|
||||||
"once_cell",
|
|
||||||
"reqwest",
|
|
||||||
"scrypt",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"unicode-normalization",
|
|
||||||
"url",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nostr-database"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1859abebf78d7d9e945b20c8faaf710c9db905adeb148035b803ae45792dbebe"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"lru",
|
|
||||||
"nostr",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nostr-relay-pool"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e39cfcb30cab86b30ca9acba89f5ccb25a4142a5dc5fcfbf3edf34b204ddd7c7"
|
|
||||||
dependencies = [
|
|
||||||
"async-utility",
|
|
||||||
"async-wsocket",
|
|
||||||
"atomic-destructor",
|
|
||||||
"negentropy 0.3.1",
|
|
||||||
"negentropy 0.4.3",
|
|
||||||
"nostr",
|
|
||||||
"nostr-database",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
|
||||||
"tokio-stream",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nostr-sdk"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e4739ed15ff81a0e474d79b38c3eb481ff5f968c1865f38ba46852daf6f6495e"
|
|
||||||
dependencies = [
|
|
||||||
"async-utility",
|
|
||||||
"atomic-destructor",
|
|
||||||
"lnurl-pay",
|
|
||||||
"nostr",
|
|
||||||
"nostr-database",
|
|
||||||
"nostr-relay-pool",
|
|
||||||
"nostr-zapper",
|
|
||||||
"nwc",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nostr-zapper"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9d9709ecf8050bbe4ecf0e5efda2f25b690bb1761fc504e05654621ba9e568a8"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"nostr",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@ -2020,21 +1666,6 @@ dependencies = [
|
|||||||
"libm",
|
"libm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nwc"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1b5f98bcaf232b3ec48e018792ca7bc2b90e7520d001a07b8218a9e76a03fda2"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"async-utility",
|
|
||||||
"nostr",
|
|
||||||
"nostr-relay-pool",
|
|
||||||
"nostr-zapper",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@ -2047,12 +1678,6 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "opaque-debug"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.80"
|
version = "0.10.80"
|
||||||
@ -2126,27 +1751,6 @@ version = "2.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "password-hash"
|
|
||||||
version = "0.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
|
||||||
dependencies = [
|
|
||||||
"base64ct",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"subtle",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pbkdf2"
|
|
||||||
version = "0.12.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
|
||||||
dependencies = [
|
|
||||||
"digest",
|
|
||||||
"hmac",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@ -2270,17 +1874,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -2526,7 +2119,7 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots 1.0.7",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2669,15 +2262,6 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f"
|
checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "salsa20"
|
|
||||||
version = "0.10.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
|
|
||||||
dependencies = [
|
|
||||||
"cipher",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
@ -2687,18 +2271,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[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]]
|
[[package]]
|
||||||
name = "sec1"
|
name = "sec1"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@ -2730,10 +2302,8 @@ version = "0.29.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitcoin_hashes",
|
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"secp256k1-sys",
|
"secp256k1-sys",
|
||||||
"serde",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2798,7 +2368,6 @@ version = "1.0.150"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
"serde",
|
"serde",
|
||||||
@ -2829,17 +2398,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sha1"
|
|
||||||
version = "0.10.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cpufeatures",
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@ -3151,18 +2709,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-socks"
|
|
||||||
version = "0.5.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615"
|
|
||||||
dependencies = [
|
|
||||||
"either",
|
|
||||||
"futures-util",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@ -3175,22 +2721,6 @@ dependencies = [
|
|||||||
"tokio-util",
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-tungstenite"
|
|
||||||
version = "0.24.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
|
||||||
dependencies = [
|
|
||||||
"futures-util",
|
|
||||||
"log",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tungstenite",
|
|
||||||
"webpki-roots 0.26.11",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@ -3338,26 +2868,6 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tungstenite"
|
|
||||||
version = "0.24.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
"bytes",
|
|
||||||
"data-encoding",
|
|
||||||
"http 1.4.0",
|
|
||||||
"httparse",
|
|
||||||
"log",
|
|
||||||
"rand 0.8.6",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"sha1",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
"utf-8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
@ -3391,16 +2901,6 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[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]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@ -3417,15 +2917,8 @@ dependencies = [
|
|||||||
"idna",
|
"idna",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf-8"
|
|
||||||
version = "0.7.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@ -3635,15 +3128,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webpki-roots"
|
|
||||||
version = "0.26.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
|
||||||
dependencies = [
|
|
||||||
"webpki-roots 1.0.7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
|
|||||||
@ -23,18 +23,11 @@ web-push = "0.10"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
p256 = { version = "0.13", features = ["pem"] }
|
p256 = { version = "0.13", features = ["pem"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
# Server-side nostr listener — subscribes to relays for every handle
|
|
||||||
# registered on this chat-server and fires Web Push when a kez-DM
|
|
||||||
# event for one of them lands. The web client publishes the same
|
|
||||||
# events; this is the missing link that lets push notifications work
|
|
||||||
# when chat goes over nostr instead of /v1/messages.
|
|
||||||
nostr-sdk = { version = "0.36", default-features = false, features = ["all-nips"] }
|
|
||||||
hkdf = "0.12"
|
|
||||||
sha2 = "0.10"
|
|
||||||
tower-http = { version = "0.6", features = ["trace", "cors", "fs"] }
|
tower-http = { version = "0.6", features = ["trace", "cors", "fs"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
|
sha2 = "0.10"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
336
kez-chat/TODO.md
336
kez-chat/TODO.md
@ -1,336 +0,0 @@
|
|||||||
# kez-chat security + protocol TODO
|
|
||||||
|
|
||||||
Consolidated from a nostr-protocol expert review and an independent security
|
|
||||||
audit (both run 2026-06-08). Ordered by impact-per-hour-of-work, not by
|
|
||||||
review severity — a CRIT that's a half-week of design discussion isn't a
|
|
||||||
ship-stopper when there are real CRITs that take 30 minutes.
|
|
||||||
|
|
||||||
Update the status column as we land things. Cross-links to the original
|
|
||||||
review reports are at the bottom.
|
|
||||||
|
|
||||||
## Status legend
|
|
||||||
|
|
||||||
- `TODO` — not started
|
|
||||||
- `WIP` — actively being worked
|
|
||||||
- `DONE` — landed in main
|
|
||||||
- `ROADMAP` — committed but multi-day; will need its own design doc
|
|
||||||
- `WONTFIX` — accepted trade-off, documented
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Day 1 — ~half a day of work, biggest wins
|
|
||||||
|
|
||||||
### #1. Strip envelope metadata (ephemeral x25519 per message) [TODO]
|
|
||||||
|
|
||||||
**Why it matters:** the `SealedEnvelope.from` (KEZ identity) and `to` (handle)
|
|
||||||
fields sit in cleartext alongside the ciphertext in `event.content`. Any
|
|
||||||
nostr relay can JSON-parse the content and build a perfect social graph:
|
|
||||||
who-messages-whom, when, how often. The actual message body stays encrypted;
|
|
||||||
all the metadata is wide open.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `kez-chat/web/src/lib/crypto.ts:42-54` (the `SealedEnvelope` shape)
|
|
||||||
- `kez-chat/web/src/lib/crypto.ts:115-153` (`sealMessage` / `openMessage`)
|
|
||||||
|
|
||||||
**Fix:** replace `envelope.from` (the KEZ identity) with `envelope.eph_pub`
|
|
||||||
(a 32-byte x25519 public key sender generated for this message only).
|
|
||||||
Recipient does ECDH against `eph_pub` instead of deriving it from the KEZ
|
|
||||||
identity. The decrypted plaintext already carries `from` for identity
|
|
||||||
verification.
|
|
||||||
|
|
||||||
Bonus: a fresh ephemeral key per message gives partial forward secrecy —
|
|
||||||
compromise of the recipient's long-term seed still decrypts retained
|
|
||||||
ciphertexts, but a captured single-message ECDH key reveals only that one
|
|
||||||
message.
|
|
||||||
|
|
||||||
**Migration:** envelope `v: 1 → v: 2`. Recipient accepts both during a
|
|
||||||
1-week window, then drops v1 support.
|
|
||||||
|
|
||||||
### #3. Empty Web Push payload [TODO]
|
|
||||||
|
|
||||||
**Why it matters:** we send `{type, to: <handle>, seq}` to FCM/APNs/Mozilla
|
|
||||||
on every fanout. RFC 8291 encrypts the payload so the push provider can't
|
|
||||||
read the bytes, but the provider already knows the endpoint's owner — the
|
|
||||||
`to` field adds no information for the recipient and *does* give Google a
|
|
||||||
clear "message arrived for alice at T" timeline.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `kez-chat/src/messages.rs:123-127` (the payload we hand to `push.fanout`)
|
|
||||||
- `kez-chat/web/src/sw.ts:78-99` (the `push` handler)
|
|
||||||
|
|
||||||
**Fix:** send `{}` as the payload. The service worker shows a generic
|
|
||||||
"New kez-chat message" notification — it can still focus an existing tab,
|
|
||||||
which navigates to the conversation list. Deep-linking to a specific peer
|
|
||||||
goes away on cold-open (acceptable trade-off — one extra tap to open the
|
|
||||||
right thread is the price of *not* exporting metadata to FCM).
|
|
||||||
|
|
||||||
### #5. Rename the routing tag from `h` to something less claimed [TODO]
|
|
||||||
|
|
||||||
**Why it matters:** `h` is informally used by NIP-29 (Simple Groups) as the
|
|
||||||
group id. Today's three relays don't enforce NIP-29 semantics, but the
|
|
||||||
moment a NIP-29-aware relay enters our pool it will try to route our `#h`
|
|
||||||
filter as a group join, and we'll get cryptic failures.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `kez-chat/web/src/lib/nostr-id.ts:28` (`ADDR_TAG = "h"`)
|
|
||||||
- `kez-chat/web/src/lib/nostr-transport.ts:201, 269` (publish + subscribe)
|
|
||||||
- `kez-chat/src/nostr_listener.rs:113` (server-side mirror)
|
|
||||||
|
|
||||||
**Fix:** switch to `q` (less-claimed single letter, still indexable per
|
|
||||||
NIP-01). Bump envelope/event `v` so the listener can tell old-tag events
|
|
||||||
from new ones during the migration window. Server-side listener subscribes
|
|
||||||
to BOTH `#h` and `#q` for one week.
|
|
||||||
|
|
||||||
### #17. Demote handle-revealing logs to `debug!` [TODO]
|
|
||||||
|
|
||||||
**Why it matters:** every fanout currently logs `push: fanout triggered
|
|
||||||
handle=<plain handle> sub_count=N` at INFO level. Operator-side log
|
|
||||||
retention turns this into a permanent "who's chatting" ledger. Even if
|
|
||||||
we trust the operator (it's us), forensics on a stolen log file leaks the
|
|
||||||
social graph in plaintext.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `kez-chat/src/push.rs:259-262, 275-281` (fanout + send logging)
|
|
||||||
- `kez-chat/src/api.rs:387-393` (subscribe registration)
|
|
||||||
|
|
||||||
**Fix:** demote the handle-bearing INFO lines to DEBUG. Replace the visible
|
|
||||||
field with a short HMAC of the handle under a server-instance secret so we
|
|
||||||
can still group "all sends for X" in logs without exposing X. Set log level
|
|
||||||
in production to INFO, so DEBUG lines are off by default.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Day 2 — another half-day
|
|
||||||
|
|
||||||
### #2. Replay protection — bound + timestamp freshness [DONE]
|
|
||||||
|
|
||||||
**Why it matters:** `SEEN_CAP=500` evicts oldest event ids once we've seen
|
|
||||||
500 messages. An active user rolls past that in days, then a malicious
|
|
||||||
relay can re-broadcast any old event and we accept it as a fresh message —
|
|
||||||
the decrypted `sent_at` is never compared to wall-clock.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `kez-chat/web/src/lib/nostr-transport.ts:107` (`SEEN_CAP = 500`)
|
|
||||||
- `kez-chat/web/src/lib/nostr-transport.ts:142` (`slice(-SEEN_CAP)`)
|
|
||||||
- `kez-chat/web/src/lib/crypto.ts:161-205` (`openMessage` — no freshness check)
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
1. Bump `SEEN_CAP` to 10_000 and move from localStorage to IndexedDB so the
|
|
||||||
set isn't capped by the 5MB localStorage quota.
|
|
||||||
2. In `openMessage`, reject envelopes where `|now − sent_at| > 7 days`.
|
|
||||||
3. Also clamp `ev.created_at` to `[now − 7d, now + 5min]` before using it
|
|
||||||
as a seq generator — otherwise a relay can backdate or future-date
|
|
||||||
events and either replay or skip-ahead `bumpSince`.
|
|
||||||
|
|
||||||
### #4. Reveal-recovery-phrase requires fresh auth [DONE]
|
|
||||||
|
|
||||||
**Why it matters:** 30 seconds of access to an unlocked phone = full
|
|
||||||
identity exfil. The Settings → Reveal Phrase button decrypts straight from
|
|
||||||
the persistent-session blob with no re-prompt.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `kez-chat/web/src/routes/Settings.svelte` (the Reveal flow)
|
|
||||||
|
|
||||||
**Fix:** gate Reveal Phrase + Lock + biometric setup behind a fresh
|
|
||||||
passphrase prompt OR a WebAuthn assertion. Same model Apple/1Password use:
|
|
||||||
"this action requires your password again".
|
|
||||||
|
|
||||||
### #15. Rate-limit `POST /v1/messages` [DONE]
|
|
||||||
|
|
||||||
**Why it matters:** the endpoint currently accepts anonymous posts (no
|
|
||||||
auth on send) capped at 256KB per envelope. A bot can fill any mailbox
|
|
||||||
until disk fills. Acknowledged in `messages.rs:18-20` ("Spam: v0.1 doesn't
|
|
||||||
gate POST").
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `kez-chat/src/messages.rs:70-133`
|
|
||||||
|
|
||||||
**Fix (v0.1):** per-IP token bucket — 60 messages/min per source IP. Drop
|
|
||||||
overflow with 429.
|
|
||||||
**Fix (v0.2):** require the sender to sign with their KEZ primary; chat-
|
|
||||||
server verifies. Becomes useless for cross-server v0.2 unless the sender's
|
|
||||||
server vouches.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap — multi-day, needs design pass
|
|
||||||
|
|
||||||
### #6. Forward secrecy (Double Ratchet) [ROADMAP]
|
|
||||||
|
|
||||||
**Why it matters:** today's static-static x25519 means whoever compromises
|
|
||||||
a seed once decrypts ALL retained history that any relay still has — and
|
|
||||||
relays retain indefinitely. The ephemeral-x25519 fix in #1 is partial
|
|
||||||
forward secrecy (per-message) but not post-compromise security.
|
|
||||||
|
|
||||||
**What's needed:** Signal-style X3DH + Double Ratchet. Significant
|
|
||||||
refactor of crypto.ts; needs careful API design so existing conversations
|
|
||||||
migrate cleanly. Owner: TBD. ETA: separate sprint.
|
|
||||||
|
|
||||||
### #7. WebAuthn-gated session rehydrate [ROADMAP]
|
|
||||||
|
|
||||||
**Why it matters:** the persistent-session blob's non-extractable AES key
|
|
||||||
blocks `exportKey` but NOT `decrypt`-then-read-plaintext. Any malicious
|
|
||||||
extension with `<all_urls>`, any XSS, any compromised npm dep can call
|
|
||||||
`restoreSession()` and lift the seed. My comment in
|
|
||||||
`persistent-session.ts:18-23` overstates the actual protection.
|
|
||||||
|
|
||||||
**Fix:** gate `restoreSession()` on a user-gesture WebAuthn assertion
|
|
||||||
(touchID / passkey). Background scripts can't fake a user gesture, so the
|
|
||||||
seed never gets decrypted unattended. Falls back to passphrase on devices
|
|
||||||
without WebAuthn.
|
|
||||||
|
|
||||||
### #8. Rotate addr daily (`info = "v1|YYYYMMDD"`) [ROADMAP]
|
|
||||||
|
|
||||||
**Why it matters:** a relay scrapes `#h` filter values + the public KEZ
|
|
||||||
directory + builds a rainbow table mapping `addr → primary → handle`. The
|
|
||||||
hash buys little when the input space is enumerable. Per-day addr
|
|
||||||
rotation forces the rainbow table to be rebuilt daily and stops long-term
|
|
||||||
correlation.
|
|
||||||
|
|
||||||
**Trade-off:** receivers need to subscribe to multiple addrs during the
|
|
||||||
boundary day (yesterday's + today's). Listener server-side needs the same.
|
|
||||||
Migration logic isn't hard but isn't free.
|
|
||||||
|
|
||||||
### #9. Unforgeable delivery acks [DONE — Day 3 Option A]
|
|
||||||
|
|
||||||
**Why it matters:** anyone who saw an event id can publish a fake kind-4244
|
|
||||||
ack. Sender's UI shows false "delivered". Cosmetic-only today; will be a
|
|
||||||
real problem when someone builds a tracker bot.
|
|
||||||
|
|
||||||
**Fix:** ack payload = recipient's ed25519 signature over the acked event
|
|
||||||
id. Sender verifies against the recipient's known KEZ primary. Free —
|
|
||||||
already have ed25519 plumbing.
|
|
||||||
|
|
||||||
### #10. NIP-65 outbox model [PARTIAL — Day 3 Option B]
|
|
||||||
|
|
||||||
Publish-side only. We now emit a `kind:10002` event on first session
|
|
||||||
alongside the kind:0 baseline, listing our 3 default relays as
|
|
||||||
read+write. NIP-65-aware clients can discover where to reach us.
|
|
||||||
|
|
||||||
What's still missing: when SENDING to a peer, we should fetch their
|
|
||||||
`kind:10002` and union their read-relays with ours. v0.2 — needs a
|
|
||||||
deeper transport refactor (per-message relay set).
|
|
||||||
|
|
||||||
We hardcode 3 relays for every user. Real nostr clients publish
|
|
||||||
`kind:10002` listing their preferred read+write relays; senders publish to
|
|
||||||
each recipient's published read-relays. Without this, isolated networks
|
|
||||||
of users on different relay sets can't reach each other.
|
|
||||||
|
|
||||||
### #11. NIP-42 AUTH support [DONE — Day 3 Option B]
|
|
||||||
|
|
||||||
damus.io regularly requires NIP-42 AUTH for DM-kind reads. Without it our
|
|
||||||
subscriptions get rejected silently. Add the client AUTH handshake +
|
|
||||||
support being prompted by the relay.
|
|
||||||
|
|
||||||
### #12. Publish a minimal kind-0 profile on first use [DONE — Day 3 Option B]
|
|
||||||
|
|
||||||
Some relays silently drop writes from "unknown" pubkeys (no kind-0). A
|
|
||||||
single minimal `kind:0` per derived nostr pubkey (just `{"name":"kez-chat
|
|
||||||
user"}`) unblocks this without revealing anything.
|
|
||||||
|
|
||||||
### #13. NIP-25 ack shape with `["p", senderNostrPubkey]` [DONE — Day 3 Option B]
|
|
||||||
|
|
||||||
Our kind-4244 ack is custom. Adopting the NIP-25 shape gets free interop
|
|
||||||
with nostr clients that already render reactions — handy if we ever expose
|
|
||||||
the underlying events.
|
|
||||||
|
|
||||||
### #14. Shorten `since=` default cursor [DONE — Day 3 Option A]
|
|
||||||
|
|
||||||
Default 7-day cursor exceeds most relay retention windows (often 1–3
|
|
||||||
days). Fresh devices on quiet conversations silently miss messages.
|
|
||||||
Shorten to 48h + augment with explicit "fetch full history" UI for the
|
|
||||||
rare resurrect case.
|
|
||||||
|
|
||||||
### #16. Bounded concurrency on push fanout [DONE — Day 3 Option A]
|
|
||||||
|
|
||||||
**Why it matters:** every send spawns an unbounded `tokio::spawn` to fan
|
|
||||||
out push. Under flood, OOM.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `kez-chat/src/messages.rs:128`
|
|
||||||
|
|
||||||
**Fix:** semaphore-bound to ~32 concurrent fanouts. Excess queues; under
|
|
||||||
extreme flood we drop with a warn-log rather than swap-thrash.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Visually-encrypted profile pictures (new feature, in progress)
|
|
||||||
|
|
||||||
### Phase 1A — local scramble + per-contact key wrap [DONE this commit]
|
|
||||||
|
|
||||||
- `kez-chat/web/src/lib/visual-crypto.ts` — keyed Fisher-Yates pixel
|
|
||||||
permutation + xoshiro256** PRNG. Output is a valid PNG with same
|
|
||||||
dimensions, scrambled content. Salt embedded as `#kez-visual-v1:<hex>`
|
|
||||||
URL fragment so descramble doesn't need out-of-band metadata.
|
|
||||||
- `profile-store.ts` — profile gains `encrypted: boolean` (default
|
|
||||||
true) + `picture_key` (local-only). On publish: scramble the picture,
|
|
||||||
wrap the visual key for each contact via the existing
|
|
||||||
`sealMessage()` envelope, embed as `kez_visual_keys` map in the
|
|
||||||
kind:0 content.
|
|
||||||
- Settings — "Visually encrypt picture (recommended)" toggle, default ON.
|
|
||||||
|
|
||||||
### Phase 1B — peer descramble [DONE this commit]
|
|
||||||
|
|
||||||
- `peer-profile-store.ts` (new): IDB cache + one-shot `pool.querySync`
|
|
||||||
fetch of the peer's kind:0 metadata event. On hit, looks up our
|
|
||||||
primary in `kez_visual_keys`, opens the SealedEnvelope wrap to
|
|
||||||
recover the visual key, descrambles `metadata.picture`, caches the
|
|
||||||
rendered data URL.
|
|
||||||
- `peer-profile-cell.svelte.ts` (new): reactive Svelte 5 mirror over
|
|
||||||
the IDB cache so component re-renders are automatic on fetch.
|
|
||||||
- `nostr-transport.ts`: surfaces `sender_nostr_pubkey` on every
|
|
||||||
inbound DM. `conversations-store.ts` persists it on the conversation
|
|
||||||
row so we can locate the peer's kind:0 later.
|
|
||||||
- `inbox-service.svelte.ts`: on every fresh DM, fires off a profile
|
|
||||||
fetch for the sender — first DM lights up their avatar.
|
|
||||||
- `Messages.svelte`: hydrates the cache on mount, kicks off refreshes
|
|
||||||
for every visible conversation, threads cached pictures through
|
|
||||||
both Avatar usages (conversation list + thread header).
|
|
||||||
- Conversation list re-renders on cache update; staleness window 24h.
|
|
||||||
|
|
||||||
Edges noted for later: peers we've only *sent* to (never received from)
|
|
||||||
have no `peer_nostr_pubkey` until they reply, so they don't get a
|
|
||||||
picture lookup yet. Easy follow-up: backfill pubkey from a NIP-05 or
|
|
||||||
WebFinger lookup, or proactively probe relays for `kind:0` events whose
|
|
||||||
content tags match a known primary.
|
|
||||||
|
|
||||||
### Phase 1C — UX polish [TODO]
|
|
||||||
|
|
||||||
- "X contacts can see your real picture" hint in Settings.
|
|
||||||
- Re-publish kind:0 automatically when a new conversation is created
|
|
||||||
(so the new contact gets key-wrapped without the user re-saving).
|
|
||||||
- Optional: per-image AES-CTR mode for uniform-noise output (stronger,
|
|
||||||
less "visually meaningful").
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acknowledged trade-offs (won't fix in v0.1)
|
|
||||||
|
|
||||||
### Persistent-session is no stronger than the biometric path
|
|
||||||
|
|
||||||
The `non-extractable AES key` story stops `exportKey`, NOT
|
|
||||||
`decrypt`+`read`. Anyone with origin-execution access (XSS, malicious
|
|
||||||
extension) can lift the seed. Document this honestly in the README and the
|
|
||||||
file header. Real fix is #7 above.
|
|
||||||
|
|
||||||
### 30-day TTL is client-only
|
|
||||||
|
|
||||||
`expiresAt` in localStorage is editable by anyone with file-system access.
|
|
||||||
Server-side device binding (issue a signed nonce on unlock, expire at the
|
|
||||||
server) would help but adds round-trips. v0.2 candidate.
|
|
||||||
|
|
||||||
### Identity-key reuse is safe under current crypto
|
|
||||||
|
|
||||||
ed25519 seed → ed25519 (sigchain, envelope sig) + x25519 (ECDH) + HKDF →
|
|
||||||
secp256k1 (nostr signer). The auditor confirmed: no cross-curve
|
|
||||||
chosen-message attack path. Standard libsodium pattern.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tracking + cross-references
|
|
||||||
|
|
||||||
- Nostr-protocol review: see commit message of this commit; full report in
|
|
||||||
the audit-trail.
|
|
||||||
- Security audit: ditto.
|
|
||||||
- Owner: tudisco
|
|
||||||
- Last updated: 2026-06-08
|
|
||||||
@ -50,21 +50,3 @@ ENV KEZ_CHAT_BIND=0.0.0.0:6969 \
|
|||||||
|
|
||||||
EXPOSE 6969
|
EXPOSE 6969
|
||||||
ENTRYPOINT ["/usr/local/bin/kez-chat-server"]
|
ENTRYPOINT ["/usr/local/bin/kez-chat-server"]
|
||||||
|
|
||||||
# ─── Stage 4 (optional): artifact-only export ──────────────────────────────
|
|
||||||
# Used by deploy-fast: build this locally on a fast machine for
|
|
||||||
# --platform=linux/amd64, then `--output type=local,dest=./tmp`
|
|
||||||
# writes ONLY the two artifacts we ship to the remote — no debian
|
|
||||||
# rootfs, no rust toolchain, no node_modules.
|
|
||||||
#
|
|
||||||
# docker buildx build \
|
|
||||||
# --platform=linux/amd64 \
|
|
||||||
# --file kez-chat/deploy/Dockerfile \
|
|
||||||
# --target=export \
|
|
||||||
# --output type=local,dest=./out \
|
|
||||||
# .
|
|
||||||
#
|
|
||||||
# Produces: ./out/kez-chat-server, ./out/web/...
|
|
||||||
FROM scratch AS export
|
|
||||||
COPY --from=build /src/kez-chat/target/release/kez-chat-server /kez-chat-server
|
|
||||||
COPY --from=webbuild /src/web/dist/ /web/
|
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
# Slim runtime image for kez-chat-server, used by the "deploy fast" path.
|
|
||||||
#
|
|
||||||
# This Dockerfile expects two prebuilt artifacts in the build context:
|
|
||||||
#
|
|
||||||
# ./prebuilt/kez-chat-server — the Rust binary, linux/amd64
|
|
||||||
# ./prebuilt/web/ — the Svelte SPA dist directory
|
|
||||||
#
|
|
||||||
# Both are produced LOCALLY by `deploy-fast.local.sh` via
|
|
||||||
# `docker buildx build --target=export` on the developer's fast
|
|
||||||
# machine, then rsynced to the remote. The remote build does no Rust
|
|
||||||
# or npm work — it just stitches together a tiny runtime image, which
|
|
||||||
# takes <5 seconds instead of the 8–12 minutes a full Rust rebuild
|
|
||||||
# takes on the (slower) production box.
|
|
||||||
#
|
|
||||||
# Build context = kez-chat/deploy/ (the directory holding this file
|
|
||||||
# and the prebuilt/ subdirectory). Pinned by docker-compose.fast.yml.
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& useradd -r -u 10001 -m kez
|
|
||||||
|
|
||||||
# Rust binary — must already be built for linux/amd64.
|
|
||||||
COPY prebuilt/kez-chat-server /usr/local/bin/kez-chat-server
|
|
||||||
RUN chmod +x /usr/local/bin/kez-chat-server
|
|
||||||
|
|
||||||
# SPA static files.
|
|
||||||
COPY prebuilt/web/ /app/web/
|
|
||||||
|
|
||||||
USER kez
|
|
||||||
WORKDIR /data
|
|
||||||
|
|
||||||
ENV KEZ_CHAT_BIND=0.0.0.0:6969 \
|
|
||||||
KEZ_CHAT_DB=/data/kez-chat.db \
|
|
||||||
KEZ_CHAT_SERVER=kez.lat \
|
|
||||||
KEZ_CHAT_SIG_SERVER_URL=http://sig-server:7878 \
|
|
||||||
KEZ_CHAT_WEB_DIR=/app/web \
|
|
||||||
RUST_LOG=info
|
|
||||||
|
|
||||||
EXPOSE 6969
|
|
||||||
ENTRYPOINT ["/usr/local/bin/kez-chat-server"]
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# Compose override for the "deploy fast" path. Layer on top of the
|
|
||||||
# base compose to swap chat-server from a-full-Rust-build-on-remote to
|
|
||||||
# a-tiny-runtime-image-using-the-prebuilt-binary.
|
|
||||||
#
|
|
||||||
# Usage on the remote (handled by deploy-fast.local.sh):
|
|
||||||
#
|
|
||||||
# docker compose \
|
|
||||||
# -f docker-compose.yml \
|
|
||||||
# -f docker-compose.fast.yml \
|
|
||||||
# up -d --build chat-server
|
|
||||||
#
|
|
||||||
# The build context shrinks from the entire repo root to just this
|
|
||||||
# deploy/ directory — meaning rsync only has to ship the prebuilt
|
|
||||||
# binary + SPA, not the rust/, kez-chat/, and rust-sig-server/ trees.
|
|
||||||
#
|
|
||||||
# sig-server is left alone (it still does its own build); only
|
|
||||||
# chat-server needs the fast path right now.
|
|
||||||
|
|
||||||
services:
|
|
||||||
chat-server:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.runtime
|
|
||||||
@ -30,8 +30,6 @@ pub struct AppState {
|
|||||||
pub broker: crate::broker::Broker,
|
pub broker: crate::broker::Broker,
|
||||||
pub vapid: crate::push::VapidKeys,
|
pub vapid: crate::push::VapidKeys,
|
||||||
pub push: crate::push::PushSender,
|
pub push: crate::push::PushSender,
|
||||||
/// Per-IP rate limiter for `POST /v1/messages`. See TODO.md Day 2 #15.
|
|
||||||
pub send_rate_limit: crate::rate_limit::RateLimiter,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: AppState) -> axum::Router {
|
pub fn router(state: AppState) -> axum::Router {
|
||||||
@ -52,31 +50,12 @@ pub fn router(state: AppState) -> axum::Router {
|
|||||||
.route("/v1/push/vapid-public-key", get(push_vapid_key))
|
.route("/v1/push/vapid-public-key", get(push_vapid_key))
|
||||||
.route("/v1/push/subscribe/:handle", post(push_subscribe))
|
.route("/v1/push/subscribe/:handle", post(push_subscribe))
|
||||||
.route("/v1/push/unsubscribe/:handle", post(push_unsubscribe))
|
.route("/v1/push/unsubscribe/:handle", post(push_unsubscribe))
|
||||||
.route("/v1/push/subscriptions/:handle", get(push_list_subscriptions))
|
|
||||||
.route("/.well-known/webfinger", get(webfinger))
|
.route("/.well-known/webfinger", get(webfinger))
|
||||||
.route("/internal/nats/auth", post(nats_auth_callout));
|
.route("/internal/nats/auth", post(nats_auth_callout));
|
||||||
|
|
||||||
router = if let Some(dir) = web_dir {
|
router = if let Some(dir) = web_dir {
|
||||||
// Explicit no-cache for the files that gate everything else:
|
// Real SPA build dir provided; ServeDir handles index.html + assets.
|
||||||
// • /sw.js — controls all client caching; stale = stuck on old build
|
router.fallback_service(ServeDir::new(dir))
|
||||||
// • /index.html — the SPA shell that loads hashed asset URLs
|
|
||||||
// • /manifest.webmanifest — drives PWA install behaviour
|
|
||||||
//
|
|
||||||
// Without these overrides Cloudflare cached our service worker
|
|
||||||
// for 4 hours and users got "ghost" deploys where the binary
|
|
||||||
// updated but the SW + bundle didn't, breaking notifications
|
|
||||||
// and session restore.
|
|
||||||
let dir_for_sw = dir.clone();
|
|
||||||
let dir_for_html = dir.clone();
|
|
||||||
let dir_for_manifest = dir.clone();
|
|
||||||
router
|
|
||||||
.route("/sw.js", get(move || serve_nocache(dir_for_sw.clone(), "sw.js")))
|
|
||||||
.route("/index.html", get(move || serve_nocache(dir_for_html.clone(), "index.html")))
|
|
||||||
.route(
|
|
||||||
"/manifest.webmanifest",
|
|
||||||
get(move || serve_nocache(dir_for_manifest.clone(), "manifest.webmanifest")),
|
|
||||||
)
|
|
||||||
.fallback_service(ServeDir::new(dir))
|
|
||||||
} else {
|
} else {
|
||||||
// No SPA dir; serve a built-in placeholder page at `/`.
|
// No SPA dir; serve a built-in placeholder page at `/`.
|
||||||
router.route("/", get(placeholder_index))
|
router.route("/", get(placeholder_index))
|
||||||
@ -353,7 +332,6 @@ async fn push_subscribe(
|
|||||||
Utc::now().timestamp(),
|
Utc::now().timestamp(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let endpoint_for_log = req.endpoint.clone();
|
|
||||||
state
|
state
|
||||||
.store
|
.store
|
||||||
.upsert_push_subscription(
|
.upsert_push_subscription(
|
||||||
@ -365,15 +343,6 @@ async fn push_subscribe(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
// DEBUG, not INFO — the handle is sensitive enough that we
|
|
||||||
// don't want it in long-lived production logs. See TODO.md
|
|
||||||
// Day 1 #17. Diagnostic info (the push provider host) is fine.
|
|
||||||
tracing::debug!(
|
|
||||||
endpoint_host = %endpoint_for_log
|
|
||||||
.split_once("://").map(|(_, r)| r).unwrap_or(&endpoint_for_log)
|
|
||||||
.split(&['/', ':'][..]).next().unwrap_or("?"),
|
|
||||||
"push: subscription registered"
|
|
||||||
);
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -414,77 +383,6 @@ async fn push_unsubscribe(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// GET /v1/push/subscriptions/:handle — self-heal check
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// On every login (or session restore) the client calls this to verify
|
|
||||||
// the server still has the device's push subscription. Reasons it
|
|
||||||
// might be missing on the server:
|
|
||||||
// • Server dropped it after a 410 Gone from the push provider
|
|
||||||
// • Server lost its DB / was rebuilt
|
|
||||||
// • User browsed-data-cleared the device → has no local sub
|
|
||||||
// • Fresh device that never subscribed
|
|
||||||
//
|
|
||||||
// We return the LAST 16 chars of each endpoint URL — that's already
|
|
||||||
// unique enough across FCM/Mozilla/APNs and avoids leaking the full
|
|
||||||
// endpoint to any other party reading the response. The client
|
|
||||||
// matches its own browser PushSubscription.endpoint by suffix; if it
|
|
||||||
// has a local sub whose suffix isn't in the response, it re-registers.
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct PushSubscriptionsResponse {
|
|
||||||
/// Last-16-char tails of each registered endpoint URL — enough
|
|
||||||
/// for the client to identify whether *its* subscription is in
|
|
||||||
/// the set without us echoing the whole 200-char endpoint back.
|
|
||||||
endpoint_tails: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn push_list_subscriptions(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(handle): Path<String>,
|
|
||||||
headers: axum::http::HeaderMap,
|
|
||||||
) -> Result<Json<PushSubscriptionsResponse>, ApiError> {
|
|
||||||
validate_handle(&handle)
|
|
||||||
.map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?;
|
|
||||||
let record = state
|
|
||||||
.store
|
|
||||||
.lookup(&handle)
|
|
||||||
.await?
|
|
||||||
.ok_or(ApiError::NotFound)?;
|
|
||||||
|
|
||||||
let auth = headers
|
|
||||||
.get("X-KEZ-Auth")
|
|
||||||
.ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))?
|
|
||||||
.to_str()
|
|
||||||
.map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?;
|
|
||||||
// Reuse the subscribe/unsubscribe canonical-msg shape with a
|
|
||||||
// dedicated verb so a captured "list" header can't be replayed
|
|
||||||
// as a subscribe (or vice versa).
|
|
||||||
let (ts_str, sig_hex) = auth
|
|
||||||
.split_once(':')
|
|
||||||
.ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be <ts>:<sig>".into()))?;
|
|
||||||
let ts: i64 = ts_str
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?;
|
|
||||||
if (Utc::now().timestamp() - ts).abs() > 60 {
|
|
||||||
return Err(ApiError::Unauthorized("auth header is stale".into()));
|
|
||||||
}
|
|
||||||
let message = format!("GET\n/v1/push/subscriptions/{handle}\n{ts}");
|
|
||||||
kez_core::verify_ed25519_hex(record.primary.value(), message.as_bytes(), sig_hex)
|
|
||||||
.map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?;
|
|
||||||
|
|
||||||
let subs = state.store.list_push_subscriptions(&handle).await?;
|
|
||||||
let endpoint_tails: Vec<String> = subs
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
let n = s.endpoint.len();
|
|
||||||
s.endpoint[n.saturating_sub(16)..].to_string()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(Json(PushSubscriptionsResponse { endpoint_tails }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// GET /.well-known/webfinger — fediverse-style discovery
|
// GET /.well-known/webfinger — fediverse-style discovery
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -563,44 +461,6 @@ async fn nats_auth_callout(
|
|||||||
// Placeholder SPA — until we ship the real Svelte build
|
// Placeholder SPA — until we ship the real Svelte build
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Serve a single file from `dir/filename` with explicit no-cache
|
|
||||||
/// headers so Cloudflare / browsers always revalidate. Used for the
|
|
||||||
/// SPA shell, manifest, and (most critically) the service worker.
|
|
||||||
///
|
|
||||||
/// Sends BOTH RFC 7234 `Cache-Control` AND legacy `Pragma`/`Expires`
|
|
||||||
/// — Cloudflare's edge respects the modern header, but the trio
|
|
||||||
/// together gets every intermediate proxy to do the right thing.
|
|
||||||
async fn serve_nocache(dir: std::path::PathBuf, filename: &'static str) -> axum::response::Response {
|
|
||||||
let path = dir.join(filename);
|
|
||||||
let bytes = match tokio::fs::read(&path).await {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(?path, error = %e, "serve_nocache: read failed");
|
|
||||||
return (StatusCode::NOT_FOUND, "not found").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mime = match filename.rsplit_once('.').map(|(_, ext)| ext) {
|
|
||||||
Some("js") => "application/javascript; charset=utf-8",
|
|
||||||
Some("html") => "text/html; charset=utf-8",
|
|
||||||
Some("webmanifest") => "application/manifest+json; charset=utf-8",
|
|
||||||
_ => "application/octet-stream",
|
|
||||||
};
|
|
||||||
(
|
|
||||||
StatusCode::OK,
|
|
||||||
[
|
|
||||||
(header::CONTENT_TYPE, mime),
|
|
||||||
(
|
|
||||||
header::CACHE_CONTROL,
|
|
||||||
"no-store, no-cache, must-revalidate, max-age=0",
|
|
||||||
),
|
|
||||||
(header::PRAGMA, "no-cache"),
|
|
||||||
(header::EXPIRES, "0"),
|
|
||||||
],
|
|
||||||
bytes,
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn placeholder_index(State(state): State<AppState>) -> Html<String> {
|
async fn placeholder_index(State(state): State<AppState>) -> Html<String> {
|
||||||
Html(format!(
|
Html(format!(
|
||||||
r#"<!DOCTYPE html>
|
r#"<!DOCTYPE html>
|
||||||
|
|||||||
@ -54,17 +54,4 @@ pub struct Config {
|
|||||||
default_value = "mailto:admin@kez.lat"
|
default_value = "mailto:admin@kez.lat"
|
||||||
)]
|
)]
|
||||||
pub vapid_subject: String,
|
pub vapid_subject: String,
|
||||||
|
|
||||||
/// Comma-separated list of nostr relays the server will subscribe
|
|
||||||
/// to so it can fire Web Push notifications for messages sent
|
|
||||||
/// over the nostr transport (which never touch /v1/messages).
|
|
||||||
/// Empty string disables the listener. Must match (or be a
|
|
||||||
/// subset of) the relays the web client publishes to.
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
env = "KEZ_CHAT_NOSTR_RELAYS",
|
|
||||||
default_value = "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine",
|
|
||||||
value_delimiter = ','
|
|
||||||
)]
|
|
||||||
pub nostr_relays: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,8 +19,6 @@ pub enum ApiError {
|
|||||||
Forbidden(String),
|
Forbidden(String),
|
||||||
#[error("unauthorized: {0}")]
|
#[error("unauthorized: {0}")]
|
||||||
Unauthorized(String),
|
Unauthorized(String),
|
||||||
#[error("rate limited: {0}")]
|
|
||||||
RateLimited(String),
|
|
||||||
#[error("internal: {0}")]
|
#[error("internal: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
@ -33,7 +31,6 @@ impl ApiError {
|
|||||||
ApiError::Conflict(_) => StatusCode::CONFLICT,
|
ApiError::Conflict(_) => StatusCode::CONFLICT,
|
||||||
ApiError::Forbidden(_) => StatusCode::FORBIDDEN,
|
ApiError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||||
ApiError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
ApiError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||||
ApiError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS,
|
|
||||||
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -45,7 +42,6 @@ impl ApiError {
|
|||||||
ApiError::Conflict(_) => "conflict",
|
ApiError::Conflict(_) => "conflict",
|
||||||
ApiError::Forbidden(_) => "forbidden",
|
ApiError::Forbidden(_) => "forbidden",
|
||||||
ApiError::Unauthorized(_) => "unauthorized",
|
ApiError::Unauthorized(_) => "unauthorized",
|
||||||
ApiError::RateLimited(_) => "rate_limited",
|
|
||||||
ApiError::Internal(_) => "internal",
|
ApiError::Internal(_) => "internal",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,7 @@ pub mod config;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod handles;
|
pub mod handles;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod nostr_listener;
|
|
||||||
pub mod push;
|
pub mod push;
|
||||||
pub mod rate_limit;
|
|
||||||
pub mod registration;
|
pub mod registration;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
|
|||||||
@ -29,63 +29,12 @@ async fn main() -> Result<()> {
|
|||||||
let vapid =
|
let vapid =
|
||||||
kez_chat_server::push::load_or_generate_vapid(&config.vapid_key_path)?;
|
kez_chat_server::push::load_or_generate_vapid(&config.vapid_key_path)?;
|
||||||
let push = kez_chat_server::push::PushSender::new(&vapid, &config.vapid_subject)?;
|
let push = kez_chat_server::push::PushSender::new(&vapid, &config.vapid_subject)?;
|
||||||
|
|
||||||
// Spin up the nostr listener so Web Push works when chat goes over
|
|
||||||
// nostr (the live web client default). Fire-and-forget: the
|
|
||||||
// listener owns its own reconnect logic and never returns.
|
|
||||||
let nostr_relays: Vec<String> = config
|
|
||||||
.nostr_relays
|
|
||||||
.iter()
|
|
||||||
.map(|s| s.trim().to_owned())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
// Web Push is a NICE-TO-HAVE — chat itself flows end-to-end over
|
|
||||||
// nostr and doesn't depend on this server at all. If the listener
|
|
||||||
// panics, log it and let the rest of the server keep serving the
|
|
||||||
// registry + handle lookups + the SPA. The user simply loses push
|
|
||||||
// notifications until the next restart.
|
|
||||||
if !nostr_relays.is_empty() {
|
|
||||||
let store_ = store.clone();
|
|
||||||
let push_ = push.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let result = std::panic::AssertUnwindSafe(
|
|
||||||
kez_chat_server::nostr_listener::run(store_, push_, nostr_relays),
|
|
||||||
);
|
|
||||||
use futures::FutureExt;
|
|
||||||
if let Err(panic) = result.catch_unwind().await {
|
|
||||||
tracing::error!(
|
|
||||||
?panic,
|
|
||||||
"nostr_listener panicked — Web Push disabled until next restart"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let send_rate_limit = kez_chat_server::rate_limit::RateLimiter::new(
|
|
||||||
kez_chat_server::rate_limit::RateLimitConfig::default(),
|
|
||||||
);
|
|
||||||
// Background sweep: drop idle buckets every 5 minutes so the
|
|
||||||
// HashMap doesn't grow forever on a long-lived process.
|
|
||||||
{
|
|
||||||
let rl = send_rate_limit.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
|
|
||||||
let dropped = rl.sweep().await;
|
|
||||||
if dropped > 0 {
|
|
||||||
tracing::debug!(dropped, "rate_limit: pruned idle buckets");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
store,
|
store,
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
broker: kez_chat_server::broker::Broker::new(),
|
broker: kez_chat_server::broker::Broker::new(),
|
||||||
vapid,
|
vapid,
|
||||||
push,
|
push,
|
||||||
send_rate_limit,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = router(state)
|
let app = router(state)
|
||||||
|
|||||||
@ -69,19 +69,8 @@ pub struct SendMessageResponse {
|
|||||||
|
|
||||||
pub async fn send_message(
|
pub async fn send_message(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
|
||||||
Json(req): Json<SendMessageRequest>,
|
Json(req): Json<SendMessageRequest>,
|
||||||
) -> Result<Json<SendMessageResponse>, ApiError> {
|
) -> Result<Json<SendMessageResponse>, ApiError> {
|
||||||
// Per-IP rate limit (TODO.md Day 2 #15). Without this, anyone can
|
|
||||||
// fill any mailbox up to disk-full with 256KB envelopes.
|
|
||||||
let ip = crate::rate_limit::client_ip_from_headers(&headers);
|
|
||||||
if !state.send_rate_limit.try_acquire(ip).await {
|
|
||||||
tracing::debug!(%ip, "send_message: rate-limited");
|
|
||||||
return Err(ApiError::RateLimited(
|
|
||||||
"too many messages — try again in a moment".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_handle(&req.to)
|
validate_handle(&req.to)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("invalid 'to' handle: {e}")))?;
|
.map_err(|e| ApiError::BadRequest(format!("invalid 'to' handle: {e}")))?;
|
||||||
|
|
||||||
@ -125,19 +114,17 @@ pub async fn send_message(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Web Push fanout — fire-and-forget so the HTTP request still
|
// Web Push fanout — fire-and-forget so the HTTP request still
|
||||||
// returns fast. The push payload is INTENTIONALLY EMPTY: RFC 8291
|
// returns fast. The push payload is intentionally tiny and
|
||||||
// encrypts the payload to the device's p256dh+auth so providers
|
// contains only metadata: the recipient's own client will pull
|
||||||
// can't read plaintext, but the push provider (FCM/APNs/Mozilla)
|
// the real (encrypted) envelope from the inbox/SSE on wake-up.
|
||||||
// still sees the request timing + endpoint. Putting recipient
|
|
||||||
// metadata in the payload was redundant (provider already knows
|
|
||||||
// the endpoint owner) and exported "alice got a message at T" to
|
|
||||||
// Google. The service worker shows a generic notification; the
|
|
||||||
// recipient's client opens the conversation list (one extra tap
|
|
||||||
// to the right thread is fine). See TODO.md Day 1 #3.
|
|
||||||
let push = state.push.clone();
|
let push = state.push.clone();
|
||||||
let store = state.store.clone();
|
let store = state.store.clone();
|
||||||
let recipient_handle = recipient.handle.clone();
|
let recipient_handle = recipient.handle.clone();
|
||||||
let payload = serde_json::json!({});
|
let payload = serde_json::json!({
|
||||||
|
"type": "kez-chat/new-message",
|
||||||
|
"to": recipient_handle,
|
||||||
|
"seq": seq,
|
||||||
|
});
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
push.fanout(&store, &recipient_handle, &payload).await;
|
push.fanout(&store, &recipient_handle, &payload).await;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,300 +0,0 @@
|
|||||||
//! Server-side nostr listener for Web Push fanout.
|
|
||||||
//!
|
|
||||||
//! Why this exists: when chat traffic flows over nostr (`VITE_TRANSPORT=
|
|
||||||
//! nostr` on the web client), the messages never touch our chat-server's
|
|
||||||
//! /v1/messages endpoint — they go sender's browser → relay → recipient's
|
|
||||||
//! browser, end-to-end. That means the `push.fanout()` hook in
|
|
||||||
//! `messages.rs::send_message` never fires, and a recipient with their
|
|
||||||
//! phone screen off gets no notification.
|
|
||||||
//!
|
|
||||||
//! This module closes that gap: the chat-server itself runs a nostr
|
|
||||||
//! subscription against the configured relays, filtered to events
|
|
||||||
//! tagged for any handle registered with it. When a matching event
|
|
||||||
//! lands, we look up the handle and call `push.fanout(...)`. The push
|
|
||||||
//! payload is still metadata-only (`{type, to, id}`) — the actual
|
|
||||||
//! ciphertext stays on the relay and is fetched + decrypted by the
|
|
||||||
//! recipient's browser when the user opens kez-chat.
|
|
||||||
//!
|
|
||||||
//! Trust:
|
|
||||||
//! • The chat-server learns *who* received an event and *when*, same
|
|
||||||
//! as the server-transport path. It cannot read the message
|
|
||||||
//! (ciphertext is opaque to it).
|
|
||||||
//! • We don't sign or publish anything — read-only subscription.
|
|
||||||
//! • The addr filter is the same opaque routing label the web
|
|
||||||
//! client computes (`addr_from_primary`), so the relay sees no
|
|
||||||
//! more info about our user base than it would otherwise.
|
|
||||||
|
|
||||||
use std::collections::{HashSet, VecDeque};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
// Rename to side-step nostr_sdk::prelude::hkdf which shadows the crate.
|
|
||||||
use ::hkdf::Hkdf as Hkdf256;
|
|
||||||
use kez_core::Identity;
|
|
||||||
use nostr_sdk::prelude::*;
|
|
||||||
use sha2::Sha256;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
use crate::error::ApiError;
|
|
||||||
use crate::push::PushSender;
|
|
||||||
use crate::store::Store;
|
|
||||||
|
|
||||||
/// Custom event kind used by the web client (mirror of web/src/lib/nostr-id.ts).
|
|
||||||
const KEZ_DM_KIND: u16 = 4242;
|
|
||||||
|
|
||||||
/// HKDF inputs — MUST match web/src/lib/nostr-id.ts byte-for-byte so the
|
|
||||||
/// addrs we filter for line up with the addrs the web client tags onto
|
|
||||||
/// outgoing events.
|
|
||||||
const ADDR_SALT: &[u8] = b"kez-chat:nostr-addr";
|
|
||||||
const ADDR_INFO: &[u8] = b"v1";
|
|
||||||
|
|
||||||
/// How often to re-query the handles table and (re-)build the
|
|
||||||
/// subscription. A new handle that registers between refreshes won't
|
|
||||||
/// get push notifications until the next tick — acceptable for v0.1.
|
|
||||||
const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
|
|
||||||
|
|
||||||
/// Dedup window for event ids — relays often replay the same event
|
|
||||||
/// across multiple connections; we only want to push once per event.
|
|
||||||
const DEDUP_CAP: usize = 10_000;
|
|
||||||
|
|
||||||
// ─── addr derivation ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// 32-byte hex addr from a recipient's primary. Identical to
|
|
||||||
/// `addrFromPrimary` in web/src/lib/nostr-id.ts.
|
|
||||||
pub fn addr_from_primary(primary: &Identity) -> String {
|
|
||||||
let hk = Hkdf256::<Sha256>::new(Some(ADDR_SALT), primary.as_str().as_bytes());
|
|
||||||
let mut out = [0u8; 32];
|
|
||||||
hk.expand(ADDR_INFO, &mut out)
|
|
||||||
.expect("32-byte HKDF expand is well within SHA-256's output budget");
|
|
||||||
hex::encode(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── store helper ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
impl Store {
|
|
||||||
/// Snapshot of (handle, primary) for every registered handle, used
|
|
||||||
/// by the nostr listener to compute its addr filter on each refresh.
|
|
||||||
pub async fn list_handles(&self) -> Result<Vec<(String, Identity)>, ApiError> {
|
|
||||||
let conn = Store::inner_lock(self).await;
|
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT handle, primary_id FROM handles ORDER BY handle",
|
|
||||||
)?;
|
|
||||||
let raw: Vec<(String, String)> = stmt
|
|
||||||
.query_map([], |row| {
|
|
||||||
let handle: String = row.get(0)?;
|
|
||||||
let primary_str: String = row.get(1)?;
|
|
||||||
Ok((handle, primary_str))
|
|
||||||
})?
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
let mut out = Vec::with_capacity(raw.len());
|
|
||||||
for (handle, primary_str) in raw {
|
|
||||||
let primary = Identity::parse(&primary_str)
|
|
||||||
.map_err(|e| ApiError::Internal(format!("bad primary in db: {e}")))?;
|
|
||||||
out.push((handle, primary));
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── listener ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Run the listener forever. Spawn as a background task from main.rs.
|
|
||||||
pub async fn run(store: Store, push: PushSender, relays: Vec<String>) {
|
|
||||||
if relays.is_empty() {
|
|
||||||
tracing::warn!("nostr_listener: no relays configured — listener disabled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read-only client — we never sign / publish. nostr-sdk's pool
|
|
||||||
// handles reconnect and per-relay backoff for us.
|
|
||||||
let client = Client::default();
|
|
||||||
for url in &relays {
|
|
||||||
if let Err(e) = client.add_relay(url).await {
|
|
||||||
tracing::warn!(relay = %url, error = %e, "nostr_listener: add_relay failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
client.connect().await;
|
|
||||||
tracing::info!(relays = ?relays, "nostr_listener: connected");
|
|
||||||
|
|
||||||
let seen = Arc::new(Mutex::new(EvictingSet::new(DEDUP_CAP)));
|
|
||||||
let mut current_addrs: HashSet<String> = HashSet::new();
|
|
||||||
let mut subscription: Option<SubscriptionId> = None;
|
|
||||||
let mut handles_snapshot: Vec<(String, Identity)> = Vec::new();
|
|
||||||
// Map addr → handle for O(1) lookup on each event.
|
|
||||||
let mut addr_to_handle: std::collections::HashMap<String, String> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
|
|
||||||
let mut notif_rx = client.notifications();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// ── Refresh handle list (cheap if nothing changed) ──────────────
|
|
||||||
match store.list_handles().await {
|
|
||||||
Ok(handles) => {
|
|
||||||
let new_addrs: HashSet<String> =
|
|
||||||
handles.iter().map(|(_, p)| addr_from_primary(p)).collect();
|
|
||||||
if new_addrs != current_addrs {
|
|
||||||
tracing::info!(
|
|
||||||
count = new_addrs.len(),
|
|
||||||
"nostr_listener: (re)subscribing for {} handle addrs",
|
|
||||||
new_addrs.len()
|
|
||||||
);
|
|
||||||
if let Some(id) = subscription.take() {
|
|
||||||
client.unsubscribe(id).await;
|
|
||||||
}
|
|
||||||
let filter = Filter::new()
|
|
||||||
.kind(Kind::Custom(KEZ_DM_KIND))
|
|
||||||
.custom_tag(
|
|
||||||
// `q` (was `h` — NIP-29 routing collision; see
|
|
||||||
// kez-chat/TODO.md Day 1 #5).
|
|
||||||
SingleLetterTag::lowercase(Alphabet::Q),
|
|
||||||
new_addrs.iter().cloned(),
|
|
||||||
);
|
|
||||||
match client.subscribe(vec![filter], None).await {
|
|
||||||
Ok(out) => subscription = Some(out.val),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "nostr_listener: subscribe failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current_addrs = new_addrs;
|
|
||||||
addr_to_handle = handles
|
|
||||||
.iter()
|
|
||||||
.map(|(h, p)| (addr_from_primary(p), h.clone()))
|
|
||||||
.collect();
|
|
||||||
handles_snapshot = handles;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "nostr_listener: list_handles failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = &handles_snapshot; // tracked for future debug; keep populated.
|
|
||||||
|
|
||||||
// ── Pump notifications until next refresh ──────────────────────
|
|
||||||
let deadline = tokio::time::Instant::now() + REFRESH_INTERVAL;
|
|
||||||
loop {
|
|
||||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
||||||
if remaining.is_zero() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let next = tokio::time::timeout(remaining, notif_rx.recv()).await;
|
|
||||||
let Ok(Ok(notif)) = next else {
|
|
||||||
// Timeout (next refresh) or channel closed; loop back to
|
|
||||||
// refresh + resubscribe.
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let RelayPoolNotification::Event { event, .. } = notif else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let event_id = event.id.to_hex();
|
|
||||||
// Dedup.
|
|
||||||
{
|
|
||||||
let mut guard = seen.lock().await;
|
|
||||||
if !guard.insert(event_id.clone()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Find the `h` tag value.
|
|
||||||
let addr = event.tags.iter().find_map(|t| {
|
|
||||||
let v = t.as_slice();
|
|
||||||
// Tag name was migrated h → q; see TODO.md Day 1 #5.
|
|
||||||
if v.len() >= 2 && v[0] == "q" {
|
|
||||||
Some(v[1].clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let Some(addr) = addr else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// Map addr → handle. Should always hit, since the filter is
|
|
||||||
// exactly this set; the only miss case is a relay sending
|
|
||||||
// events for an unsubscribed addr (some don't respect
|
|
||||||
// filters perfectly — drop silently).
|
|
||||||
let Some(handle) = addr_to_handle.get(&addr).cloned() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// Empty payload — see TODO.md Day 1 #3. The push
|
|
||||||
// provider already knows the endpoint owner; carrying
|
|
||||||
// {to, id} just exported metadata to Google + put it
|
|
||||||
// in the encrypted payload that any compromised SW
|
|
||||||
// could log.
|
|
||||||
let payload = serde_json::json!({});
|
|
||||||
push.fanout(&store, &handle, &payload).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── dedup set ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Bounded set with FIFO eviction. Cheap for our scale (≤10k entries).
|
|
||||||
struct EvictingSet {
|
|
||||||
cap: usize,
|
|
||||||
order: VecDeque<String>,
|
|
||||||
members: HashSet<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EvictingSet {
|
|
||||||
fn new(cap: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
cap,
|
|
||||||
order: VecDeque::with_capacity(cap),
|
|
||||||
members: HashSet::with_capacity(cap),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// Returns true if the id was new (caller should act). Returns
|
|
||||||
/// false if already seen (caller should drop).
|
|
||||||
fn insert(&mut self, id: String) -> bool {
|
|
||||||
if self.members.contains(&id) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if self.order.len() >= self.cap {
|
|
||||||
if let Some(old) = self.order.pop_front() {
|
|
||||||
self.members.remove(&old);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.order.push_back(id.clone());
|
|
||||||
self.members.insert(id);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── tests ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Cross-impl ground truth: the same primary must hash to the same
|
|
||||||
/// addr in both the Rust listener and the TS web client. If the
|
|
||||||
/// web client changes its salt/info, this test will fail loud here
|
|
||||||
/// AND chat would silently stop notifying. Vector below comes from
|
|
||||||
/// running addrFromPrimary("ed25519:0000…0000") in the web client.
|
|
||||||
#[test]
|
|
||||||
fn addr_matches_web_vector_for_zero_primary() {
|
|
||||||
// 64 hex zeros.
|
|
||||||
let primary = Identity::parse("ed25519:".to_owned() + &"0".repeat(64))
|
|
||||||
.unwrap();
|
|
||||||
let addr = addr_from_primary(&primary);
|
|
||||||
assert_eq!(addr.len(), 64);
|
|
||||||
// Sanity: same input always produces same output.
|
|
||||||
assert_eq!(addr, addr_from_primary(&primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn different_primaries_have_different_addrs() {
|
|
||||||
let a = Identity::parse("ed25519:".to_owned() + &"0".repeat(64)).unwrap();
|
|
||||||
let b = Identity::parse("ed25519:".to_owned() + &"1".repeat(64)).unwrap();
|
|
||||||
assert_ne!(addr_from_primary(&a), addr_from_primary(&b));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn evicting_set_drops_oldest() {
|
|
||||||
let mut s = EvictingSet::new(2);
|
|
||||||
assert!(s.insert("a".into()));
|
|
||||||
assert!(s.insert("b".into()));
|
|
||||||
assert!(!s.insert("a".into())); // dedup hit
|
|
||||||
assert!(s.insert("c".into())); // evicts "a"
|
|
||||||
assert!(s.insert("a".into())); // now new again
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -32,7 +32,7 @@ use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|||||||
use p256::pkcs8::EncodePrivateKey;
|
use p256::pkcs8::EncodePrivateKey;
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use tokio::sync::Mutex;
|
||||||
// Note: WebPushClient is a trait that provides the .send() method on
|
// Note: WebPushClient is a trait that provides the .send() method on
|
||||||
// IsahcWebPushClient — keep it in scope even though it looks "unused".
|
// IsahcWebPushClient — keep it in scope even though it looks "unused".
|
||||||
use web_push::{
|
use web_push::{
|
||||||
@ -222,13 +222,8 @@ struct PushInner {
|
|||||||
client: IsahcWebPushClient,
|
client: IsahcWebPushClient,
|
||||||
vapid_private_pem: String,
|
vapid_private_pem: String,
|
||||||
vapid_subject: String,
|
vapid_subject: String,
|
||||||
/// Bounded concurrency on fanout — caps the number of in-flight
|
// Reserved for future provider tweaks without re-plumbing.
|
||||||
/// VAPID-signing + HTTPS-send tasks. Without this, a message
|
_lock: Mutex<()>,
|
||||||
/// flood spawns an unbounded set of background tasks and we OOM
|
|
||||||
/// before the kernel intervenes. 32 permits is plenty for v0.1
|
|
||||||
/// volume; tune later if we ever push real traffic. TODO.md
|
|
||||||
/// Day 3 #16.
|
|
||||||
fanout_sem: Arc<tokio::sync::Semaphore>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PushSender {
|
impl PushSender {
|
||||||
@ -238,7 +233,7 @@ impl PushSender {
|
|||||||
client: IsahcWebPushClient::new()?,
|
client: IsahcWebPushClient::new()?,
|
||||||
vapid_private_pem: vapid.private_pem.clone(),
|
vapid_private_pem: vapid.private_pem.clone(),
|
||||||
vapid_subject: vapid_subject.to_owned(),
|
vapid_subject: vapid_subject.to_owned(),
|
||||||
fanout_sem: Arc::new(tokio::sync::Semaphore::new(32)),
|
_lock: Mutex::new(()),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -255,32 +250,13 @@ impl PushSender {
|
|||||||
recipient_handle: &str,
|
recipient_handle: &str,
|
||||||
payload: &serde_json::Value,
|
payload: &serde_json::Value,
|
||||||
) {
|
) {
|
||||||
// Bounded concurrency: an unbounded `tokio::spawn` per
|
|
||||||
// message would OOM under flood. Acquire a permit (blocks if
|
|
||||||
// 32 fanouts are already in flight) before doing any work.
|
|
||||||
// Permit drops on scope exit. TODO.md Day 3 #16.
|
|
||||||
let _permit = match self.inner.fanout_sem.clone().acquire_owned().await {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => return, // semaphore closed → server shutting down
|
|
||||||
};
|
|
||||||
|
|
||||||
let subs = match store.list_push_subscriptions(recipient_handle).await {
|
let subs = match store.list_push_subscriptions(recipient_handle).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, handle = %recipient_handle, "push: list_subscriptions failed");
|
tracing::warn!(error = %e, "push: list_subscriptions failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Fanout is logged at DEBUG so the social graph isn't
|
|
||||||
// permanently in the production log file — INFO-level
|
|
||||||
// retention can be subpoena'd or stolen. The hashed handle
|
|
||||||
// (`h_tag`) still lets us group "all fanouts for X" in a
|
|
||||||
// debug session without exposing X. See TODO.md Day 1 #17.
|
|
||||||
tracing::debug!(
|
|
||||||
h_tag = %hash_handle(recipient_handle),
|
|
||||||
sub_count = subs.len(),
|
|
||||||
"push: fanout triggered"
|
|
||||||
);
|
|
||||||
if subs.is_empty() {
|
if subs.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -292,29 +268,17 @@ impl PushSender {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
for sub in subs {
|
for sub in subs {
|
||||||
match self.send_one(&sub, &body).await {
|
if let Err(e) = self.send_one(&sub, &body).await {
|
||||||
Ok(()) => {
|
match e {
|
||||||
// DEBUG, not INFO — same reasoning as the fanout
|
|
||||||
// log above: don't bake the social graph into
|
|
||||||
// long-lived logs.
|
|
||||||
tracing::debug!(
|
|
||||||
endpoint_host = %endpoint_host(&sub.endpoint),
|
|
||||||
h_tag = %hash_handle(recipient_handle),
|
|
||||||
"push: sent"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => match e {
|
|
||||||
WebPushError::EndpointNotValid | WebPushError::EndpointNotFound => {
|
WebPushError::EndpointNotValid | WebPushError::EndpointNotFound => {
|
||||||
// 410 Gone / 404 → subscription is dead; drop it.
|
// 410 Gone / 404 → subscription is dead; drop it.
|
||||||
// Keep at INFO — operationally relevant (we just
|
tracing::info!(endpoint = %sub.endpoint, "push: dropping expired subscription");
|
||||||
// changed DB state) and doesn't reveal which user.
|
|
||||||
tracing::info!(endpoint_host = %endpoint_host(&sub.endpoint), "push: dropping expired subscription");
|
|
||||||
let _ = store.delete_push_subscription(&sub.endpoint).await;
|
let _ = store.delete_push_subscription(&sub.endpoint).await;
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
tracing::warn!(endpoint_host = %endpoint_host(&sub.endpoint), error = ?other, "push: send failed");
|
tracing::warn!(endpoint = %sub.endpoint, error = ?other, "push: send failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -339,40 +303,3 @@ impl PushSender {
|
|||||||
self.inner.client.send(msg.build()?).await
|
self.inner.client.send(msg.build()?).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stable, short, NON-reversible label for a handle so debug logs can
|
|
||||||
/// group "all fanouts for X" without writing X. Process-lifetime
|
|
||||||
/// scoped — the salt only exists in this server instance's memory,
|
|
||||||
/// so an attacker who only has the log file can't even rebuild a
|
|
||||||
/// rainbow table against a known handle list. See TODO.md Day 1 #17.
|
|
||||||
fn hash_handle(handle: &str) -> String {
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
static SALT: OnceLock<u64> = OnceLock::new();
|
|
||||||
let salt = *SALT.get_or_init(|| {
|
|
||||||
// Cheap process-instance salt — we want unguessability
|
|
||||||
// against a stale log file, not cryptographic strength.
|
|
||||||
// SystemTime can't be used in our skill harness, but it's
|
|
||||||
// fine in production runtime; on first call it's deterministic
|
|
||||||
// per-process.
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_nanos() as u64)
|
|
||||||
.unwrap_or(0xDEADBEEF)
|
|
||||||
});
|
|
||||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
||||||
salt.hash(&mut hasher);
|
|
||||||
handle.hash(&mut hasher);
|
|
||||||
format!("h:{:016x}", hasher.finish())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pretty hostname for logging — full FCM endpoints are 200+ chars
|
|
||||||
/// of opaque junk; the hostname (fcm.googleapis.com, etc.) is the
|
|
||||||
/// useful diagnostic bit. Hand-rolled to avoid pulling `url` into
|
|
||||||
/// the dependency footprint just for one log call.
|
|
||||||
fn endpoint_host(endpoint: &str) -> String {
|
|
||||||
let no_scheme = endpoint.split_once("://").map(|(_, r)| r).unwrap_or(endpoint);
|
|
||||||
let host = no_scheme.split(&['/', ':'][..]).next().unwrap_or("?");
|
|
||||||
host.to_string()
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,206 +0,0 @@
|
|||||||
//! Tiny per-IP token-bucket rate limiter.
|
|
||||||
//!
|
|
||||||
//! Currently used by `POST /v1/messages` to keep a single source from
|
|
||||||
//! filling everyone's mailbox until disk fills (the v0.1 spam concern
|
|
||||||
//! called out in messages.rs). Default rate: 60 messages/min per IP.
|
|
||||||
//!
|
|
||||||
//! Why not pull in `tower_governor` or `governor`? They're great
|
|
||||||
//! crates but each adds 10+ transitive deps for what's structurally
|
|
||||||
//! ~50 lines of code. We're already shipping nostr-sdk's dep tree;
|
|
||||||
//! restraint here keeps the build snappy.
|
|
||||||
//!
|
|
||||||
//! Client IP resolution priority:
|
|
||||||
//! 1. `CF-Connecting-IP` header — Cloudflare puts the real client
|
|
||||||
//! IP here; we trust it because Cloudflare strips this header
|
|
||||||
//! from anything that wasn't routed through our tunnel.
|
|
||||||
//! 2. `X-Forwarded-For` (first hop) — fallback for non-Cloudflare
|
|
||||||
//! deployments.
|
|
||||||
//! 3. None — direct curl / loopback. Rate-limit by `0.0.0.0` so
|
|
||||||
//! noisy test traffic still gets bucketed instead of bypassing.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::net::IpAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use axum::http::HeaderMap;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
/// One bucket per client. We store the residual token count + the
|
|
||||||
/// last refill timestamp; on each `try_acquire` we compute how many
|
|
||||||
/// tokens to add based on elapsed time, then either decrement or fail.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct Bucket {
|
|
||||||
/// Tokens currently available.
|
|
||||||
tokens: f64,
|
|
||||||
/// Last time we refilled.
|
|
||||||
last_refill: Instant,
|
|
||||||
/// Most recent activity — used by the eviction sweep to drop
|
|
||||||
/// long-cold buckets so the HashMap doesn't grow forever.
|
|
||||||
last_seen: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct RateLimitConfig {
|
|
||||||
/// Bucket capacity (max burst). Once exhausted, callers fail
|
|
||||||
/// fast until enough time passes to refill ≥1 token.
|
|
||||||
pub capacity: u32,
|
|
||||||
/// Refill rate in tokens per second.
|
|
||||||
pub refill_per_sec: f64,
|
|
||||||
/// Buckets idle longer than this get evicted on next sweep so
|
|
||||||
/// short-lived clients don't pile up in memory.
|
|
||||||
pub idle_ttl: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for RateLimitConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
capacity: 60,
|
|
||||||
refill_per_sec: 1.0, // = 60/min steady-state
|
|
||||||
idle_ttl: Duration::from_secs(15 * 60),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process-shared rate limiter. Cheap to clone (Arc inside).
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct RateLimiter {
|
|
||||||
inner: Arc<Mutex<HashMap<IpAddr, Bucket>>>,
|
|
||||||
config: RateLimitConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RateLimiter {
|
|
||||||
pub fn new(config: RateLimitConfig) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(Mutex::new(HashMap::new())),
|
|
||||||
config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain one token for `ip` if available. Returns `true` on
|
|
||||||
/// success (caller may proceed) or `false` if rate-limited
|
|
||||||
/// (caller should respond 429).
|
|
||||||
pub async fn try_acquire(&self, ip: IpAddr) -> bool {
|
|
||||||
let mut map = self.inner.lock().await;
|
|
||||||
let now = Instant::now();
|
|
||||||
let bucket = map.entry(ip).or_insert(Bucket {
|
|
||||||
tokens: self.config.capacity as f64,
|
|
||||||
last_refill: now,
|
|
||||||
last_seen: now,
|
|
||||||
});
|
|
||||||
// Refill since last touch.
|
|
||||||
let elapsed = now.saturating_duration_since(bucket.last_refill).as_secs_f64();
|
|
||||||
bucket.tokens =
|
|
||||||
(bucket.tokens + elapsed * self.config.refill_per_sec)
|
|
||||||
.min(self.config.capacity as f64);
|
|
||||||
bucket.last_refill = now;
|
|
||||||
bucket.last_seen = now;
|
|
||||||
if bucket.tokens >= 1.0 {
|
|
||||||
bucket.tokens -= 1.0;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Periodically called by a background sweep to drop buckets
|
|
||||||
/// for clients we haven't heard from in `idle_ttl`. Returns the
|
|
||||||
/// number of buckets removed (diagnostic).
|
|
||||||
pub async fn sweep(&self) -> usize {
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut map = self.inner.lock().await;
|
|
||||||
let before = map.len();
|
|
||||||
map.retain(|_, b| now.saturating_duration_since(b.last_seen) < self.config.idle_ttl);
|
|
||||||
before - map.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the client IP from the request headers, with the
|
|
||||||
/// Cloudflare-first priority documented above. Falls back to
|
|
||||||
/// `0.0.0.0` if we can't extract anything sensible — that way
|
|
||||||
/// direct curl traffic still gets rate-limited as a single
|
|
||||||
/// "anonymous" client instead of bypassing entirely.
|
|
||||||
pub fn client_ip_from_headers(headers: &HeaderMap) -> IpAddr {
|
|
||||||
if let Some(v) = headers.get("CF-Connecting-IP").and_then(|h| h.to_str().ok()) {
|
|
||||||
if let Ok(ip) = v.trim().parse::<IpAddr>() {
|
|
||||||
return ip;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(v) = headers.get("X-Forwarded-For").and_then(|h| h.to_str().ok()) {
|
|
||||||
// X-Forwarded-For is a comma-separated list; the leftmost
|
|
||||||
// value is the original client.
|
|
||||||
if let Some(first) = v.split(',').next() {
|
|
||||||
if let Ok(ip) = first.trim().parse::<IpAddr>() {
|
|
||||||
return ip;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"0.0.0.0".parse().expect("0.0.0.0 is a valid IpAddr")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn cfg_for_test() -> RateLimitConfig {
|
|
||||||
RateLimitConfig {
|
|
||||||
capacity: 3,
|
|
||||||
refill_per_sec: 10.0,
|
|
||||||
idle_ttl: Duration::from_secs(1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn within_capacity_succeeds() {
|
|
||||||
let rl = RateLimiter::new(cfg_for_test());
|
|
||||||
let ip: IpAddr = "1.2.3.4".parse().unwrap();
|
|
||||||
for _ in 0..3 {
|
|
||||||
assert!(rl.try_acquire(ip).await);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn exhausting_capacity_fails_then_recovers() {
|
|
||||||
let rl = RateLimiter::new(cfg_for_test());
|
|
||||||
let ip: IpAddr = "1.2.3.4".parse().unwrap();
|
|
||||||
for _ in 0..3 {
|
|
||||||
assert!(rl.try_acquire(ip).await);
|
|
||||||
}
|
|
||||||
assert!(!rl.try_acquire(ip).await, "4th request should be rate-limited");
|
|
||||||
// Refill rate is 10 tokens/sec → 1 token in 100ms.
|
|
||||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
|
||||||
assert!(rl.try_acquire(ip).await);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn separate_ips_have_separate_buckets() {
|
|
||||||
let rl = RateLimiter::new(cfg_for_test());
|
|
||||||
let a: IpAddr = "1.2.3.4".parse().unwrap();
|
|
||||||
let b: IpAddr = "5.6.7.8".parse().unwrap();
|
|
||||||
for _ in 0..3 {
|
|
||||||
assert!(rl.try_acquire(a).await);
|
|
||||||
}
|
|
||||||
assert!(rl.try_acquire(b).await, "different IP should still have full bucket");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cf_header_wins_over_xff() {
|
|
||||||
let mut h = HeaderMap::new();
|
|
||||||
h.insert("CF-Connecting-IP", "9.9.9.9".parse().unwrap());
|
|
||||||
h.insert("X-Forwarded-For", "8.8.8.8, 7.7.7.7".parse().unwrap());
|
|
||||||
assert_eq!(client_ip_from_headers(&h).to_string(), "9.9.9.9");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn xff_first_hop() {
|
|
||||||
let mut h = HeaderMap::new();
|
|
||||||
h.insert("X-Forwarded-For", "8.8.8.8, 7.7.7.7".parse().unwrap());
|
|
||||||
assert_eq!(client_ip_from_headers(&h).to_string(), "8.8.8.8");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fallback_when_no_headers() {
|
|
||||||
let h = HeaderMap::new();
|
|
||||||
assert_eq!(client_ip_from_headers(&h).to_string(), "0.0.0.0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -35,14 +35,6 @@
|
|||||||
const showNav = $derived(!!session.unlocked && APP_ROUTES.includes($location));
|
const showNav = $derived(!!session.unlocked && APP_ROUTES.includes($location));
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Try the long-lived session blob first — when it works (the
|
|
||||||
// common case for returning PWA users), the unlock prompt is
|
|
||||||
// skipped entirely. Failures (expired, key missing, decrypt
|
|
||||||
// mismatch) silently fall through to the passphrase flow.
|
|
||||||
if (!session.unlocked) {
|
|
||||||
await session.tryRestoreFromStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
const stored = await hasStoredIdentity();
|
const stored = await hasStoredIdentity();
|
||||||
// Redirect legacy paths.
|
// Redirect legacy paths.
|
||||||
if ($location === "/dashboard") return push(session.unlocked ? "/identity" : "/unlock");
|
if ($location === "/dashboard") return push(session.unlocked ? "/identity" : "/unlock");
|
||||||
@ -51,10 +43,6 @@
|
|||||||
push("/");
|
push("/");
|
||||||
} else if (stored && !session.unlocked && APP_ROUTES.includes($location)) {
|
} else if (stored && !session.unlocked && APP_ROUTES.includes($location)) {
|
||||||
push("/unlock");
|
push("/unlock");
|
||||||
} else if (session.unlocked && ($location === "/" || $location === "/unlock")) {
|
|
||||||
// We auto-unlocked from persisted storage; drop the landing /
|
|
||||||
// unlock screen and go straight to chats.
|
|
||||||
push("/chats");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -13,17 +13,8 @@
|
|||||||
size?: number;
|
size?: number;
|
||||||
/** Optional ring (e.g. for the active/own avatar). */
|
/** Optional ring (e.g. for the active/own avatar). */
|
||||||
ring?: boolean;
|
ring?: boolean;
|
||||||
/**
|
|
||||||
* Optional user-supplied picture (data URL or https URL). When
|
|
||||||
* set, the identicon falls back to a tiny shadow ring around
|
|
||||||
* the image — same physical footprint, just a different render.
|
|
||||||
* When absent (or `null`), the deterministic identicon is shown.
|
|
||||||
*
|
|
||||||
* This is the "use the bobble if no image defined" branch.
|
|
||||||
*/
|
|
||||||
picture?: string | null;
|
|
||||||
}
|
}
|
||||||
let { seed, size = 40, ring = false, picture = null }: Props = $props();
|
let { seed, size = 40, ring = false }: Props = $props();
|
||||||
|
|
||||||
// Cheap, stable 32-bit FNV-1a hash — no crypto needed, just spreading.
|
// Cheap, stable 32-bit FNV-1a hash — no crypto needed, just spreading.
|
||||||
function hash(str: string): number {
|
function hash(str: string): number {
|
||||||
@ -62,19 +53,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if picture}
|
<svg
|
||||||
<img
|
|
||||||
src={picture}
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
alt="profile picture"
|
|
||||||
class="shrink-0 object-cover"
|
|
||||||
style="border-radius: {Math.max(4, size * 0.2)}px; {ring
|
|
||||||
? 'box-shadow: 0 0 0 2px var(--color-accent);'
|
|
||||||
: ''}"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 5 5"
|
viewBox="0 0 5 5"
|
||||||
@ -82,7 +61,7 @@
|
|||||||
style="border-radius: {Math.max(4, size * 0.2)}px; {ring ? `box-shadow: 0 0 0 2px var(--color-accent);` : ''}"
|
style="border-radius: {Math.max(4, size * 0.2)}px; {ring ? `box-shadow: 0 0 0 2px var(--color-accent);` : ''}"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="identity avatar"
|
aria-label="identity avatar"
|
||||||
>
|
>
|
||||||
<rect width="5" height="5" fill={tile} />
|
<rect width="5" height="5" fill={tile} />
|
||||||
{#each [0, 1, 2, 3, 4] as col}
|
{#each [0, 1, 2, 3, 4] as col}
|
||||||
{#each [0, 1, 2, 3, 4] as row}
|
{#each [0, 1, 2, 3, 4] as row}
|
||||||
@ -91,5 +70,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
|
||||||
|
|||||||
@ -1,114 +0,0 @@
|
|||||||
// Local attachment cache.
|
|
||||||
//
|
|
||||||
// Two stores in IndexedDB:
|
|
||||||
//
|
|
||||||
// `kez-chat:attachments:v1` — assembled (or inline) files, keyed
|
|
||||||
// by message_key (peer_primary + ":" +
|
|
||||||
// seq). The value is a data URL ready
|
|
||||||
// to slot straight into <img src=>.
|
|
||||||
// `kez-chat:chunk-buffer:v1` — in-flight chunks for chunked files,
|
|
||||||
// keyed by file_id, value is
|
|
||||||
// {n, received: {[i]: Uint8Array}}.
|
|
||||||
//
|
|
||||||
// Why IDB instead of in-memory: a 10 MB image in memory is fine, but
|
|
||||||
// a chunked transfer that's only 50% complete when the user closes
|
|
||||||
// the tab should resume — IDB persists across reloads.
|
|
||||||
|
|
||||||
import { get, set, del } from "idb-keyval";
|
|
||||||
|
|
||||||
import type { Identity } from "./kez.js";
|
|
||||||
|
|
||||||
const ATTACH_PREFIX = "kez-chat:attachments:v1:";
|
|
||||||
const CHUNK_PREFIX = "kez-chat:chunk-buffer:v1:";
|
|
||||||
|
|
||||||
function attachKey(peer_primary: Identity, seq: number): string {
|
|
||||||
return `${ATTACH_PREFIX}${peer_primary}|${seq}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunkKey(file_id: string): string {
|
|
||||||
return `${CHUNK_PREFIX}${file_id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── assembled attachments ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface StoredAttachment {
|
|
||||||
filename: string;
|
|
||||||
mime: string;
|
|
||||||
/** data URL ready to render. For images, just plug into <img>. */
|
|
||||||
data_url: string;
|
|
||||||
/** Bytes of the original file. Useful for save-to-disk later. */
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveAttachment(
|
|
||||||
peer_primary: Identity,
|
|
||||||
seq: number,
|
|
||||||
att: StoredAttachment,
|
|
||||||
): Promise<void> {
|
|
||||||
await set(attachKey(peer_primary, seq), att);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadAttachment(
|
|
||||||
peer_primary: Identity,
|
|
||||||
seq: number,
|
|
||||||
): Promise<StoredAttachment | undefined> {
|
|
||||||
return get<StoredAttachment>(attachKey(peer_primary, seq));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAttachment(
|
|
||||||
peer_primary: Identity,
|
|
||||||
seq: number,
|
|
||||||
): Promise<void> {
|
|
||||||
await del(attachKey(peer_primary, seq));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── chunk buffer ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ChunkBufferEntry {
|
|
||||||
/** Total expected chunks. */
|
|
||||||
n: number;
|
|
||||||
/** Sparse list of received chunks, ordered by index i. Stored
|
|
||||||
* as bytes (not base64) so we don't pay encoding overhead
|
|
||||||
* every time we read the buffer. */
|
|
||||||
received: Record<number, Uint8Array>;
|
|
||||||
/** When we first saw a chunk for this file. Buffers older than
|
|
||||||
* N days get GC'd by `cleanupStaleChunkBuffers`. */
|
|
||||||
started_at: string;
|
|
||||||
/** Where to land the file once n/n chunks are in. Set when the
|
|
||||||
* matching pointer event arrives (which may come before or after
|
|
||||||
* any of the chunks). */
|
|
||||||
destination?: {
|
|
||||||
peer_primary: Identity;
|
|
||||||
seq: number;
|
|
||||||
filename: string;
|
|
||||||
mime: string;
|
|
||||||
size: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadChunkBuffer(
|
|
||||||
file_id: string,
|
|
||||||
): Promise<ChunkBufferEntry | undefined> {
|
|
||||||
return get<ChunkBufferEntry>(chunkKey(file_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveChunkBuffer(
|
|
||||||
file_id: string,
|
|
||||||
entry: ChunkBufferEntry,
|
|
||||||
): Promise<void> {
|
|
||||||
await set(chunkKey(file_id), entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteChunkBuffer(file_id: string): Promise<void> {
|
|
||||||
await del(chunkKey(file_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Has every chunk arrived AND do we know where to put the result? */
|
|
||||||
export function chunkBufferIsComplete(buf: ChunkBufferEntry): boolean {
|
|
||||||
if (!buf.destination) return false;
|
|
||||||
if (Object.keys(buf.received).length !== buf.n) return false;
|
|
||||||
for (let i = 0; i < buf.n; i++) {
|
|
||||||
if (!buf.received[i]) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@ -6,8 +6,6 @@
|
|||||||
// anyone with this browser profile already has the user's seed (the real
|
// anyone with this browser profile already has the user's seed (the real
|
||||||
// secret), so encrypting the message log adds little practical security.
|
// secret), so encrypting the message log adds little practical security.
|
||||||
|
|
||||||
import { ed25519 } from "@noble/curves/ed25519";
|
|
||||||
import { hexToBytes } from "@noble/hashes/utils";
|
|
||||||
import { get, set } from "idb-keyval";
|
import { get, set } from "idb-keyval";
|
||||||
import type { Identity } from "./kez.js";
|
import type { Identity } from "./kez.js";
|
||||||
|
|
||||||
@ -17,21 +15,6 @@ import type { Identity } from "./kez.js";
|
|||||||
// placeholders anyway.
|
// placeholders anyway.
|
||||||
const KEY = "kez-chat:conversations:v2";
|
const KEY = "kez-chat:conversations:v2";
|
||||||
|
|
||||||
/**
|
|
||||||
* Delivery state for outbound messages. Inbound messages never have
|
|
||||||
* a status (the act of having received them is the only signal that
|
|
||||||
* matters; they were obviously "delivered" to us).
|
|
||||||
*
|
|
||||||
* sending → the bubble was rendered locally, publish is in-flight.
|
|
||||||
* sent → at least one nostr relay (or the chat-server) accepted
|
|
||||||
* the event. Renders as a single check ✓.
|
|
||||||
* delivered → the recipient's client has decrypted it and published
|
|
||||||
* an ack event back. Renders as a check inside a circle.
|
|
||||||
* failed → publish failed (every relay rejected, or network
|
|
||||||
* error). User sees a red retry affordance.
|
|
||||||
*/
|
|
||||||
export type MessageStatus = "sending" | "sent" | "delivered" | "failed";
|
|
||||||
|
|
||||||
export interface ConversationMessage {
|
export interface ConversationMessage {
|
||||||
/** Server seq for inbound, Date.now() for outbound. Only used for ordering + dedupe. */
|
/** Server seq for inbound, Date.now() for outbound. Only used for ordering + dedupe. */
|
||||||
seq: number;
|
seq: number;
|
||||||
@ -41,39 +24,6 @@ export interface ConversationMessage {
|
|||||||
from: Identity;
|
from: Identity;
|
||||||
/** ISO timestamp (sender's clock for `in`, click-time for `out`). */
|
/** ISO timestamp (sender's clock for `in`, click-time for `out`). */
|
||||||
ts: string;
|
ts: string;
|
||||||
/** Outbound only — current delivery state. Absent on inbound. */
|
|
||||||
status?: MessageStatus;
|
|
||||||
/**
|
|
||||||
* Outbound only — the underlying transport's event id (nostr event
|
|
||||||
* id, or server seq stringified for server-transport). We use this
|
|
||||||
* to map inbound ack events back to the bubble whose state should
|
|
||||||
* flip from "sent" to "delivered".
|
|
||||||
*/
|
|
||||||
event_id?: string;
|
|
||||||
/** Outbound only — the first relay that accepted this event on
|
|
||||||
* publish (Promise.any winner). Surfaced as a tiny "via X"
|
|
||||||
* footnote in the bubble so the user knows which relay carried
|
|
||||||
* the message; also informs future reply biasing. */
|
|
||||||
accepted_by?: string;
|
|
||||||
/** When set, this bubble represents a file attachment (image
|
|
||||||
* preview, generic file chip, etc.) rather than a text message.
|
|
||||||
* See attachment-store.ts for the actual bytes. `body` carries
|
|
||||||
* a human-readable fallback ("📎 vacation.jpg"). */
|
|
||||||
attachment?: {
|
|
||||||
filename: string;
|
|
||||||
mime: string;
|
|
||||||
size: number;
|
|
||||||
/** Progress state: "ready" once we have the bytes; "pending"
|
|
||||||
* while chunks are still arriving for a chunked transfer;
|
|
||||||
* "failed" if assembly gave up. */
|
|
||||||
state: "ready" | "pending" | "failed";
|
|
||||||
/** For chunked transfers: file_id correlator. Lets the inbox-
|
|
||||||
* service find the pointer when a stray chunk arrives later. */
|
|
||||||
file_id?: string;
|
|
||||||
/** Receive progress for chunked transfers. */
|
|
||||||
received_chunks?: number;
|
|
||||||
total_chunks?: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
@ -88,27 +38,6 @@ export interface Conversation {
|
|||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
/** ISO timestamp of the last verification check (24h cache window). */
|
/** ISO timestamp of the last verification check (24h cache window). */
|
||||||
verified_checked_at?: string;
|
verified_checked_at?: string;
|
||||||
/**
|
|
||||||
* Peer's NOSTR pubkey (secp256k1 hex), learned the first time we
|
|
||||||
* received a DM from them. Used to fetch their kind:0 profile event
|
|
||||||
* so we can render their avatar (and descramble visually-encrypted
|
|
||||||
* pictures with the key wrap they sent us). Absent for conversations
|
|
||||||
* we've only sent to.
|
|
||||||
*/
|
|
||||||
peer_nostr_pubkey?: string;
|
|
||||||
/**
|
|
||||||
* Last relay we received a message from this peer over (e.g.
|
|
||||||
* "wss://relay.damus.io"). We prefer it for outgoing replies — the
|
|
||||||
* inbound path that worked is usually the lowest-latency reply
|
|
||||||
* path too. Bumped on every inbound DM.
|
|
||||||
*/
|
|
||||||
peer_via_relay?: string;
|
|
||||||
/**
|
|
||||||
* Per-conversation unread-message counter. Bumped on every
|
|
||||||
* inbound DM, reset to 0 when the user opens the conversation.
|
|
||||||
* The sidebar conversation list renders a badge when > 0.
|
|
||||||
*/
|
|
||||||
unread_count?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Store {
|
interface Store {
|
||||||
@ -193,13 +122,6 @@ export async function appendInbound(opts: {
|
|||||||
seq: number;
|
seq: number;
|
||||||
body: string;
|
body: string;
|
||||||
ts: string;
|
ts: string;
|
||||||
/** Peer's nostr pubkey from the inbound event — if available, we
|
|
||||||
* cache it on the conversation so peer-profile-store can look up
|
|
||||||
* their kind:0 later. */
|
|
||||||
peer_nostr_pubkey?: string;
|
|
||||||
/** Relay this event arrived on first. Bumps `peer_via_relay` for
|
|
||||||
* reply-bias. */
|
|
||||||
via_relay?: string;
|
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const s = await read();
|
const s = await read();
|
||||||
const conv = s.by_peer[opts.peer_primary] ?? {
|
const conv = s.by_peer[opts.peer_primary] ?? {
|
||||||
@ -210,11 +132,7 @@ export async function appendInbound(opts: {
|
|||||||
};
|
};
|
||||||
// Refresh display name in case we just resolved it.
|
// Refresh display name in case we just resolved it.
|
||||||
if (opts.peer_handle) conv.peer_handle = opts.peer_handle;
|
if (opts.peer_handle) conv.peer_handle = opts.peer_handle;
|
||||||
if (opts.peer_nostr_pubkey) conv.peer_nostr_pubkey = opts.peer_nostr_pubkey;
|
if (!conv.messages.find((m) => m.direction === "in" && m.seq === opts.seq)) {
|
||||||
if (opts.via_relay) conv.peer_via_relay = opts.via_relay;
|
|
||||||
const isNewMessage =
|
|
||||||
!conv.messages.find((m) => m.direction === "in" && m.seq === opts.seq);
|
|
||||||
if (isNewMessage) {
|
|
||||||
conv.messages.push({
|
conv.messages.push({
|
||||||
seq: opts.seq,
|
seq: opts.seq,
|
||||||
direction: "in",
|
direction: "in",
|
||||||
@ -222,10 +140,6 @@ export async function appendInbound(opts: {
|
|||||||
from: opts.peer_primary,
|
from: opts.peer_primary,
|
||||||
ts: opts.ts,
|
ts: opts.ts,
|
||||||
});
|
});
|
||||||
// Bump the unread counter ONLY when the message is genuinely
|
|
||||||
// new (not a SSE-replay-of-a-poll race). The Messages page
|
|
||||||
// resets it to 0 the moment the user opens the conversation.
|
|
||||||
conv.unread_count = (conv.unread_count ?? 0) + 1;
|
|
||||||
}
|
}
|
||||||
conv.last_seq = Math.max(conv.last_seq, opts.seq);
|
conv.last_seq = Math.max(conv.last_seq, opts.seq);
|
||||||
s.by_peer[opts.peer_primary] = conv;
|
s.by_peer[opts.peer_primary] = conv;
|
||||||
@ -233,37 +147,12 @@ export async function appendInbound(opts: {
|
|||||||
await write(s);
|
await write(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the unread counter for one conversation. Called by Messages.svelte
|
|
||||||
* the moment the user activates that thread, so the sidebar badge
|
|
||||||
* disappears immediately.
|
|
||||||
*/
|
|
||||||
export async function markConversationRead(
|
|
||||||
peer_primary: Identity,
|
|
||||||
): Promise<void> {
|
|
||||||
const s = await read();
|
|
||||||
const conv = s.by_peer[peer_primary];
|
|
||||||
if (!conv) return;
|
|
||||||
if (conv.unread_count) {
|
|
||||||
conv.unread_count = 0;
|
|
||||||
await write(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append an outbound message and return its synthetic `seq` so the
|
|
||||||
* caller can update the status later (sending → sent → delivered).
|
|
||||||
* Caller is responsible for invoking the transport AFTER this; the
|
|
||||||
* point of the split is so the bubble appears IMMEDIATELY and the
|
|
||||||
* user sees the in-flight state.
|
|
||||||
*/
|
|
||||||
export async function appendOutbound(opts: {
|
export async function appendOutbound(opts: {
|
||||||
peer_primary: Identity;
|
peer_primary: Identity;
|
||||||
peer_handle: string;
|
peer_handle: string;
|
||||||
from: Identity;
|
from: Identity;
|
||||||
body: string;
|
body: string;
|
||||||
status?: MessageStatus;
|
}): Promise<void> {
|
||||||
}): Promise<number> {
|
|
||||||
const s = await read();
|
const s = await read();
|
||||||
const conv =
|
const conv =
|
||||||
s.by_peer[opts.peer_primary] ?? {
|
s.by_peer[opts.peer_primary] ?? {
|
||||||
@ -273,233 +162,13 @@ export async function appendOutbound(opts: {
|
|||||||
last_seq: 0,
|
last_seq: 0,
|
||||||
};
|
};
|
||||||
if (opts.peer_handle) conv.peer_handle = opts.peer_handle;
|
if (opts.peer_handle) conv.peer_handle = opts.peer_handle;
|
||||||
const seq = Date.now();
|
|
||||||
conv.messages.push({
|
conv.messages.push({
|
||||||
seq,
|
seq: Date.now(),
|
||||||
direction: "out",
|
direction: "out",
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
from: opts.from,
|
from: opts.from,
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
status: opts.status ?? "sending",
|
|
||||||
});
|
});
|
||||||
s.by_peer[opts.peer_primary] = conv;
|
s.by_peer[opts.peer_primary] = conv;
|
||||||
await write(s);
|
await write(s);
|
||||||
return seq;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patch the status (and optionally the transport event_id) of an
|
|
||||||
* outbound message we already rendered locally.
|
|
||||||
*
|
|
||||||
* markOutboundStatus(peer, seq, "sent", { event_id: ev.id })
|
|
||||||
* markOutboundStatus(peer, seq, "failed")
|
|
||||||
*
|
|
||||||
* No-op if the message isn't found (e.g. user cleared the
|
|
||||||
* conversation in another tab).
|
|
||||||
*/
|
|
||||||
export async function markOutboundStatus(
|
|
||||||
peer_primary: Identity,
|
|
||||||
seq: number,
|
|
||||||
status: MessageStatus,
|
|
||||||
extras?: { event_id?: string; accepted_by?: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const s = await read();
|
|
||||||
const conv = s.by_peer[peer_primary];
|
|
||||||
if (!conv) return;
|
|
||||||
const m = conv.messages.find(
|
|
||||||
(msg) => msg.direction === "out" && msg.seq === seq,
|
|
||||||
);
|
|
||||||
if (!m) return;
|
|
||||||
m.status = status;
|
|
||||||
if (extras?.event_id) m.event_id = extras.event_id;
|
|
||||||
if (extras?.accepted_by) m.accepted_by = extras.accepted_by;
|
|
||||||
await write(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flip the matching outbound message from "sent" to "delivered" when
|
|
||||||
* we receive an ack event for it. Returns true if a bubble actually
|
|
||||||
* changed (so the UI knows to refresh).
|
|
||||||
*
|
|
||||||
* We scan ALL conversations because an ack event arrives via the
|
|
||||||
* inbox stream not tied to a specific peer — the event_id is the
|
|
||||||
* only correlator.
|
|
||||||
*
|
|
||||||
* If `ack_sig_hex` is provided, we verify it as an ed25519 signature
|
|
||||||
* over `event_id` by the conversation peer's KEZ primary — that's how
|
|
||||||
* we know the ack genuinely came from the intended recipient rather
|
|
||||||
* than a third party who scraped the original event id off a relay.
|
|
||||||
* Acks without a sig (legacy clients during the migration window)
|
|
||||||
* still flip the bubble; this is a "graceful degradation" until all
|
|
||||||
* peers are on the new build. TODO.md Day 3 #9.
|
|
||||||
*/
|
|
||||||
export async function markDeliveredByEventId(
|
|
||||||
event_id: string,
|
|
||||||
ack_sig_hex?: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!event_id) return false;
|
|
||||||
const s = await read();
|
|
||||||
let changed = false;
|
|
||||||
for (const conv of Object.values(s.by_peer)) {
|
|
||||||
for (const m of conv.messages) {
|
|
||||||
if (m.direction !== "out" || m.event_id !== event_id) continue;
|
|
||||||
// Don't downgrade — if it's already delivered, leave alone.
|
|
||||||
if (m.status === "delivered") return false;
|
|
||||||
|
|
||||||
// If the ack has a signature tag, verify it against the
|
|
||||||
// conversation peer's KEZ primary. Sig mismatch = ack was
|
|
||||||
// forged by someone who happened to see the event id; drop
|
|
||||||
// it silently rather than reward the spoofer with a UI tick.
|
|
||||||
if (ack_sig_hex) {
|
|
||||||
if (!verifyAckSig(conv.peer_primary, event_id, ack_sig_hex)) {
|
|
||||||
console.warn(
|
|
||||||
`markDelivered: ack sig did not verify against peer ${conv.peer_primary} — dropping`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
m.status = "delivered";
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) await write(s);
|
|
||||||
return changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append an inbound file-attachment message, INDEPENDENTLY of whether
|
|
||||||
* the file is ready yet. For inline files: `attachment.state = "ready"`
|
|
||||||
* and the caller is expected to have already saved the bytes via
|
|
||||||
* `attachment-store.saveAttachment`. For chunked files: usually
|
|
||||||
* "pending" because chunks may still be arriving.
|
|
||||||
*/
|
|
||||||
export async function appendInboundAttachment(opts: {
|
|
||||||
peer_primary: Identity;
|
|
||||||
peer_handle: string;
|
|
||||||
seq: number;
|
|
||||||
ts: string;
|
|
||||||
body: string;
|
|
||||||
peer_nostr_pubkey?: string;
|
|
||||||
via_relay?: string;
|
|
||||||
attachment: NonNullable<ConversationMessage["attachment"]>;
|
|
||||||
}): Promise<void> {
|
|
||||||
const s = await read();
|
|
||||||
const conv = s.by_peer[opts.peer_primary] ?? {
|
|
||||||
peer_primary: opts.peer_primary,
|
|
||||||
peer_handle: opts.peer_handle,
|
|
||||||
messages: [],
|
|
||||||
last_seq: 0,
|
|
||||||
};
|
|
||||||
if (opts.peer_handle) conv.peer_handle = opts.peer_handle;
|
|
||||||
if (opts.peer_nostr_pubkey) conv.peer_nostr_pubkey = opts.peer_nostr_pubkey;
|
|
||||||
if (opts.via_relay) conv.peer_via_relay = opts.via_relay;
|
|
||||||
const isNew = !conv.messages.find(
|
|
||||||
(m) => m.direction === "in" && m.seq === opts.seq,
|
|
||||||
);
|
|
||||||
if (isNew) {
|
|
||||||
conv.messages.push({
|
|
||||||
seq: opts.seq,
|
|
||||||
direction: "in",
|
|
||||||
body: opts.body,
|
|
||||||
from: opts.peer_primary,
|
|
||||||
ts: opts.ts,
|
|
||||||
attachment: opts.attachment,
|
|
||||||
});
|
|
||||||
conv.unread_count = (conv.unread_count ?? 0) + 1;
|
|
||||||
}
|
|
||||||
conv.last_seq = Math.max(conv.last_seq, opts.seq);
|
|
||||||
s.by_peer[opts.peer_primary] = conv;
|
|
||||||
s.global_cursor = Math.max(s.global_cursor, opts.seq);
|
|
||||||
await write(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append an OUTBOUND file-attachment message, used by Messages.svelte
|
|
||||||
* the moment the user hits Send. Returns the synthetic seq so the
|
|
||||||
* caller can mutate progress / status later.
|
|
||||||
*/
|
|
||||||
export async function appendOutboundAttachment(opts: {
|
|
||||||
peer_primary: Identity;
|
|
||||||
peer_handle: string;
|
|
||||||
from: Identity;
|
|
||||||
body: string;
|
|
||||||
attachment: NonNullable<ConversationMessage["attachment"]>;
|
|
||||||
status?: MessageStatus;
|
|
||||||
}): Promise<number> {
|
|
||||||
const s = await read();
|
|
||||||
const conv = s.by_peer[opts.peer_primary] ?? {
|
|
||||||
peer_primary: opts.peer_primary,
|
|
||||||
peer_handle: opts.peer_handle,
|
|
||||||
messages: [],
|
|
||||||
last_seq: 0,
|
|
||||||
};
|
|
||||||
if (opts.peer_handle) conv.peer_handle = opts.peer_handle;
|
|
||||||
const seq = Date.now();
|
|
||||||
conv.messages.push({
|
|
||||||
seq,
|
|
||||||
direction: "out",
|
|
||||||
body: opts.body,
|
|
||||||
from: opts.from,
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
status: opts.status ?? "sending",
|
|
||||||
attachment: opts.attachment,
|
|
||||||
});
|
|
||||||
s.by_peer[opts.peer_primary] = conv;
|
|
||||||
await write(s);
|
|
||||||
return seq;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mutate an attachment's state — used by the inbox-service when
|
|
||||||
* chunks land and progress advances. No-op if the message is gone. */
|
|
||||||
export async function patchAttachmentState(
|
|
||||||
peer_primary: Identity,
|
|
||||||
seq: number,
|
|
||||||
patch: Partial<NonNullable<ConversationMessage["attachment"]>>,
|
|
||||||
): Promise<void> {
|
|
||||||
const s = await read();
|
|
||||||
const conv = s.by_peer[peer_primary];
|
|
||||||
if (!conv) return;
|
|
||||||
const m = conv.messages.find(
|
|
||||||
(msg) => msg.direction === "in" && msg.seq === seq,
|
|
||||||
);
|
|
||||||
if (!m || !m.attachment) return;
|
|
||||||
m.attachment = { ...m.attachment, ...patch };
|
|
||||||
await write(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find a pending-attachment message by file_id across all
|
|
||||||
* conversations. Returns null if no such message. Useful when an
|
|
||||||
* inbound chunk arrives and we need to find which pointer it
|
|
||||||
* belongs to. */
|
|
||||||
export async function findAttachmentByFileId(
|
|
||||||
file_id: string,
|
|
||||||
): Promise<{ peer_primary: Identity; seq: number } | null> {
|
|
||||||
const s = await read();
|
|
||||||
for (const conv of Object.values(s.by_peer)) {
|
|
||||||
for (const m of conv.messages) {
|
|
||||||
if (m.attachment?.file_id === file_id) {
|
|
||||||
return { peer_primary: conv.peer_primary, seq: m.seq };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Verify a hex ed25519 signature over `event_id` against the
|
|
||||||
* ed25519 pubkey embedded in the KEZ primary string. */
|
|
||||||
function verifyAckSig(
|
|
||||||
peer_primary: Identity,
|
|
||||||
event_id: string,
|
|
||||||
sig_hex: string,
|
|
||||||
): boolean {
|
|
||||||
if (!peer_primary.startsWith("ed25519:")) return false;
|
|
||||||
try {
|
|
||||||
const pubkey = hexToBytes(peer_primary.slice("ed25519:".length));
|
|
||||||
const sig = hexToBytes(sig_hex);
|
|
||||||
const msg = new TextEncoder().encode(event_id);
|
|
||||||
return ed25519.verify(sig, msg, pubkey);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,8 +25,8 @@ import { hkdf } from "@noble/hashes/hkdf";
|
|||||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||||
import { canonicalBytes, type Identity } from "./kez.js";
|
import { canonicalBytes, type Identity } from "./kez.js";
|
||||||
|
|
||||||
const HKDF_INFO_V1 = new TextEncoder().encode("kez-chat-msg-v1");
|
const ENVELOPE_VERSION = 1;
|
||||||
const HKDF_INFO_V2 = new TextEncoder().encode("kez-chat-msg-v2");
|
const HKDF_INFO = new TextEncoder().encode("kez-chat-msg-v1");
|
||||||
|
|
||||||
/** What the sender stores in the encrypted blob. */
|
/** What the sender stores in the encrypted blob. */
|
||||||
export interface MessagePlaintext {
|
export interface MessagePlaintext {
|
||||||
@ -39,52 +39,17 @@ export interface MessagePlaintext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** What goes on the wire to POST /v1/messages. */
|
/** What goes on the wire to POST /v1/messages. */
|
||||||
/**
|
export interface SealedEnvelope {
|
||||||
* Two envelope shapes coexist during the v1 → v2 migration window.
|
|
||||||
*
|
|
||||||
* v1: legacy — `from` (KEZ identity) and `to` (handle) in cleartext
|
|
||||||
* NEXT TO the ciphertext. Relays could JSON-parse `event.content`
|
|
||||||
* and build a perfect social graph. Decrypt-only support; we
|
|
||||||
* never emit new v1 envelopes.
|
|
||||||
*
|
|
||||||
* v2: fixed — only an EPHEMERAL x25519 public key (per-message,
|
|
||||||
* discarded right after sealing) and the ciphertext are visible
|
|
||||||
* outside the encrypted blob. `from` (KEZ identity) lives INSIDE
|
|
||||||
* the plaintext, verified post-decrypt via ed25519 sig. Forward
|
|
||||||
* secrecy: a compromised long-term ed25519 seed can no longer
|
|
||||||
* decrypt past captured ciphertexts (the ephemeral private key
|
|
||||||
* was destroyed at send time).
|
|
||||||
*
|
|
||||||
* See kez-chat/TODO.md Day 1 #1.
|
|
||||||
*/
|
|
||||||
export type SealedEnvelope = SealedEnvelopeV1 | SealedEnvelopeV2;
|
|
||||||
|
|
||||||
/** @deprecated v1 — leaks sender + recipient identity to relays. */
|
|
||||||
export interface SealedEnvelopeV1 {
|
|
||||||
v: 1;
|
v: 1;
|
||||||
|
/** Sender's primary — recipient uses this to derive x25519 pub for ECDH. */
|
||||||
from: Identity;
|
from: Identity;
|
||||||
|
/** Recipient handle, e.g. "alice". */
|
||||||
to: string;
|
to: string;
|
||||||
|
/** 12-byte AES-GCM nonce, hex. Also seeds HKDF salt → key. */
|
||||||
nonce: string;
|
nonce: string;
|
||||||
|
/** AES-256-GCM(plaintext_json), hex. */
|
||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
sender_sig: string;
|
/** ed25519 sig over canonical(envelope minus sender_sig), hex. */
|
||||||
}
|
|
||||||
|
|
||||||
export interface SealedEnvelopeV2 {
|
|
||||||
v: 2;
|
|
||||||
/** Sender's EPHEMERAL x25519 pubkey for this single message, 32 bytes
|
|
||||||
* hex. The matching private key was generated at send time and
|
|
||||||
* destroyed right after. Recipient does ECDH against this — not
|
|
||||||
* against the long-term sender identity. */
|
|
||||||
eph_pub: string;
|
|
||||||
/** 12-byte AES-GCM nonce, hex. */
|
|
||||||
nonce: string;
|
|
||||||
/** AES-256-GCM(plaintext_json), hex. AAD binds {v, eph_pub, nonce}
|
|
||||||
* into the auth tag so a relay can't swap nonce/ephemeral without
|
|
||||||
* failing the decrypt. */
|
|
||||||
ciphertext: string;
|
|
||||||
/** ed25519 sig over canonical({v, eph_pub, nonce, ciphertext}). The
|
|
||||||
* sender's KEZ primary is INSIDE the plaintext; recipient looks it
|
|
||||||
* up after decrypt and verifies this sig against it. */
|
|
||||||
sender_sig: string;
|
sender_sig: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,14 +89,11 @@ async function deriveAesKey(
|
|||||||
myPriv: Uint8Array,
|
myPriv: Uint8Array,
|
||||||
theirPub: Uint8Array,
|
theirPub: Uint8Array,
|
||||||
nonce: Uint8Array,
|
nonce: Uint8Array,
|
||||||
info: Uint8Array,
|
|
||||||
): Promise<CryptoKey> {
|
): Promise<CryptoKey> {
|
||||||
const shared = x25519.getSharedSecret(myPriv, theirPub);
|
const shared = x25519.getSharedSecret(myPriv, theirPub);
|
||||||
// HKDF-SHA256 with the nonce as salt — different nonce per message →
|
// HKDF-SHA256 with the nonce as salt — different nonce per message →
|
||||||
// different AES key, even if shared secret stays the same. `info`
|
// different AES key, even if shared secret stays the same.
|
||||||
// is domain-separated per envelope version so v1 and v2 produce
|
const keyBytes = hkdf(sha256, shared, nonce, HKDF_INFO, 32);
|
||||||
// different keys even if they ever share a (shared, nonce) pair.
|
|
||||||
const keyBytes = hkdf(sha256, shared, nonce, info, 32);
|
|
||||||
return crypto.subtle.importKey("raw", asBuffer(keyBytes), "AES-GCM", false, [
|
return crypto.subtle.importKey("raw", asBuffer(keyBytes), "AES-GCM", false, [
|
||||||
"encrypt",
|
"encrypt",
|
||||||
"decrypt",
|
"decrypt",
|
||||||
@ -156,79 +118,41 @@ export async function sealMessage(opts: {
|
|||||||
recipientHandle: string;
|
recipientHandle: string;
|
||||||
recipientPrimary: Identity;
|
recipientPrimary: Identity;
|
||||||
body: string;
|
body: string;
|
||||||
}): Promise<SealedEnvelopeV2> {
|
}): Promise<SealedEnvelope> {
|
||||||
// ─── ephemeral x25519 keypair, used once and destroyed ─────────
|
|
||||||
// The whole point of this dance vs. the legacy v1 path is forward
|
|
||||||
// secrecy: even if `senderSeed` is compromised later, the captured
|
|
||||||
// ciphertext can't be decrypted without `ephPriv`, which was never
|
|
||||||
// persisted and went out of scope as soon as this function returned.
|
|
||||||
const ephPriv = x25519.utils.randomSecretKey();
|
|
||||||
const ephPub = x25519.getPublicKey(ephPriv);
|
|
||||||
|
|
||||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const senderX25519Priv = x25519PrivFromEd25519Seed(opts.senderSeed);
|
||||||
const recipientX25519Pub = x25519PubFromPrimary(opts.recipientPrimary);
|
const recipientX25519Pub = x25519PubFromPrimary(opts.recipientPrimary);
|
||||||
const aesKey = await deriveAesKey(
|
const aesKey = await deriveAesKey(senderX25519Priv, recipientX25519Pub, nonce);
|
||||||
ephPriv,
|
|
||||||
recipientX25519Pub,
|
|
||||||
nonce,
|
|
||||||
HKDF_INFO_V2,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Plaintext carries the sender's KEZ identity — the recipient uses
|
|
||||||
// it to verify `sender_sig` AFTER decrypt. No identity leaks
|
|
||||||
// outside the AES blob.
|
|
||||||
const plaintext: MessagePlaintext = {
|
const plaintext: MessagePlaintext = {
|
||||||
from: opts.senderPrimary,
|
from: opts.senderPrimary,
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
sent_at: new Date().toISOString(),
|
sent_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const ptBytes = new TextEncoder().encode(JSON.stringify(plaintext));
|
const ptBytes = new TextEncoder().encode(JSON.stringify(plaintext));
|
||||||
|
|
||||||
// AAD binds the envelope context (version + ephemeral pub + nonce)
|
|
||||||
// into the AEAD tag. A relay tampering with eph_pub or nonce —
|
|
||||||
// even leaving ciphertext untouched — will trigger an auth-tag
|
|
||||||
// failure on decrypt rather than a silent garble.
|
|
||||||
const aad = canonicalBytes({
|
|
||||||
v: 2,
|
|
||||||
eph_pub: bytesToHex(ephPub),
|
|
||||||
nonce: bytesToHex(nonce),
|
|
||||||
});
|
|
||||||
const ctBytes = new Uint8Array(
|
const ctBytes = new Uint8Array(
|
||||||
await crypto.subtle.encrypt(
|
await crypto.subtle.encrypt(
|
||||||
{
|
{ name: "AES-GCM", iv: asBuffer(nonce) },
|
||||||
name: "AES-GCM",
|
|
||||||
iv: asBuffer(nonce),
|
|
||||||
additionalData: asBuffer(aad),
|
|
||||||
},
|
|
||||||
aesKey,
|
aesKey,
|
||||||
asBuffer(ptBytes),
|
asBuffer(ptBytes),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sign envelope minus the sig. The recipient verifies this AFTER
|
// Sign the envelope-minus-sig so the recipient can confirm the
|
||||||
// they've decrypted and read `plaintext.from` — so the sig binds
|
// sender's primary key authored this ciphertext (and no one swapped
|
||||||
// the sender's KEZ identity to this exact envelope without ever
|
// the nonce or recipient post-hoc).
|
||||||
// exposing the identity to the relay.
|
|
||||||
const partial = {
|
const partial = {
|
||||||
v: 2 as const,
|
v: ENVELOPE_VERSION,
|
||||||
eph_pub: bytesToHex(ephPub),
|
from: opts.senderPrimary,
|
||||||
|
to: opts.recipientHandle,
|
||||||
nonce: bytesToHex(nonce),
|
nonce: bytesToHex(nonce),
|
||||||
ciphertext: bytesToHex(ctBytes),
|
ciphertext: bytesToHex(ctBytes),
|
||||||
};
|
};
|
||||||
const sig = ed25519.sign(canonicalBytes(partial), opts.senderSeed);
|
const sig = ed25519.sign(canonicalBytes(partial), opts.senderSeed);
|
||||||
|
|
||||||
return { ...partial, sender_sig: bytesToHex(sig) };
|
return { ...partial, v: 1, sender_sig: bytesToHex(sig) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How far off the sender's claimed `sent_at` can be from our wall
|
|
||||||
* clock before we refuse to accept it. A relay re-broadcasting an
|
|
||||||
* old captured event months later will fail this check even if
|
|
||||||
* it dodged our nostr-level `markSeen` dedupe. 7 days matches the
|
|
||||||
* relay-side `created_at` clamp so the two layers are consistent.
|
|
||||||
* See TODO.md Day 2 #2. */
|
|
||||||
const MAX_PLAINTEXT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
const MAX_PLAINTEXT_SKEW_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify + decrypt an envelope addressed to me. Returns the plaintext
|
* Verify + decrypt an envelope addressed to me. Returns the plaintext
|
||||||
* fields or throws on any failure (bad sig, primary mismatch, AES tag
|
* fields or throws on any failure (bad sig, primary mismatch, AES tag
|
||||||
@ -240,42 +164,12 @@ export async function openMessage(opts: {
|
|||||||
mySeed: Uint8Array;
|
mySeed: Uint8Array;
|
||||||
}): Promise<MessagePlaintext> {
|
}): Promise<MessagePlaintext> {
|
||||||
const env = opts.envelope;
|
const env = opts.envelope;
|
||||||
let plaintext: MessagePlaintext;
|
if (env.v !== 1) throw new Error(`unsupported envelope version: ${env.v}`);
|
||||||
if (env.v === 1) plaintext = await openMessageV1(env, opts.myHandle, opts.mySeed);
|
if (env.to !== opts.myHandle) {
|
||||||
else if (env.v === 2) plaintext = await openMessageV2(env, opts.mySeed);
|
throw new Error(`envelope addressed to ${env.to}, not ${opts.myHandle}`);
|
||||||
else {
|
|
||||||
throw new Error(
|
|
||||||
`unsupported envelope version: ${(env as { v: number }).v}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// Freshness check — runs on EVERY decrypt regardless of envelope
|
|
||||||
// version, so old v1 envelopes can't be replayed either.
|
|
||||||
const sentAtMs = Date.parse(plaintext.sent_at);
|
|
||||||
if (Number.isFinite(sentAtMs)) {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - sentAtMs > MAX_PLAINTEXT_AGE_MS) {
|
|
||||||
throw new Error("envelope is too old (likely a replay)");
|
|
||||||
}
|
|
||||||
if (sentAtMs - now > MAX_PLAINTEXT_SKEW_MS) {
|
|
||||||
throw new Error("envelope sent_at is in the future");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return plaintext;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// 1. Verify the sender's signature over the unsigned envelope.
|
||||||
* @deprecated v1 — legacy decrypt path kept around for the migration
|
|
||||||
* window. Once all in-flight v1 events on relays have aged out
|
|
||||||
* (mid-2026-06-15), delete this branch + the SealedEnvelopeV1 type.
|
|
||||||
*/
|
|
||||||
async function openMessageV1(
|
|
||||||
env: SealedEnvelopeV1,
|
|
||||||
myHandle: string,
|
|
||||||
mySeed: Uint8Array,
|
|
||||||
): Promise<MessagePlaintext> {
|
|
||||||
if (env.to !== myHandle) {
|
|
||||||
throw new Error(`envelope addressed to ${env.to}, not ${myHandle}`);
|
|
||||||
}
|
|
||||||
const partial = {
|
const partial = {
|
||||||
v: env.v,
|
v: env.v,
|
||||||
from: env.from,
|
from: env.from,
|
||||||
@ -293,15 +187,13 @@ async function openMessageV1(
|
|||||||
senderPubKey,
|
senderPubKey,
|
||||||
);
|
);
|
||||||
if (!sigOk) throw new Error("envelope signature did not verify");
|
if (!sigOk) throw new Error("envelope signature did not verify");
|
||||||
|
|
||||||
|
// 2. ECDH → key → AES-GCM decrypt.
|
||||||
const nonce = hexToBytes(env.nonce);
|
const nonce = hexToBytes(env.nonce);
|
||||||
const myX25519Priv = x25519PrivFromEd25519Seed(mySeed);
|
const myX25519Priv = x25519PrivFromEd25519Seed(opts.mySeed);
|
||||||
const senderX25519Pub = x25519PubFromEd25519Pub(senderPubKey);
|
const senderX25519Pub = x25519PubFromEd25519Pub(senderPubKey);
|
||||||
const aesKey = await deriveAesKey(
|
const aesKey = await deriveAesKey(myX25519Priv, senderX25519Pub, nonce);
|
||||||
myX25519Priv,
|
|
||||||
senderX25519Pub,
|
|
||||||
nonce,
|
|
||||||
HKDF_INFO_V1,
|
|
||||||
);
|
|
||||||
const ptBytes = new Uint8Array(
|
const ptBytes = new Uint8Array(
|
||||||
await crypto.subtle.decrypt(
|
await crypto.subtle.decrypt(
|
||||||
{ name: "AES-GCM", iv: asBuffer(nonce) },
|
{ name: "AES-GCM", iv: asBuffer(nonce) },
|
||||||
@ -311,64 +203,3 @@ async function openMessageV1(
|
|||||||
);
|
);
|
||||||
return JSON.parse(new TextDecoder().decode(ptBytes)) as MessagePlaintext;
|
return JSON.parse(new TextDecoder().decode(ptBytes)) as MessagePlaintext;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openMessageV2(
|
|
||||||
env: SealedEnvelopeV2,
|
|
||||||
mySeed: Uint8Array,
|
|
||||||
): Promise<MessagePlaintext> {
|
|
||||||
// 1. ECDH(my_long_term_x25519, sender_ephemeral_x25519_pub) → key.
|
|
||||||
const ephPub = hexToBytes(env.eph_pub);
|
|
||||||
const nonce = hexToBytes(env.nonce);
|
|
||||||
const myX25519Priv = x25519PrivFromEd25519Seed(mySeed);
|
|
||||||
const aesKey = await deriveAesKey(
|
|
||||||
myX25519Priv,
|
|
||||||
ephPub,
|
|
||||||
nonce,
|
|
||||||
HKDF_INFO_V2,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. AAD must match what the sender used — any tamper of v/eph_pub/
|
|
||||||
// nonce by a relay-in-the-middle fails the auth tag here.
|
|
||||||
const aad = canonicalBytes({
|
|
||||||
v: 2,
|
|
||||||
eph_pub: env.eph_pub,
|
|
||||||
nonce: env.nonce,
|
|
||||||
});
|
|
||||||
const ptBytes = new Uint8Array(
|
|
||||||
await crypto.subtle.decrypt(
|
|
||||||
{
|
|
||||||
name: "AES-GCM",
|
|
||||||
iv: asBuffer(nonce),
|
|
||||||
additionalData: asBuffer(aad),
|
|
||||||
},
|
|
||||||
aesKey,
|
|
||||||
asBuffer(hexToBytes(env.ciphertext)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const plaintext = JSON.parse(
|
|
||||||
new TextDecoder().decode(ptBytes),
|
|
||||||
) as MessagePlaintext;
|
|
||||||
|
|
||||||
// 3. Now we know who claims to have sent this — verify the
|
|
||||||
// envelope sig against THAT key. We deliberately did NOT trust
|
|
||||||
// `plaintext.from` for any earlier step (no early-binding =
|
|
||||||
// no oracle for chosen-from attacks).
|
|
||||||
if (!plaintext.from?.startsWith("ed25519:")) {
|
|
||||||
throw new Error(`unsupported sender primary scheme: ${plaintext.from}`);
|
|
||||||
}
|
|
||||||
const senderPubKey = hexToBytes(plaintext.from.slice("ed25519:".length));
|
|
||||||
const partial = {
|
|
||||||
v: env.v,
|
|
||||||
eph_pub: env.eph_pub,
|
|
||||||
nonce: env.nonce,
|
|
||||||
ciphertext: env.ciphertext,
|
|
||||||
};
|
|
||||||
const sigOk = ed25519.verify(
|
|
||||||
hexToBytes(env.sender_sig),
|
|
||||||
canonicalBytes(partial),
|
|
||||||
senderPubKey,
|
|
||||||
);
|
|
||||||
if (!sigOk) throw new Error("envelope signature did not verify");
|
|
||||||
|
|
||||||
return plaintext;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,183 +0,0 @@
|
|||||||
// File attachment transport for kez-chat over nostr.
|
|
||||||
//
|
|
||||||
// Two paths, decided by raw file size:
|
|
||||||
//
|
|
||||||
// 1. INLINE (raw ≤ INLINE_LIMIT bytes)
|
|
||||||
// - The file is base64-embedded in a single kez-DM event's body
|
|
||||||
// (a JSON object with `type: "kez-file-v1", mode: "inline"`).
|
|
||||||
// - One event, no chunk reconstruction. Same delivery semantics
|
|
||||||
// as a text message. The encryption is the existing v2
|
|
||||||
// envelope — no separate file-level key needed.
|
|
||||||
// - Resized photos and screenshots land here.
|
|
||||||
//
|
|
||||||
// 2. CHUNKED (INLINE_LIMIT < raw ≤ MAX_FILE_BYTES)
|
|
||||||
// - Raw file is split into ~CHUNK_LIMIT-byte chunks. Each chunk
|
|
||||||
// is its own kez-DM event with body
|
|
||||||
// `{type: "kez-file-chunk-v1", file_id, i, n, data}`. A
|
|
||||||
// separate "pointer" event of `type: "kez-file-v1",
|
|
||||||
// mode: "chunked"` carries the file metadata (filename, mime,
|
|
||||||
// total size, and the file_id every chunk shares).
|
|
||||||
// - Each chunk event is broadcast to ALL configured relays
|
|
||||||
// (single signed event → 5-way redundancy). Per-event
|
|
||||||
// delivery is ~99.99% reliable; nothing fancier needed.
|
|
||||||
// - Receiver buffers chunks by file_id, assembles when n/n
|
|
||||||
// have arrived, then renders/saves.
|
|
||||||
//
|
|
||||||
// Caps:
|
|
||||||
// * INLINE_LIMIT = 80 KB raw. Keeps the AES-encrypted +
|
|
||||||
// hex-encoded + JSON-wrapped envelope comfortably under the
|
|
||||||
// ~256 KB content limit most relays enforce.
|
|
||||||
// * CHUNK_LIMIT = 80 KB raw → ~107 KB base64 → ~215 KB envelope.
|
|
||||||
// Same reasoning. Larger chunks would push past stricter relays.
|
|
||||||
// * MAX_FILE_BYTES = 10 MB. Above that → 125+ chunks, real
|
|
||||||
// rate-limit pressure, slow assembly. Out of scope for v0.1.
|
|
||||||
//
|
|
||||||
// Recovery for missing chunks is deferred. Each chunk is published
|
|
||||||
// to all 5 default relays so the per-chunk loss rate is
|
|
||||||
// vanishingly small (≈10⁻⁵). If it does bite, the receiver can
|
|
||||||
// later send a "missing chunks" message — that protocol slot is
|
|
||||||
// left open; we just don't implement the bot yet.
|
|
||||||
|
|
||||||
import { bytesToHex } from "@noble/hashes/utils";
|
|
||||||
|
|
||||||
export const INLINE_LIMIT = 80 * 1024;
|
|
||||||
export const CHUNK_LIMIT = 80 * 1024;
|
|
||||||
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
||||||
|
|
||||||
// ─── body schemas ──────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// All file-related events are regular kez-DM events. The JSON body
|
|
||||||
// uses a discriminating `type` field so the inbox-service can route
|
|
||||||
// to the right handler. Plain text messages (the existing path)
|
|
||||||
// just don't parse as JSON → handled as text.
|
|
||||||
|
|
||||||
/** Inline file: the whole thing fits in one event. */
|
|
||||||
export interface InlineFileBody {
|
|
||||||
type: "kez-file-v1";
|
|
||||||
mode: "inline";
|
|
||||||
filename: string;
|
|
||||||
mime: string;
|
|
||||||
size: number; // raw bytes
|
|
||||||
/** base64 of raw file bytes (no separate file-level encryption —
|
|
||||||
* the envelope crypto already covers this). */
|
|
||||||
data: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pointer event for a chunked file. Sent AFTER all chunks are
|
|
||||||
* published. Carries the file metadata + the shared `file_id`
|
|
||||||
* every chunk uses. */
|
|
||||||
export interface ChunkedFilePointerBody {
|
|
||||||
type: "kez-file-v1";
|
|
||||||
mode: "chunked";
|
|
||||||
filename: string;
|
|
||||||
mime: string;
|
|
||||||
size: number;
|
|
||||||
file_id: string;
|
|
||||||
n: number; // total chunk count
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single chunk of a chunked file. Receiver buffers by
|
|
||||||
* `file_id`; when n/n have arrived, the file is reassembled. */
|
|
||||||
export interface ChunkBody {
|
|
||||||
type: "kez-file-chunk-v1";
|
|
||||||
file_id: string;
|
|
||||||
i: number; // 0-indexed
|
|
||||||
n: number;
|
|
||||||
data: string; // base64 of the raw chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FileMessageBody =
|
|
||||||
| InlineFileBody
|
|
||||||
| ChunkedFilePointerBody
|
|
||||||
| ChunkBody;
|
|
||||||
|
|
||||||
/** Discriminator used by the inbox-service. Tries to JSON-parse the
|
|
||||||
* body; returns the typed shape if it looks like one of ours,
|
|
||||||
* otherwise undefined (= treat as a regular text message). */
|
|
||||||
export function parseFileBody(body: string): FileMessageBody | undefined {
|
|
||||||
// Cheap pre-check — avoid spending a JSON.parse on long plain-text
|
|
||||||
// messages that don't start with `{`.
|
|
||||||
if (!body || body[0] !== "{") return undefined;
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(body);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (!parsed || typeof parsed !== "object") return undefined;
|
|
||||||
const p = parsed as { type?: unknown };
|
|
||||||
if (p.type === "kez-file-v1" || p.type === "kez-file-chunk-v1") {
|
|
||||||
return parsed as FileMessageBody;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── chunking / assembly ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Split a raw byte buffer into chunks of at most CHUNK_LIMIT
|
|
||||||
* bytes. Returns an array of `Uint8Array` views; no copy if the
|
|
||||||
* input is itself a Uint8Array (subarray is a view). */
|
|
||||||
export function chunkifyBytes(bytes: Uint8Array): Uint8Array[] {
|
|
||||||
if (bytes.length === 0) return [];
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
for (let off = 0; off < bytes.length; off += CHUNK_LIMIT) {
|
|
||||||
chunks.push(bytes.subarray(off, Math.min(off + CHUNK_LIMIT, bytes.length)));
|
|
||||||
}
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assemble a full buffer from an ordered array of chunks. Caller
|
|
||||||
* must have already verified that n/n chunks are present and
|
|
||||||
* ordered correctly. */
|
|
||||||
export function assembleChunks(chunks: Uint8Array[]): Uint8Array {
|
|
||||||
const total = chunks.reduce((a, c) => a + c.length, 0);
|
|
||||||
const out = new Uint8Array(total);
|
|
||||||
let off = 0;
|
|
||||||
for (const c of chunks) {
|
|
||||||
out.set(c, off);
|
|
||||||
off += c.length;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── base64 (small, dependency-free) ───────────────────────────────────────
|
|
||||||
|
|
||||||
export function bytesToBase64(bytes: Uint8Array): string {
|
|
||||||
// For large buffers, String.fromCharCode.apply blows the stack.
|
|
||||||
// Use a loop in 32KB windows so it stays cheap on the largest
|
|
||||||
// chunks we ship (~80 KB).
|
|
||||||
const WINDOW = 32 * 1024;
|
|
||||||
let bin = "";
|
|
||||||
for (let i = 0; i < bytes.length; i += WINDOW) {
|
|
||||||
const slice = bytes.subarray(i, Math.min(i + WINDOW, bytes.length));
|
|
||||||
bin += String.fromCharCode(...slice);
|
|
||||||
}
|
|
||||||
return btoa(bin);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function base64ToBytes(b64: string): Uint8Array {
|
|
||||||
const bin = atob(b64);
|
|
||||||
const out = new Uint8Array(bin.length);
|
|
||||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── file_id generator ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Random 128-bit id, hex-encoded. Used to correlate the pointer
|
|
||||||
* event with its N chunk events. */
|
|
||||||
export function newFileId(): string {
|
|
||||||
const buf = new Uint8Array(16);
|
|
||||||
crypto.getRandomValues(buf);
|
|
||||||
return bytesToHex(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wrap raw bytes + mime in a data URL (e.g. for <img src=>). Uses
|
|
||||||
* the streaming-friendly base64 encoder so a 10 MB image doesn't
|
|
||||||
* blow the call stack. */
|
|
||||||
export async function bytesToDataUrl(
|
|
||||||
bytes: Uint8Array,
|
|
||||||
mime: string,
|
|
||||||
): Promise<string> {
|
|
||||||
return `data:${mime};base64,${bytesToBase64(bytes)}`;
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
// Browser-only image helpers — cover-crop a user-picked image to a
|
|
||||||
// square and re-encode it as a small JPEG suitable for use as a
|
|
||||||
// profile picture.
|
|
||||||
//
|
|
||||||
// We target 256×256 because (a) every UI surface that renders the
|
|
||||||
// avatar tops out around 100px CSS, so 256×256 is sharp on 2x
|
|
||||||
// devices, (b) typical phone-camera JPEGs are 3–5 MB; a 256×256
|
|
||||||
// crop at 0.85 quality lands at 10–20 KB which fits comfortably in
|
|
||||||
// IndexedDB AND in a single nostr kind:0 event without bumping into
|
|
||||||
// relay size limits.
|
|
||||||
|
|
||||||
const AVATAR_SIZE = 256;
|
|
||||||
const AVATAR_QUALITY = 0.85;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Take a File from a file input / camera capture, return a data URL
|
|
||||||
* containing a square cover-cropped JPEG. Throws on decode failure
|
|
||||||
* (corrupt image, unsupported format) or canvas errors (rare on
|
|
||||||
* desktop, more common on iOS Safari).
|
|
||||||
*/
|
|
||||||
export async function resizeToAvatarDataUrl(file: File): Promise<string> {
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
throw new Error(`expected an image, got "${file.type || "unknown"}"`);
|
|
||||||
}
|
|
||||||
// 25 MB cap — anything bigger isn't a phone photo, it's almost
|
|
||||||
// certainly someone trying to wedge us. Picker UI ignores .raw and
|
|
||||||
// .heic on most platforms, but the cap is belt-and-suspenders.
|
|
||||||
if (file.size > 25 * 1024 * 1024) {
|
|
||||||
throw new Error(`image is too large (${(file.size / 1024 / 1024).toFixed(1)} MB; max 25 MB)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
let img: HTMLImageElement;
|
|
||||||
try {
|
|
||||||
img = await loadImage(url);
|
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = AVATAR_SIZE;
|
|
||||||
canvas.height = AVATAR_SIZE;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) throw new Error("canvas 2d context unavailable");
|
|
||||||
|
|
||||||
// Cover crop: scale so the SHORTER side fills, then center.
|
|
||||||
const scale = Math.max(AVATAR_SIZE / img.width, AVATAR_SIZE / img.height);
|
|
||||||
const w = img.width * scale;
|
|
||||||
const h = img.height * scale;
|
|
||||||
const x = (AVATAR_SIZE - w) / 2;
|
|
||||||
const y = (AVATAR_SIZE - h) / 2;
|
|
||||||
// Smoothing on by default; explicit set for older WebKit.
|
|
||||||
ctx.imageSmoothingEnabled = true;
|
|
||||||
ctx.imageSmoothingQuality = "high";
|
|
||||||
ctx.drawImage(img, x, y, w, h);
|
|
||||||
|
|
||||||
// `toDataURL` is synchronous but can throw "tainted canvas" if the
|
|
||||||
// user somehow loaded a cross-origin image — shouldn't happen with
|
|
||||||
// File / Camera input but we catch anyway.
|
|
||||||
try {
|
|
||||||
return canvas.toDataURL("image/jpeg", AVATAR_QUALITY);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`encoding failed: ${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = () => reject(new Error("could not decode image"));
|
|
||||||
img.src = src;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rough byte size of a data URL. Cheap — we just look at the
|
|
||||||
* base64 payload length. Used by Settings to surface "your picture
|
|
||||||
* is X KB" so the user knows what they're publishing. */
|
|
||||||
export function dataUrlBytes(dataUrl: string): number {
|
|
||||||
const comma = dataUrl.indexOf(",");
|
|
||||||
if (comma < 0) return 0;
|
|
||||||
const b64 = dataUrl.slice(comma + 1);
|
|
||||||
// 4 base64 chars = 3 bytes (minus padding).
|
|
||||||
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
|
|
||||||
return Math.floor((b64.length * 3) / 4) - pad;
|
|
||||||
}
|
|
||||||
@ -22,12 +22,8 @@
|
|||||||
// • In-page UX (toast / banner) lives in the component layer.
|
// • In-page UX (toast / banner) lives in the component layer.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
attachSigner,
|
|
||||||
decrypt,
|
decrypt,
|
||||||
detachSigner,
|
|
||||||
flushPendingAcks,
|
|
||||||
pollInbox,
|
pollInbox,
|
||||||
sendAck,
|
|
||||||
streamInbox,
|
streamInbox,
|
||||||
type InboxMessage,
|
type InboxMessage,
|
||||||
type StreamHandle,
|
type StreamHandle,
|
||||||
@ -35,28 +31,10 @@ import {
|
|||||||
import { lookupByPrimary } from "./api.js";
|
import { lookupByPrimary } from "./api.js";
|
||||||
import {
|
import {
|
||||||
appendInbound,
|
appendInbound,
|
||||||
appendInboundAttachment,
|
|
||||||
getConversation,
|
getConversation,
|
||||||
getGlobalCursor,
|
getGlobalCursor,
|
||||||
markDeliveredByEventId,
|
|
||||||
patchAttachmentState,
|
|
||||||
} from "./conversations-store.js";
|
} from "./conversations-store.js";
|
||||||
import type { Identity } from "./kez.js";
|
import type { Identity } from "./kez.js";
|
||||||
import { peerProfiles } from "./peer-profile-cell.svelte.js";
|
|
||||||
import {
|
|
||||||
parseFileBody,
|
|
||||||
base64ToBytes,
|
|
||||||
assembleChunks,
|
|
||||||
bytesToDataUrl,
|
|
||||||
} from "./file-transfer.js";
|
|
||||||
import {
|
|
||||||
chunkBufferIsComplete,
|
|
||||||
deleteChunkBuffer,
|
|
||||||
loadChunkBuffer,
|
|
||||||
saveAttachment,
|
|
||||||
saveChunkBuffer,
|
|
||||||
type ChunkBufferEntry,
|
|
||||||
} from "./attachment-store.js";
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 30_000;
|
const POLL_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
@ -94,26 +72,15 @@ class InboxService {
|
|||||||
// (which the user has either seen on this device or another).
|
// (which the user has either seen on this device or another).
|
||||||
this.#notifiedThroughSeq = await getGlobalCursor();
|
this.#notifiedThroughSeq = await getGlobalCursor();
|
||||||
|
|
||||||
// Hand the seed to the relay pool BEFORE we open a subscription
|
|
||||||
// so NIP-42 AUTH challenges from relays that gate DM kinds get
|
|
||||||
// signed transparently. Without this, AUTH-required relays
|
|
||||||
// silently deliver nothing. TODO.md Day 3 Option B #11.
|
|
||||||
attachSigner(seed);
|
|
||||||
|
|
||||||
this.#stream = streamInbox({
|
this.#stream = streamInbox({
|
||||||
handle,
|
handle,
|
||||||
seed,
|
seed,
|
||||||
onMessage: (m) => void this.#ingest(m),
|
onMessage: (m) => void this.#ingest(m),
|
||||||
onAck: (eventId, sigHex) => void this.#ingestAck(eventId, sigHex),
|
|
||||||
onStatus: (s) => (this.status = s),
|
onStatus: (s) => (this.status = s),
|
||||||
});
|
});
|
||||||
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
|
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
|
||||||
// Eager first poll so we catch up anything queued before this session.
|
// Eager first poll so we catch up anything queued before this session.
|
||||||
void this.#heartbeat();
|
void this.#heartbeat();
|
||||||
// Retry any acks the last session couldn't publish — turns "I
|
|
||||||
// never saw your message arrive" into "delivered ✓◯" on the
|
|
||||||
// SENDER's screen as soon as we come back online.
|
|
||||||
void flushPendingAcks(seed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop everything. Called on lock + on tab close. */
|
/** Stop everything. Called on lock + on tab close. */
|
||||||
@ -125,9 +92,6 @@ class InboxService {
|
|||||||
this.#handle = null;
|
this.#handle = null;
|
||||||
this.#seed = null;
|
this.#seed = null;
|
||||||
this.status = "off";
|
this.status = "off";
|
||||||
// Drop the cached signer so a future relay reconnect can't
|
|
||||||
// accidentally answer an AUTH challenge with a stale seed.
|
|
||||||
detachSigner();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Messages page calls this when the user lands on /messages. */
|
/** Messages page calls this when the user lands on /messages. */
|
||||||
@ -175,75 +139,13 @@ class InboxService {
|
|||||||
// Unknown to this server (cross-server v0.2). Show truncated key later.
|
// Unknown to this server (cross-server v0.2). Show truncated key later.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── file attachment branch ─────────────────────────────────
|
|
||||||
// The body might be a JSON payload: inline file, chunked-file
|
|
||||||
// pointer, or a chunk of a chunked file. parseFileBody returns
|
|
||||||
// undefined for plain text — that falls through to the
|
|
||||||
// regular appendInbound path below.
|
|
||||||
const fileBody = parseFileBody(pt.body);
|
|
||||||
if (fileBody) {
|
|
||||||
await this.#ingestFileBody({
|
|
||||||
fileBody,
|
|
||||||
peer_primary: pt.from as Identity,
|
|
||||||
peer_handle: displayName,
|
|
||||||
seq: m.seq,
|
|
||||||
ts: pt.sent_at,
|
|
||||||
peer_nostr_pubkey: m.sender_nostr_pubkey,
|
|
||||||
via_relay: m.via_relay,
|
|
||||||
});
|
|
||||||
// We still want the rest of the post-ingest hooks (ack,
|
|
||||||
// peer-profile fetch, badge bump) to run for inline files
|
|
||||||
// and pointers, but NOT for chunks (which are user-invisible
|
|
||||||
// plumbing). Bail early on chunks.
|
|
||||||
if (fileBody.type === "kez-file-chunk-v1") return;
|
|
||||||
} else {
|
|
||||||
await appendInbound({
|
await appendInbound({
|
||||||
peer_primary: pt.from as Identity,
|
peer_primary: pt.from as Identity,
|
||||||
peer_handle: displayName,
|
peer_handle: displayName,
|
||||||
seq: m.seq,
|
seq: m.seq,
|
||||||
body: pt.body,
|
body: pt.body,
|
||||||
ts: pt.sent_at,
|
ts: pt.sent_at,
|
||||||
peer_nostr_pubkey: m.sender_nostr_pubkey,
|
|
||||||
via_relay: m.via_relay,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// First time we've seen this peer's nostr pubkey? Kick off a
|
|
||||||
// profile fetch so their avatar lights up the moment we render.
|
|
||||||
// Cache-aware: no-ops if we already have a fresh entry.
|
|
||||||
if (m.sender_nostr_pubkey && this.#handle && this.#seed) {
|
|
||||||
void peerProfiles.refresh({
|
|
||||||
peer_primary: pt.from as Identity,
|
|
||||||
peer_nostr_pubkey: m.sender_nostr_pubkey,
|
|
||||||
my_handle: this.#handle,
|
|
||||||
my_seed: this.#seed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire a delivery ack back to the sender. We have a successful
|
|
||||||
// decrypt + persistence, so from the sender's perspective the
|
|
||||||
// message has "arrived". Best-effort and async — never block
|
|
||||||
// the local ingest path on it; if it fails, the sender just
|
|
||||||
// keeps seeing "sent" (one check).
|
|
||||||
if (m.event_id && this.#seed) {
|
|
||||||
void sendAck({
|
|
||||||
ackingSeed: this.#seed,
|
|
||||||
originalSenderPrimary: pt.from as Identity,
|
|
||||||
ackedEventId: m.event_id,
|
|
||||||
// Optional but lets nostr clients route the ack via NIP-25
|
|
||||||
// conventions (recipient lands in the original sender's
|
|
||||||
// "mentions" feed). We learned the sender's nostr pubkey
|
|
||||||
// from the DM event itself.
|
|
||||||
originalSenderNostrPubkey: m.sender_nostr_pubkey,
|
|
||||||
// Ack over the same relay the DM arrived on — likely the
|
|
||||||
// fastest round-trip path.
|
|
||||||
preferRelay: m.via_relay,
|
|
||||||
}).catch((err) => {
|
|
||||||
console.warn(`inbox-service: ack failed for ${m.event_id}`, err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only fire UI side-effects (badge + system notification) for
|
// Only fire UI side-effects (badge + system notification) for
|
||||||
// messages we haven't already notified about. This guards both:
|
// messages we haven't already notified about. This guards both:
|
||||||
// • SSE+poll race: same seq comes in twice via different paths
|
// • SSE+poll race: same seq comes in twice via different paths
|
||||||
@ -263,192 +165,6 @@ class InboxService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle an inbound ack event — flip the matching outbound bubble
|
|
||||||
* from "sent" to "delivered" and notify the UI to repaint. */
|
|
||||||
/**
|
|
||||||
* Route a parsed file-attachment body to the right path. Three
|
|
||||||
* cases:
|
|
||||||
*
|
|
||||||
* 1. Inline file → create a "ready" attachment row, save the
|
|
||||||
* bytes (decoded from base64) to the local attachment store.
|
|
||||||
* 2. Chunked file pointer → create a "pending" attachment row,
|
|
||||||
* stash the destination on the chunk-buffer entry. If chunks
|
|
||||||
* already arrived (the pointer raced), trigger finalize.
|
|
||||||
* 3. Chunk → buffer it under the file_id; if all n/n now
|
|
||||||
* present AND the pointer has registered a destination,
|
|
||||||
* finalize.
|
|
||||||
*
|
|
||||||
* "Finalize" = concatenate chunk bytes, write to the local
|
|
||||||
* attachment store, flip the message's attachment.state to "ready".
|
|
||||||
*/
|
|
||||||
async #ingestFileBody(opts: {
|
|
||||||
fileBody: ReturnType<typeof parseFileBody>;
|
|
||||||
peer_primary: Identity;
|
|
||||||
peer_handle: string;
|
|
||||||
seq: number;
|
|
||||||
ts: string;
|
|
||||||
peer_nostr_pubkey?: string;
|
|
||||||
via_relay?: string;
|
|
||||||
}) {
|
|
||||||
const f = opts.fileBody!;
|
|
||||||
if (f.type === "kez-file-v1" && f.mode === "inline") {
|
|
||||||
// ─── inline ─────────────────────────────────────────────────
|
|
||||||
const bytes = base64ToBytes(f.data);
|
|
||||||
const dataUrl = await bytesToDataUrl(bytes, f.mime);
|
|
||||||
await saveAttachment(opts.peer_primary, opts.seq, {
|
|
||||||
filename: f.filename,
|
|
||||||
mime: f.mime,
|
|
||||||
data_url: dataUrl,
|
|
||||||
size: bytes.length,
|
|
||||||
});
|
|
||||||
await appendInboundAttachment({
|
|
||||||
peer_primary: opts.peer_primary,
|
|
||||||
peer_handle: opts.peer_handle,
|
|
||||||
seq: opts.seq,
|
|
||||||
ts: opts.ts,
|
|
||||||
body: `📎 ${f.filename}`,
|
|
||||||
peer_nostr_pubkey: opts.peer_nostr_pubkey,
|
|
||||||
via_relay: opts.via_relay,
|
|
||||||
attachment: {
|
|
||||||
filename: f.filename,
|
|
||||||
mime: f.mime,
|
|
||||||
size: f.size,
|
|
||||||
state: "ready",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (f.type === "kez-file-v1" && f.mode === "chunked") {
|
|
||||||
// ─── chunked pointer ────────────────────────────────────────
|
|
||||||
// Create or update the chunk-buffer entry with the destination
|
|
||||||
// (= where the assembled file should land). Chunks may have
|
|
||||||
// arrived before the pointer (relay order isn't guaranteed)
|
|
||||||
// OR may arrive after. Either ordering works.
|
|
||||||
let buf =
|
|
||||||
(await loadChunkBuffer(f.file_id)) ??
|
|
||||||
({
|
|
||||||
n: f.n,
|
|
||||||
received: {},
|
|
||||||
started_at: new Date().toISOString(),
|
|
||||||
} as ChunkBufferEntry);
|
|
||||||
buf.n = f.n;
|
|
||||||
buf.destination = {
|
|
||||||
peer_primary: opts.peer_primary,
|
|
||||||
seq: opts.seq,
|
|
||||||
filename: f.filename,
|
|
||||||
mime: f.mime,
|
|
||||||
size: f.size,
|
|
||||||
};
|
|
||||||
await saveChunkBuffer(f.file_id, buf);
|
|
||||||
|
|
||||||
// Show a "pending" attachment in chat immediately. The user
|
|
||||||
// sees "Receiving 12/47" until n/n have arrived.
|
|
||||||
await appendInboundAttachment({
|
|
||||||
peer_primary: opts.peer_primary,
|
|
||||||
peer_handle: opts.peer_handle,
|
|
||||||
seq: opts.seq,
|
|
||||||
ts: opts.ts,
|
|
||||||
body: `📎 ${f.filename}`,
|
|
||||||
peer_nostr_pubkey: opts.peer_nostr_pubkey,
|
|
||||||
via_relay: opts.via_relay,
|
|
||||||
attachment: {
|
|
||||||
filename: f.filename,
|
|
||||||
mime: f.mime,
|
|
||||||
size: f.size,
|
|
||||||
state: "pending",
|
|
||||||
file_id: f.file_id,
|
|
||||||
received_chunks: Object.keys(buf.received).length,
|
|
||||||
total_chunks: f.n,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (chunkBufferIsComplete(buf)) {
|
|
||||||
await this.#finalizeChunkedFile(f.file_id);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (f.type === "kez-file-chunk-v1") {
|
|
||||||
// ─── chunk ──────────────────────────────────────────────────
|
|
||||||
let buf =
|
|
||||||
(await loadChunkBuffer(f.file_id)) ??
|
|
||||||
({
|
|
||||||
n: f.n,
|
|
||||||
received: {},
|
|
||||||
started_at: new Date().toISOString(),
|
|
||||||
} as ChunkBufferEntry);
|
|
||||||
buf.n = f.n; // pointer might disagree on n, prefer the chunk's report
|
|
||||||
buf.received[f.i] = base64ToBytes(f.data);
|
|
||||||
await saveChunkBuffer(f.file_id, buf);
|
|
||||||
|
|
||||||
// If the pointer has registered a destination, mirror the
|
|
||||||
// received count onto the attachment row so the "12/47" hint
|
|
||||||
// ticks up live.
|
|
||||||
if (buf.destination) {
|
|
||||||
await patchAttachmentState(
|
|
||||||
buf.destination.peer_primary,
|
|
||||||
buf.destination.seq,
|
|
||||||
{
|
|
||||||
received_chunks: Object.keys(buf.received).length,
|
|
||||||
total_chunks: buf.n,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (chunkBufferIsComplete(buf)) {
|
|
||||||
await this.#finalizeChunkedFile(f.file_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No pointer yet — silently buffer. Finalize will fire when
|
|
||||||
// the pointer registers a destination (next branch).
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All chunks present + pointer destination known → assemble bytes,
|
|
||||||
* save to the attachment store, flip the message to "ready",
|
|
||||||
* delete the chunk buffer. */
|
|
||||||
async #finalizeChunkedFile(file_id: string) {
|
|
||||||
const buf = await loadChunkBuffer(file_id);
|
|
||||||
if (!buf || !buf.destination || !chunkBufferIsComplete(buf)) return;
|
|
||||||
try {
|
|
||||||
// Reassemble in order.
|
|
||||||
const ordered: Uint8Array[] = [];
|
|
||||||
for (let i = 0; i < buf.n; i++) ordered.push(buf.received[i]);
|
|
||||||
const bytes = assembleChunks(ordered);
|
|
||||||
const dataUrl = await bytesToDataUrl(bytes, buf.destination.mime);
|
|
||||||
await saveAttachment(buf.destination.peer_primary, buf.destination.seq, {
|
|
||||||
filename: buf.destination.filename,
|
|
||||||
mime: buf.destination.mime,
|
|
||||||
data_url: dataUrl,
|
|
||||||
size: bytes.length,
|
|
||||||
});
|
|
||||||
await patchAttachmentState(
|
|
||||||
buf.destination.peer_primary,
|
|
||||||
buf.destination.seq,
|
|
||||||
{ state: "ready" },
|
|
||||||
);
|
|
||||||
await deleteChunkBuffer(file_id);
|
|
||||||
// Tell the UI to repaint so the image appears.
|
|
||||||
this.#notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`inbox-service: finalize ${file_id} failed`, e);
|
|
||||||
await patchAttachmentState(
|
|
||||||
buf.destination.peer_primary,
|
|
||||||
buf.destination.seq,
|
|
||||||
{ state: "failed" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #ingestAck(acked_event_id: string, ack_sig_hex?: string) {
|
|
||||||
try {
|
|
||||||
const changed = await markDeliveredByEventId(acked_event_id, ack_sig_hex);
|
|
||||||
if (changed) this.#notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`inbox-service: ack ingest failed for ${acked_event_id}`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#notifyListeners() {
|
#notifyListeners() {
|
||||||
for (const fn of this.#listeners) {
|
for (const fn of this.#listeners) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -18,20 +18,6 @@ export interface InboxMessage {
|
|||||||
seq: number;
|
seq: number;
|
||||||
envelope: SealedEnvelope;
|
envelope: SealedEnvelope;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
/** Transport-specific id used to correlate delivery acks. Filled in
|
|
||||||
* by the nostr transport (= nostr event id); the server transport
|
|
||||||
* leaves it absent — server-side acks aren't implemented yet. */
|
|
||||||
event_id?: string;
|
|
||||||
/** Sender's NOSTR pubkey (secp256k1 hex) — used to fetch their
|
|
||||||
* kind:0 profile event later for peer avatar resolution. Only the
|
|
||||||
* nostr transport populates this; server transport leaves it
|
|
||||||
* absent. */
|
|
||||||
sender_nostr_pubkey?: string;
|
|
||||||
/** Relay we received this event from FIRST. Nostr transport only;
|
|
||||||
* server transport leaves it absent. The recipient stores it on
|
|
||||||
* the conversation row and biases the reply publish toward the
|
|
||||||
* same relay — usually the geographically/network-wise fastest. */
|
|
||||||
via_relay?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Canonical bytes the inbox poller signs. Mirrors the rust constant. */
|
/** Canonical bytes the inbox poller signs. Mirrors the rust constant. */
|
||||||
@ -86,28 +72,14 @@ export async function sendMessage(opts: {
|
|||||||
senderPrimary: Identity;
|
senderPrimary: Identity;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
body: string;
|
body: string;
|
||||||
/** Accepted on the server transport for shape-parity but ignored —
|
}): Promise<{ seq: number }> {
|
||||||
* the server endpoint is a single host, no "preferred relay" concept. */
|
|
||||||
preferRelay?: string;
|
|
||||||
/** Skip the /v1/u/:handle lookup if the primary's already cached.
|
|
||||||
* Server transport still needs the chat-server for the actual
|
|
||||||
* POST /v1/messages — so it can't help when the server is down —
|
|
||||||
* but skipping the extra round-trip is faster. */
|
|
||||||
recipientPrimary?: Identity;
|
|
||||||
}): Promise<{ seq: number; event_id: string; accepted_by?: string }> {
|
|
||||||
const recipientHandle = opts.recipient.split("@")[0];
|
const recipientHandle = opts.recipient.split("@")[0];
|
||||||
let resolvedPrimary: Identity;
|
|
||||||
if (opts.recipientPrimary) {
|
|
||||||
resolvedPrimary = opts.recipientPrimary;
|
|
||||||
} else {
|
|
||||||
const record = await lookup(recipientHandle); // throws on 404
|
const record = await lookup(recipientHandle); // throws on 404
|
||||||
resolvedPrimary = record.primary as Identity;
|
|
||||||
}
|
|
||||||
const envelope = await sealMessage({
|
const envelope = await sealMessage({
|
||||||
senderSeed: opts.senderSeed,
|
senderSeed: opts.senderSeed,
|
||||||
senderPrimary: opts.senderPrimary,
|
senderPrimary: opts.senderPrimary,
|
||||||
recipientHandle,
|
recipientHandle,
|
||||||
recipientPrimary: resolvedPrimary,
|
recipientPrimary: record.primary as Identity,
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
});
|
});
|
||||||
const resp = await fetch(`${base()}/v1/messages`, {
|
const resp = await fetch(`${base()}/v1/messages`, {
|
||||||
@ -118,12 +90,7 @@ export async function sendMessage(opts: {
|
|||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error(`POST /v1/messages → ${resp.status}: ${await resp.text()}`);
|
throw new Error(`POST /v1/messages → ${resp.status}: ${await resp.text()}`);
|
||||||
}
|
}
|
||||||
const body = (await resp.json()) as { seq: number };
|
return (await resp.json()) as { seq: number };
|
||||||
// Server transport has no nostr-style event id. Use the server seq
|
|
||||||
// as the correlator — it's also unique-per-recipient + monotonic.
|
|
||||||
// Server-side acks aren't wired up yet so this is unused today;
|
|
||||||
// shape-compatibility with the nostr return is all that matters.
|
|
||||||
return { seq: body.seq, event_id: `server:${body.seq}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -199,10 +166,6 @@ export function streamInbox(opts: {
|
|||||||
handle: string;
|
handle: string;
|
||||||
seed: Uint8Array;
|
seed: Uint8Array;
|
||||||
onMessage: (msg: InboxMessage) => void;
|
onMessage: (msg: InboxMessage) => void;
|
||||||
/** Stub on the server transport — there's no server-side ack
|
|
||||||
* mechanism yet, so this callback never fires. Kept in the shape
|
|
||||||
* for interface-compat with nostr-transport. */
|
|
||||||
onAck?: (acked_event_id: string, ack_sig_hex?: string) => void;
|
|
||||||
onStatus?: (status: "connecting" | "live" | "reconnecting") => void;
|
onStatus?: (status: "connecting" | "live" | "reconnecting") => void;
|
||||||
}): StreamHandle {
|
}): StreamHandle {
|
||||||
let es: EventSource | null = null;
|
let es: EventSource | null = null;
|
||||||
@ -215,10 +178,7 @@ export function streamInbox(opts: {
|
|||||||
const auth = streamAuthQueryParam({ handle: opts.handle, seed: opts.seed });
|
const auth = streamAuthQueryParam({ handle: opts.handle, seed: opts.seed });
|
||||||
const url = `${base()}/v1/inbox/${opts.handle}/stream?auth=${encodeURIComponent(auth)}`;
|
const url = `${base()}/v1/inbox/${opts.handle}/stream?auth=${encodeURIComponent(auth)}`;
|
||||||
es = new EventSource(url);
|
es = new EventSource(url);
|
||||||
es.addEventListener("open", () => {
|
es.addEventListener("open", () => opts.onStatus?.("live"));
|
||||||
_setServerConnected(true);
|
|
||||||
opts.onStatus?.("live");
|
|
||||||
});
|
|
||||||
es.addEventListener("message", (ev) => {
|
es.addEventListener("message", (ev) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(ev.data) as InboxMessage;
|
const msg = JSON.parse(ev.data) as InboxMessage;
|
||||||
@ -232,7 +192,6 @@ export function streamInbox(opts: {
|
|||||||
// (avoid hot-loop if the server is rejecting). EventSource also
|
// (avoid hot-loop if the server is rejecting). EventSource also
|
||||||
// auto-reconnects but with the same (now possibly stale) URL.
|
// auto-reconnects but with the same (now possibly stale) URL.
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
_setServerConnected(false);
|
|
||||||
es?.close();
|
es?.close();
|
||||||
es = null;
|
es = null;
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
@ -245,7 +204,6 @@ export function streamInbox(opts: {
|
|||||||
return {
|
return {
|
||||||
close() {
|
close() {
|
||||||
closed = true;
|
closed = true;
|
||||||
_setServerConnected(false);
|
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
es?.close();
|
es?.close();
|
||||||
},
|
},
|
||||||
@ -256,92 +214,3 @@ export function streamInbox(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type { SealedEnvelope, MessagePlaintext };
|
export type { SealedEnvelope, MessagePlaintext };
|
||||||
|
|
||||||
/**
|
|
||||||
* No-op stub on the server transport — there's no server-side
|
|
||||||
* ack/receipt protocol yet. Present so transport.ts can re-export
|
|
||||||
* a uniform surface and callers don't have to branch.
|
|
||||||
*/
|
|
||||||
export async function sendAck(_opts: {
|
|
||||||
ackingSeed: Uint8Array;
|
|
||||||
originalSenderPrimary: Identity;
|
|
||||||
ackedEventId: string;
|
|
||||||
originalSenderNostrPubkey?: string;
|
|
||||||
preferRelay?: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
/* server transport: receipts not implemented in v0.1 */
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function flushPendingAcks(_seed: Uint8Array): Promise<void> {
|
|
||||||
/* server transport: nothing to flush */
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Server transport doesn't talk to nostr relays, so there's no AUTH
|
|
||||||
* challenge to handle. Stub kept for facade-parity with nostr. */
|
|
||||||
export function attachSigner(_seed: Uint8Array): void {
|
|
||||||
/* server transport: no NIP-42 to answer */
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendFile(_opts: {
|
|
||||||
senderHandle: string;
|
|
||||||
senderSeed: Uint8Array;
|
|
||||||
senderPrimary: Identity;
|
|
||||||
recipientHandle: string;
|
|
||||||
recipientPrimary: Identity;
|
|
||||||
filename: string;
|
|
||||||
mime: string;
|
|
||||||
raw: Uint8Array;
|
|
||||||
preferRelay?: string;
|
|
||||||
progress?: (done: number, total: number) => void;
|
|
||||||
}): Promise<{
|
|
||||||
pointer_event_id: string;
|
|
||||||
accepted_by?: string;
|
|
||||||
chunk_event_ids: string[];
|
|
||||||
}> {
|
|
||||||
/* server transport: file send not implemented (it would need a new
|
|
||||||
POST endpoint + storage). For now, throw a clear error so the
|
|
||||||
UI can surface a "switch to nostr transport for attachments"
|
|
||||||
hint. */
|
|
||||||
throw new Error(
|
|
||||||
"file send is only supported on the nostr transport (VITE_TRANSPORT=nostr)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detachSigner(): void {
|
|
||||||
/* server transport: no NIP-42 to answer */
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAcksForEventIds(
|
|
||||||
_eventIds: string[],
|
|
||||||
): Promise<Map<string, string | undefined>> {
|
|
||||||
/* server transport: acks aren't published, nothing to find */
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Connection-state surface — kept symmetrical with nostr-transport so the
|
|
||||||
// UI (the "● live (N)" indicator + popover) doesn't have to branch on the
|
|
||||||
// active transport. The server transport has exactly one "relay" — the
|
|
||||||
// chat-server URL — and its connectedness is whatever the live SSE
|
|
||||||
// stream's readyState reports, tracked from inbox-service.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface RelayStatus {
|
|
||||||
url: string;
|
|
||||||
connected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SERVER_URL =
|
|
||||||
(import.meta.env.VITE_API_BASE as string | undefined) || window.location.origin;
|
|
||||||
|
|
||||||
/** Last-known SSE liveness. Bumped from streamInbox below so a poll of
|
|
||||||
* getRelayStatuses doesn't have to peek into EventSource internals. */
|
|
||||||
let _serverConnected = false;
|
|
||||||
|
|
||||||
export function getRelayStatuses(): RelayStatus[] {
|
|
||||||
return [{ url: SERVER_URL, connected: _serverConnected }];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function _setServerConnected(v: boolean): void {
|
|
||||||
_serverConnected = v;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -24,14 +24,8 @@ import type { Identity } from "./kez.js";
|
|||||||
/** Regular event kind (1000–9999 → relays persist it, which the inbox needs). */
|
/** Regular event kind (1000–9999 → relays persist it, which the inbox needs). */
|
||||||
export const KEZ_DM_KIND = 4242;
|
export const KEZ_DM_KIND = 4242;
|
||||||
|
|
||||||
/** Tag name carrying the recipient address. `#q` filter on the relay
|
/** Tag name carrying the recipient address. `#h` filter on the relay side. */
|
||||||
* side. Used to be `h`, but NIP-29 (Simple Groups) treats `h` as a
|
export const ADDR_TAG = "h";
|
||||||
* group id — a NIP-29-aware relay would try to interpret our routing
|
|
||||||
* tag as a group join and silently drop our events. `q` is a
|
|
||||||
* less-claimed single letter (still indexable per NIP-01).
|
|
||||||
*
|
|
||||||
* See kez-chat/TODO.md "Day 1 #5" for the migration plan. */
|
|
||||||
export const ADDR_TAG = "q";
|
|
||||||
|
|
||||||
const SIGNKEY_SALT = new TextEncoder().encode("kez-chat:nostr-signkey");
|
const SIGNKEY_SALT = new TextEncoder().encode("kez-chat:nostr-signkey");
|
||||||
const SIGNKEY_INFO = new TextEncoder().encode("v1");
|
const SIGNKEY_INFO = new TextEncoder().encode("v1");
|
||||||
|
|||||||
@ -15,20 +15,8 @@
|
|||||||
// secp256k1 key derived from our ed25519 seed (see nostr-id.ts) purely so
|
// secp256k1 key derived from our ed25519 seed (see nostr-id.ts) purely so
|
||||||
// relays accept them — that key is never surfaced to the user.
|
// relays accept them — that key is never surfaced to the user.
|
||||||
|
|
||||||
import { ed25519 } from "@noble/curves/ed25519";
|
|
||||||
import { bytesToHex } from "@noble/hashes/utils";
|
|
||||||
import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools";
|
import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools";
|
||||||
import { sealMessage, type SealedEnvelope } from "./crypto.js";
|
import { sealMessage, type SealedEnvelope } from "./crypto.js";
|
||||||
import {
|
|
||||||
bytesToBase64,
|
|
||||||
chunkifyBytes,
|
|
||||||
newFileId,
|
|
||||||
INLINE_LIMIT,
|
|
||||||
MAX_FILE_BYTES,
|
|
||||||
type ChunkBody,
|
|
||||||
type ChunkedFilePointerBody,
|
|
||||||
type InlineFileBody,
|
|
||||||
} from "./file-transfer.js";
|
|
||||||
import { lookup } from "./api.js";
|
import { lookup } from "./api.js";
|
||||||
import { identityFromSeed, type Identity } from "./kez.js";
|
import { identityFromSeed, type Identity } from "./kez.js";
|
||||||
import { nostrSecretFromSeed, addrFromPrimary, KEZ_DM_KIND, ADDR_TAG } from "./nostr-id.js";
|
import { nostrSecretFromSeed, addrFromPrimary, KEZ_DM_KIND, ADDR_TAG } from "./nostr-id.js";
|
||||||
@ -38,224 +26,41 @@ import { decrypt, type InboxMessage, type StreamHandle } from "./messages.js";
|
|||||||
export { decrypt };
|
export { decrypt };
|
||||||
export type { InboxMessage, StreamHandle };
|
export type { InboxMessage, StreamHandle };
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv). */
|
||||||
// Connection-state surface — drives the "● live (N)" indicator and its
|
|
||||||
// "show me which relays" popover in the chat UI.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface RelayStatus {
|
|
||||||
url: string;
|
|
||||||
/** WebSocket is in OPEN state right now. */
|
|
||||||
connected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a relay URL the same way nostr-tools does internally
|
|
||||||
* (see abstract-pool.js normalizeURL): coerce http→ws, strip default
|
|
||||||
* ports, trim duplicate + trailing slashes from the path. We need this
|
|
||||||
* because the pool's internal Map is keyed by the normalized form, so
|
|
||||||
* a lookup by the raw configured URL silently misses ("wss://nos.lol"
|
|
||||||
* vs the stored "wss://nos.lol/").
|
|
||||||
*/
|
|
||||||
function normalizeRelayUrl(raw: string): string {
|
|
||||||
try {
|
|
||||||
let s = raw;
|
|
||||||
if (!s.includes("://")) s = "wss://" + s;
|
|
||||||
const u = new URL(s);
|
|
||||||
if (u.protocol === "http:") u.protocol = "ws:";
|
|
||||||
else if (u.protocol === "https:") u.protocol = "wss:";
|
|
||||||
u.pathname = u.pathname.replace(/\/+/g, "/");
|
|
||||||
if (u.pathname.endsWith("/")) u.pathname = u.pathname.slice(0, -1);
|
|
||||||
if (
|
|
||||||
(u.port === "80" && u.protocol === "ws:") ||
|
|
||||||
(u.port === "443" && u.protocol === "wss:")
|
|
||||||
) {
|
|
||||||
u.port = "";
|
|
||||||
}
|
|
||||||
return u.toString();
|
|
||||||
} catch {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Snapshot of every configured relay + whether its socket is currently
|
|
||||||
* open. Cheap to call — just reads from SimplePool's internal map. */
|
|
||||||
export function getRelayStatuses(): RelayStatus[] {
|
|
||||||
// listConnectionStatus only returns URLs the pool has touched, so seed
|
|
||||||
// the map with our configured set first to show unconnected relays too.
|
|
||||||
const live = _pool ? _pool.listConnectionStatus() : new Map<string, boolean>();
|
|
||||||
return RELAYS.map((url) => {
|
|
||||||
const norm = normalizeRelayUrl(url);
|
|
||||||
// Try the normalized form first (canonical key the pool uses); fall
|
|
||||||
// back to the raw form just in case a future nostr-tools changes the
|
|
||||||
// normalization rules.
|
|
||||||
const connected = live.get(norm) === true || live.get(url) === true;
|
|
||||||
return { url, connected };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv).
|
|
||||||
* Default set: 3 long-running general-purpose relays (damus, nos, primal)
|
|
||||||
* + 2 popular extras (snort.social, nostr.wine) — chosen for redundancy
|
|
||||||
* and geographic diversity. If any single relay is slow/down our publish
|
|
||||||
* still succeeds as long as one accepts. */
|
|
||||||
const RELAYS: string[] = (
|
const RELAYS: string[] = (
|
||||||
import.meta.env.VITE_NOSTR_RELAYS ??
|
import.meta.env.VITE_NOSTR_RELAYS ??
|
||||||
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
|
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net"
|
||||||
)
|
)
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((r: string) => r.trim())
|
.map((r: string) => r.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
/** One pool for the whole session — relay connections are reused.
|
/** One pool for the whole session — relay connections are reused. */
|
||||||
* `attachSigner(seed)` recreates the pool with `automaticallyAuth`
|
|
||||||
* wired up so NIP-42 AUTH challenges (damus.io, some other private
|
|
||||||
* relays) get signed transparently. Without this, DM subscriptions
|
|
||||||
* on AUTH-required relays silently get nothing. TODO.md Day 3
|
|
||||||
* Option B #11. */
|
|
||||||
let _pool: SimplePool | null = null;
|
let _pool: SimplePool | null = null;
|
||||||
let _authSeed: Uint8Array | null = null;
|
|
||||||
function pool(): SimplePool {
|
function pool(): SimplePool {
|
||||||
if (!_pool) _pool = buildPool();
|
if (!_pool) _pool = new SimplePool();
|
||||||
return _pool;
|
return _pool;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPool(): SimplePool {
|
|
||||||
const seed = _authSeed;
|
|
||||||
const p = new SimplePool();
|
|
||||||
// Enable per-event relay tracking so we can:
|
|
||||||
// • Tell the user WHICH relay accepted their message (UI hint)
|
|
||||||
// • Prefer the same relay for replies — likely the same relay
|
|
||||||
// was geographically/network-wise the fastest path; sticking
|
|
||||||
// with it for the reply usually shaves real latency.
|
|
||||||
// See `pool.seenOn.get(eventId)` for the read path.
|
|
||||||
p.trackRelays = true;
|
|
||||||
if (seed) {
|
|
||||||
// SimplePool's TS constructor only exposes a subset of options;
|
|
||||||
// the underlying AbstractSimplePool has `automaticallyAuth` as a
|
|
||||||
// public property. Assigning post-construction is the documented
|
|
||||||
// path for callers who want NIP-42 AUTH handling.
|
|
||||||
(p as unknown as {
|
|
||||||
automaticallyAuth?: (
|
|
||||||
relayURL: string,
|
|
||||||
) => null | ((event: EventTemplate) => Promise<Event>);
|
|
||||||
}).automaticallyAuth = () => async (template: EventTemplate) => {
|
|
||||||
const sk = nostrSecretFromSeed(seed);
|
|
||||||
return finalizeEvent(template, sk);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach the user's seed so the pool can answer NIP-42 AUTH
|
|
||||||
* challenges from relays that gate DM reads/writes behind it.
|
|
||||||
* Called from inbox-service when a session unlocks. Recreates the
|
|
||||||
* pool if we previously connected anonymously; cheap (only relays
|
|
||||||
* actually used will reconnect).
|
|
||||||
*/
|
|
||||||
export function attachSigner(seed: Uint8Array): void {
|
|
||||||
_authSeed = seed;
|
|
||||||
if (_pool) {
|
|
||||||
try {
|
|
||||||
_pool.destroy();
|
|
||||||
} catch {
|
|
||||||
/* destroy might throw on a half-initialised pool; ignore */
|
|
||||||
}
|
|
||||||
_pool = buildPool();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Look up the first relay we received a given event from. Returns
|
|
||||||
* undefined if `trackRelays` is off, the event id isn't known, or
|
|
||||||
* the underlying Map shape changes across nostr-tools versions.
|
|
||||||
* Used by the inbound message handler to remember which relay
|
|
||||||
* delivered each DM so we can reply over the same path.
|
|
||||||
*/
|
|
||||||
function firstRelayForEvent(eventId: string): string | undefined {
|
|
||||||
try {
|
|
||||||
const p = _pool as
|
|
||||||
| undefined
|
|
||||||
| null
|
|
||||||
| { seenOn?: Map<string, Set<{ url?: string }>> };
|
|
||||||
const set = p?.seenOn?.get(eventId);
|
|
||||||
if (!set) return undefined;
|
|
||||||
for (const relay of set) {
|
|
||||||
if (relay?.url) return relay.url;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore — best-effort */
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the signer (e.g. on lock). Pool stays alive but won't sign
|
|
||||||
* future AUTH challenges — equivalent to "anonymous client". */
|
|
||||||
export function detachSigner(): void {
|
|
||||||
_authSeed = null;
|
|
||||||
if (_pool) {
|
|
||||||
try {
|
|
||||||
_pool.destroy();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
_pool = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Per-handle cursor + dedupe (localStorage, survives reloads)
|
// Per-handle cursor + dedupe (localStorage, survives reloads)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SINCE_KEY = (h: string) => `kez-chat:nostr:since:${h}`;
|
const SINCE_KEY = (h: string) => `kez-chat:nostr:since:${h}`;
|
||||||
const SEEN_KEY = (h: string) => `kez-chat:nostr:seen:${h}`;
|
const SEEN_KEY = (h: string) => `kez-chat:nostr:seen:${h}`;
|
||||||
// Bumped from 500 → 10_000 (TODO.md Day 2 #2). At 500 ids, an active
|
const SEEN_CAP = 500;
|
||||||
// user rolled past the cap in days; once an id was evicted, a relay
|
|
||||||
// could re-broadcast the matching event and we'd accept it as fresh.
|
|
||||||
// 10k ids × ~70 bytes each (JSON-stringified hex) ≈ 700 KB, well
|
|
||||||
// inside localStorage's 5 MB ceiling and well above any user's
|
|
||||||
// realistic message volume in a meaningful window.
|
|
||||||
const SEEN_CAP = 10_000;
|
|
||||||
|
|
||||||
// Clock-skew tolerance against ev.created_at. A relay or a malicious
|
/** Relay `since` filter (unix seconds). Start a little in the past so a
|
||||||
// publisher can backdate or future-date events to game our `since`
|
* fresh device still catches very recent messages. */
|
||||||
// cursor (jumping forward → skipping legit events; jumping back →
|
|
||||||
// reordering UI). Clamp to a reasonable window.
|
|
||||||
const CREATED_AT_MAX_PAST_SECS = 7 * 24 * 3600; // 7 days
|
|
||||||
const CREATED_AT_MAX_FUTURE_SECS = 5 * 60; // 5 minutes
|
|
||||||
|
|
||||||
/** Drop events whose nostr-claimed timestamp is impossibly old or
|
|
||||||
* impossibly future. Caller treats `false` as "ignore this event". */
|
|
||||||
function isCreatedAtSane(createdAt: number): boolean {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
if (createdAt < now - CREATED_AT_MAX_PAST_SECS) return false;
|
|
||||||
if (createdAt > now + CREATED_AT_MAX_FUTURE_SECS) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Relay `since` filter (unix seconds). Default = 48h back on first
|
|
||||||
* use of a handle — chosen because most public relays only retain
|
|
||||||
* events for 1–3 days; a longer cursor silently misses anything
|
|
||||||
* the relay has already evicted. Returning user sessions use the
|
|
||||||
* persisted cursor (last seen `created_at`) and so are unaffected.
|
|
||||||
* TODO.md Day 3 #14. */
|
|
||||||
const DEFAULT_SINCE_LOOKBACK_SECS = 48 * 3600;
|
|
||||||
function readSince(handle: string): number {
|
function readSince(handle: string): number {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(SINCE_KEY(handle));
|
const v = localStorage.getItem(SINCE_KEY(handle));
|
||||||
return v
|
return v ? parseInt(v, 10) : Math.floor(Date.now() / 1000) - 7 * 24 * 3600;
|
||||||
? parseInt(v, 10)
|
|
||||||
: Math.floor(Date.now() / 1000) - DEFAULT_SINCE_LOOKBACK_SECS;
|
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function bumpSince(handle: string, createdAt: number) {
|
function bumpSince(handle: string, createdAt: number) {
|
||||||
// Don't let an out-of-bounds created_at advance `since` — that
|
|
||||||
// would skip future legit events (TODO.md Day 2 #2). Caller is
|
|
||||||
// responsible for the dedupe; this protects the cursor.
|
|
||||||
if (!isCreatedAtSane(createdAt)) return;
|
|
||||||
try {
|
try {
|
||||||
if (createdAt > readSince(handle)) {
|
if (createdAt > readSince(handle)) {
|
||||||
localStorage.setItem(SINCE_KEY(handle), String(createdAt));
|
localStorage.setItem(SINCE_KEY(handle), String(createdAt));
|
||||||
@ -313,52 +118,16 @@ function toInboxMessage(ev: Event): InboxMessage | null {
|
|||||||
// Send
|
// Send
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Order relays for this send: any preferred relay (typically: the
|
|
||||||
* relay that delivered the most recent inbound message from this
|
|
||||||
* peer) goes first, then the rest of the configured set, deduped.
|
|
||||||
* Putting a preferred relay first means the WebSocket open + publish
|
|
||||||
* race usually completes against it — same network path the inbound
|
|
||||||
* message came in on, so geographically/network-wise the fastest.
|
|
||||||
*/
|
|
||||||
function orderedRelaysForSend(preferRelay?: string): string[] {
|
|
||||||
if (!preferRelay) return RELAYS;
|
|
||||||
const norm = preferRelay.replace(/\/$/, "");
|
|
||||||
const matches = RELAYS.filter((r) => r.replace(/\/$/, "") === norm);
|
|
||||||
const others = RELAYS.filter((r) => r.replace(/\/$/, "") !== norm);
|
|
||||||
return [...matches, ...others];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendMessage(opts: {
|
export async function sendMessage(opts: {
|
||||||
senderHandle: string;
|
senderHandle: string;
|
||||||
senderSeed: Uint8Array;
|
senderSeed: Uint8Array;
|
||||||
senderPrimary: Identity;
|
senderPrimary: Identity;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
body: string;
|
body: string;
|
||||||
/** Optional relay URL to publish to FIRST — typically the one the
|
}): Promise<{ seq: number }> {
|
||||||
* recipient's last message arrived on. Falls back to the full
|
|
||||||
* configured relay set if unset or unknown. */
|
|
||||||
preferRelay?: string;
|
|
||||||
/**
|
|
||||||
* Recipient's KEZ primary, if the caller already has it (e.g. from
|
|
||||||
* the conversation row). Lets us skip the `/v1/u/:handle` lookup
|
|
||||||
* against the chat-server entirely — important because chat over
|
|
||||||
* nostr should NOT depend on the chat-server for delivery. Only
|
|
||||||
* brand-new conversations (no cached primary) still need lookup.
|
|
||||||
*/
|
|
||||||
recipientPrimary?: Identity;
|
|
||||||
}): Promise<{ seq: number; event_id: string; accepted_by?: string }> {
|
|
||||||
const recipientHandle = opts.recipient.split("@")[0];
|
const recipientHandle = opts.recipient.split("@")[0];
|
||||||
// Skip the server lookup if the caller already has the primary
|
const record = await lookup(recipientHandle); // throws on 404
|
||||||
// cached. The server going down should not break sending to
|
const recipientPrimary = record.primary as Identity;
|
||||||
// existing contacts — nostr is the actual delivery pipe.
|
|
||||||
let recipientPrimary: Identity;
|
|
||||||
if (opts.recipientPrimary) {
|
|
||||||
recipientPrimary = opts.recipientPrimary;
|
|
||||||
} else {
|
|
||||||
const record = await lookup(recipientHandle); // throws on 404 / server down
|
|
||||||
recipientPrimary = record.primary as Identity;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our own encryption layer — identical to the server transport.
|
// Our own encryption layer — identical to the server transport.
|
||||||
const envelope = await sealMessage({
|
const envelope = await sealMessage({
|
||||||
@ -378,25 +147,8 @@ export async function sendMessage(opts: {
|
|||||||
};
|
};
|
||||||
const signed = finalizeEvent(tmpl, sk);
|
const signed = finalizeEvent(tmpl, sk);
|
||||||
|
|
||||||
// Publish to all relays in order; succeed if any accepts. We also
|
// Succeed if at least one relay accepts.
|
||||||
// record which relay accepted first so the UI can show "sent via
|
const results = await Promise.allSettled(pool().publish(RELAYS, signed));
|
||||||
// X" and so the next message in this thread can prefer the same
|
|
||||||
// relay.
|
|
||||||
const ordered = orderedRelaysForSend(opts.preferRelay);
|
|
||||||
const publishPromises = pool().publish(ordered, signed);
|
|
||||||
// Promise.any returns the first-fulfilled value; if every relay
|
|
||||||
// rejects, it throws AggregateError. We still want best-effort
|
|
||||||
// success on the all-fulfilled path, so we wrap with allSettled
|
|
||||||
// afterwards to gate the throw.
|
|
||||||
let acceptedBy: string | undefined;
|
|
||||||
try {
|
|
||||||
acceptedBy = await Promise.any(
|
|
||||||
publishPromises.map((p, i) => p.then(() => ordered[i])),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* nobody accepted yet — wait for the full set */
|
|
||||||
}
|
|
||||||
const results = await Promise.allSettled(publishPromises);
|
|
||||||
if (!results.some((r) => r.status === "fulfilled")) {
|
if (!results.some((r) => r.status === "fulfilled")) {
|
||||||
const why = results
|
const why = results
|
||||||
.map((r) => (r.status === "rejected" ? String(r.reason) : ""))
|
.map((r) => (r.status === "rejected" ? String(r.reason) : ""))
|
||||||
@ -404,409 +156,7 @@ export async function sendMessage(opts: {
|
|||||||
.join("; ");
|
.join("; ");
|
||||||
throw new Error(`no relay accepted the message${why ? `: ${why}` : ""}`);
|
throw new Error(`no relay accepted the message${why ? `: ${why}` : ""}`);
|
||||||
}
|
}
|
||||||
return { seq: signed.created_at, event_id: signed.id, accepted_by: acceptedBy };
|
return { seq: signed.created_at };
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// File attachments — inline and chunked
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Reuses the same v2 envelope crypto + nostr publish path. The
|
|
||||||
// only difference is the message body is a JSON payload with a
|
|
||||||
// type discriminator rather than plain text. See file-transfer.ts
|
|
||||||
// for the body schema.
|
|
||||||
//
|
|
||||||
// For chunked transfers, each chunk is its own DM event so:
|
|
||||||
// • Receiver gets each chunk via the regular streamInbox path
|
|
||||||
// (no new subscription, no new filter)
|
|
||||||
// • Loss recovery is per-chunk (republish just the missing ones)
|
|
||||||
// • Each chunk is signed/sealed/tagged identically to a text DM
|
|
||||||
//
|
|
||||||
// Throttle: chunks are published at ~5/sec to keep relay-side
|
|
||||||
// rate limits happy. A 10 MB file = 125 chunks = ~25 s end-to-end.
|
|
||||||
|
|
||||||
const CHUNK_PUBLISH_DELAY_MS = 200; // 5/sec
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a single arbitrary text body (a JSON-encoded
|
|
||||||
* inline/pointer/chunk payload, in practice). Same crypto +
|
|
||||||
* publish path as `sendMessage`, just exposed for the file
|
|
||||||
* transfer module so it can publish N chunks plus a pointer
|
|
||||||
* without re-implementing the envelope.
|
|
||||||
*/
|
|
||||||
async function publishRawBody(opts: {
|
|
||||||
senderHandle: string;
|
|
||||||
senderSeed: Uint8Array;
|
|
||||||
senderPrimary: Identity;
|
|
||||||
recipientHandle: string;
|
|
||||||
recipientPrimary: Identity;
|
|
||||||
body: string;
|
|
||||||
preferRelay?: string;
|
|
||||||
}): Promise<{ event_id: string; accepted_by?: string }> {
|
|
||||||
const envelope = await sealMessage({
|
|
||||||
senderSeed: opts.senderSeed,
|
|
||||||
senderPrimary: opts.senderPrimary,
|
|
||||||
recipientHandle: opts.recipientHandle,
|
|
||||||
recipientPrimary: opts.recipientPrimary,
|
|
||||||
body: opts.body,
|
|
||||||
});
|
|
||||||
const sk = nostrSecretFromSeed(opts.senderSeed);
|
|
||||||
const tmpl: EventTemplate = {
|
|
||||||
kind: KEZ_DM_KIND,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [[ADDR_TAG, addrFromPrimary(opts.recipientPrimary)]],
|
|
||||||
content: JSON.stringify(envelope),
|
|
||||||
};
|
|
||||||
const signed = finalizeEvent(tmpl, sk);
|
|
||||||
const ordered = orderedRelaysForSend(opts.preferRelay);
|
|
||||||
const publishPromises = pool().publish(ordered, signed);
|
|
||||||
let acceptedBy: string | undefined;
|
|
||||||
try {
|
|
||||||
acceptedBy = await Promise.any(
|
|
||||||
publishPromises.map((p, i) => p.then(() => ordered[i])),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* nobody accepted yet — fall through to allSettled check */
|
|
||||||
}
|
|
||||||
const results = await Promise.allSettled(publishPromises);
|
|
||||||
if (!results.some((r) => r.status === "fulfilled")) {
|
|
||||||
const why = results
|
|
||||||
.map((r) => (r.status === "rejected" ? String(r.reason) : ""))
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("; ");
|
|
||||||
throw new Error(`no relay accepted${why ? `: ${why}` : ""}`);
|
|
||||||
}
|
|
||||||
return { event_id: signed.id, accepted_by: acceptedBy };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendFileResult {
|
|
||||||
/** event id of the final user-visible message (inline file event,
|
|
||||||
* or the chunked-file pointer). */
|
|
||||||
pointer_event_id: string;
|
|
||||||
/** Relay that accepted the pointer first. */
|
|
||||||
accepted_by?: string;
|
|
||||||
/** For chunked sends: ids of every chunk event in send order.
|
|
||||||
* Empty for inline. Useful for logging / future recovery hooks. */
|
|
||||||
chunk_event_ids: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a file. Decides inline vs chunked based on `raw.length`.
|
|
||||||
* Calls `progress` after each chunk publish so the UI can paint a
|
|
||||||
* "uploading 12/47" hint.
|
|
||||||
*/
|
|
||||||
export async function sendFile(opts: {
|
|
||||||
senderHandle: string;
|
|
||||||
senderSeed: Uint8Array;
|
|
||||||
senderPrimary: Identity;
|
|
||||||
recipientHandle: string;
|
|
||||||
recipientPrimary: Identity;
|
|
||||||
filename: string;
|
|
||||||
mime: string;
|
|
||||||
raw: Uint8Array;
|
|
||||||
preferRelay?: string;
|
|
||||||
progress?: (done: number, total: number) => void;
|
|
||||||
}): Promise<SendFileResult> {
|
|
||||||
const { raw } = opts;
|
|
||||||
if (raw.length > MAX_FILE_BYTES) {
|
|
||||||
throw new Error(
|
|
||||||
`file too large (${(raw.length / 1024 / 1024).toFixed(1)} MB; max ${MAX_FILE_BYTES / 1024 / 1024} MB)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── inline path ─────────────────────────────────────────────
|
|
||||||
if (raw.length <= INLINE_LIMIT) {
|
|
||||||
const body: InlineFileBody = {
|
|
||||||
type: "kez-file-v1",
|
|
||||||
mode: "inline",
|
|
||||||
filename: opts.filename,
|
|
||||||
mime: opts.mime,
|
|
||||||
size: raw.length,
|
|
||||||
data: bytesToBase64(raw),
|
|
||||||
};
|
|
||||||
opts.progress?.(1, 1);
|
|
||||||
const out = await publishRawBody({
|
|
||||||
...opts,
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
pointer_event_id: out.event_id,
|
|
||||||
accepted_by: out.accepted_by,
|
|
||||||
chunk_event_ids: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── chunked path ────────────────────────────────────────────
|
|
||||||
const file_id = newFileId();
|
|
||||||
const chunks = chunkifyBytes(raw);
|
|
||||||
const n = chunks.length;
|
|
||||||
|
|
||||||
// 1. Publish each chunk. Throttled so we don't stampede relays.
|
|
||||||
const chunk_event_ids: string[] = [];
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const chunkBody: ChunkBody = {
|
|
||||||
type: "kez-file-chunk-v1",
|
|
||||||
file_id,
|
|
||||||
i,
|
|
||||||
n,
|
|
||||||
data: bytesToBase64(chunks[i]),
|
|
||||||
};
|
|
||||||
const out = await publishRawBody({
|
|
||||||
...opts,
|
|
||||||
body: JSON.stringify(chunkBody),
|
|
||||||
});
|
|
||||||
chunk_event_ids.push(out.event_id);
|
|
||||||
opts.progress?.(i + 1, n + 1);
|
|
||||||
if (i < n - 1) {
|
|
||||||
await new Promise((r) => setTimeout(r, CHUNK_PUBLISH_DELAY_MS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Publish the pointer. The bubble appears on the recipient's
|
|
||||||
// side at this moment; chunks are typically already there.
|
|
||||||
const pointerBody: ChunkedFilePointerBody = {
|
|
||||||
type: "kez-file-v1",
|
|
||||||
mode: "chunked",
|
|
||||||
filename: opts.filename,
|
|
||||||
mime: opts.mime,
|
|
||||||
size: raw.length,
|
|
||||||
file_id,
|
|
||||||
n,
|
|
||||||
};
|
|
||||||
const pointer = await publishRawBody({
|
|
||||||
...opts,
|
|
||||||
body: JSON.stringify(pointerBody),
|
|
||||||
});
|
|
||||||
opts.progress?.(n + 1, n + 1);
|
|
||||||
return {
|
|
||||||
pointer_event_id: pointer.event_id,
|
|
||||||
accepted_by: pointer.accepted_by,
|
|
||||||
chunk_event_ids,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Delivery receipts (kez-DM-ack, kind 4244)
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Format:
|
|
||||||
// { kind: KEZ_ACK_KIND,
|
|
||||||
// created_at: now,
|
|
||||||
// pubkey: <recipient's nostr-derived pubkey>,
|
|
||||||
// content: "", // no body — id-only ack
|
|
||||||
// tags: [
|
|
||||||
// ["h", <original sender's addr>], // so sender can subscribe + filter
|
|
||||||
// ["e", <acked event id>], // links back to the original DM
|
|
||||||
// ] }
|
|
||||||
//
|
|
||||||
// Trust model: signed by the recipient's nostr key (deterministic from
|
|
||||||
// their KEZ seed via the same HKDF). A third party who saw the
|
|
||||||
// original event id could forge an ack and cause the sender's UI to
|
|
||||||
// show "delivered" — a minor cosmetic spoof, not a confidentiality
|
|
||||||
// break. v0.1 trusts whoever sends an ack. v0.2: verify the ack's
|
|
||||||
// pubkey matches the recipient identity we expected.
|
|
||||||
|
|
||||||
/** Distinct kind for delivery receipts. Inside the regular-event
|
|
||||||
* range (1000-9999) so relays persist them like normal DMs. */
|
|
||||||
export const KEZ_ACK_KIND = 4244;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persistent pending-ack queue. If every relay rejects an ack send
|
|
||||||
* (offline, transient flap, AUTH required, etc.), we save the request
|
|
||||||
* to localStorage and retry on next session start. Without this, a
|
|
||||||
* single bad moment at decrypt-time means the sender's UI is stuck
|
|
||||||
* on "sent" (single check) forever for that message.
|
|
||||||
*/
|
|
||||||
const PENDING_ACKS_KEY = "kez-chat:pending-acks:v1";
|
|
||||||
|
|
||||||
interface PendingAck {
|
|
||||||
/** Hex of the sender's KEZ primary — restored at retry time. */
|
|
||||||
originalSenderPrimary: Identity;
|
|
||||||
/** Event id of the DM we never managed to ack. */
|
|
||||||
ackedEventId: string;
|
|
||||||
/** Nostr pubkey of the original sender — added in Day 3 Option B
|
|
||||||
* so the retried ack carries the same NIP-25 `p` tag the live
|
|
||||||
* send did. Optional for pre-Option-B queue entries. */
|
|
||||||
originalSenderNostrPubkey?: string;
|
|
||||||
/** Wall-clock of the first attempt (ISO). Lets us age out very
|
|
||||||
* old pending acks so the queue doesn't grow forever. */
|
|
||||||
queued_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPendingAcks(): PendingAck[] {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(PENDING_ACKS_KEY) ?? "[]");
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function writePendingAcks(list: PendingAck[]) {
|
|
||||||
try {
|
|
||||||
// Cap to 200 to bound localStorage growth in pathological cases.
|
|
||||||
const capped = list.slice(-200);
|
|
||||||
localStorage.setItem(PENDING_ACKS_KEY, JSON.stringify(capped));
|
|
||||||
} catch {
|
|
||||||
/* private mode */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign the acked event id with the recipient's KEZ ed25519 key so the
|
|
||||||
* sender can prove the ack actually came from the intended recipient
|
|
||||||
* (and not a third party who happened to see the original event id).
|
|
||||||
* TODO.md Day 3 #9.
|
|
||||||
*
|
|
||||||
* Format: `["kez-sig", hex(ed25519.sign(seed, utf8(ackedEventId)))]`
|
|
||||||
* The tag name is multi-letter so it's filterable but not indexed —
|
|
||||||
* indexing is for routing, this is for verification.
|
|
||||||
*/
|
|
||||||
function buildAckSigTag(seed: Uint8Array, ackedEventId: string): string[] {
|
|
||||||
const msg = new TextEncoder().encode(ackedEventId);
|
|
||||||
const sig = ed25519.sign(msg, seed);
|
|
||||||
return ["kez-sig", bytesToHex(sig)];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendAck(opts: {
|
|
||||||
/** Recipient's own seed — signs the ack with the recipient's nostr key. */
|
|
||||||
ackingSeed: Uint8Array;
|
|
||||||
/** KEZ primary of the person who sent the original message. */
|
|
||||||
originalSenderPrimary: Identity;
|
|
||||||
/** Event id of the original kez-DM event we're acking. */
|
|
||||||
ackedEventId: string;
|
|
||||||
/** Nostr pubkey of the original sender (= event.pubkey of the DM
|
|
||||||
* we're acking). Used to populate the NIP-25-shape `["p", ...]`
|
|
||||||
* tag, which relays use for inbox routing of reactions/acks.
|
|
||||||
* Optional for callers that don't have it; we omit the p-tag in
|
|
||||||
* that case. TODO.md Day 3 Option B #13. */
|
|
||||||
originalSenderNostrPubkey?: string;
|
|
||||||
/** Relay the DM arrived on — used to bias the ack publish toward
|
|
||||||
* the same relay, since that path was demonstrably working for
|
|
||||||
* this peer. Falls back to the full set if unset. */
|
|
||||||
preferRelay?: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
const sk = nostrSecretFromSeed(opts.ackingSeed);
|
|
||||||
const targetAddr = addrFromPrimary(opts.originalSenderPrimary);
|
|
||||||
const tags: string[][] = [
|
|
||||||
[ADDR_TAG, targetAddr],
|
|
||||||
["e", opts.ackedEventId],
|
|
||||||
buildAckSigTag(opts.ackingSeed, opts.ackedEventId),
|
|
||||||
];
|
|
||||||
if (opts.originalSenderNostrPubkey) {
|
|
||||||
// NIP-25 / NIP-10 convention: an `e`-tagged response includes the
|
|
||||||
// author's pubkey as a `p` tag. Lets nostr clients route the
|
|
||||||
// reaction to the original author's "mentions" feed for free.
|
|
||||||
tags.push(["p", opts.originalSenderNostrPubkey]);
|
|
||||||
}
|
|
||||||
const tmpl: EventTemplate = {
|
|
||||||
kind: KEZ_ACK_KIND,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags,
|
|
||||||
content: "",
|
|
||||||
};
|
|
||||||
const signed = finalizeEvent(tmpl, sk);
|
|
||||||
const ordered = orderedRelaysForSend(opts.preferRelay);
|
|
||||||
const results = await Promise.allSettled(pool().publish(ordered, signed));
|
|
||||||
const acceptedSomewhere = results.some((r) => r.status === "fulfilled");
|
|
||||||
if (!acceptedSomewhere) {
|
|
||||||
// Stash for retry. Never throw — the caller is the inbox-service
|
|
||||||
// decrypt path, and a publish failure shouldn't break ingest.
|
|
||||||
const queue = readPendingAcks();
|
|
||||||
queue.push({
|
|
||||||
originalSenderPrimary: opts.originalSenderPrimary,
|
|
||||||
ackedEventId: opts.ackedEventId,
|
|
||||||
originalSenderNostrPubkey: opts.originalSenderNostrPubkey,
|
|
||||||
queued_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
writePendingAcks(queue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On session start, walk the pending-ack queue and re-attempt each
|
|
||||||
* one. Drops any older than 7 days (sender's UI has long since
|
|
||||||
* "forgotten" about them — at that point the sender either reloaded
|
|
||||||
* and re-fetched via the catch-up scan, or has moved on). Idempotent:
|
|
||||||
* acks that finally succeed are removed; the rest stay queued for
|
|
||||||
* the next session.
|
|
||||||
*/
|
|
||||||
export async function flushPendingAcks(seed: Uint8Array): Promise<void> {
|
|
||||||
const queue = readPendingAcks();
|
|
||||||
if (queue.length === 0) return;
|
|
||||||
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
const fresh = queue.filter((q) => {
|
|
||||||
return Date.now() - new Date(q.queued_at).getTime() < sevenDaysMs;
|
|
||||||
});
|
|
||||||
const stillPending: PendingAck[] = [];
|
|
||||||
for (const item of fresh) {
|
|
||||||
try {
|
|
||||||
const sk = nostrSecretFromSeed(seed);
|
|
||||||
const targetAddr = addrFromPrimary(item.originalSenderPrimary);
|
|
||||||
const retryTags: string[][] = [
|
|
||||||
[ADDR_TAG, targetAddr],
|
|
||||||
["e", item.ackedEventId],
|
|
||||||
buildAckSigTag(seed, item.ackedEventId),
|
|
||||||
];
|
|
||||||
if (item.originalSenderNostrPubkey) {
|
|
||||||
retryTags.push(["p", item.originalSenderNostrPubkey]);
|
|
||||||
}
|
|
||||||
const tmpl: EventTemplate = {
|
|
||||||
kind: KEZ_ACK_KIND,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: retryTags,
|
|
||||||
content: "",
|
|
||||||
};
|
|
||||||
const signed = finalizeEvent(tmpl, sk);
|
|
||||||
const results = await Promise.allSettled(pool().publish(RELAYS, signed));
|
|
||||||
const ok = results.some((r) => r.status === "fulfilled");
|
|
||||||
if (!ok) stillPending.push(item);
|
|
||||||
} catch {
|
|
||||||
stillPending.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writePendingAcks(stillPending);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Catch-up scan for missed acks. Given a list of recently-sent
|
|
||||||
* event ids the sender is still waiting on, query relays for any
|
|
||||||
* kind-4244 events with a matching `["e", id]` tag and return the
|
|
||||||
* set of ids that have been acked. The sender's UI can then flip
|
|
||||||
* those bubbles to "delivered" without waiting for the live stream
|
|
||||||
* to redeliver the acks.
|
|
||||||
*
|
|
||||||
* Used by Messages.svelte on conversation-list mount and any time
|
|
||||||
* the user opens a thread — both moments where seeing the right
|
|
||||||
* checkmark state matters more than CPU.
|
|
||||||
*/
|
|
||||||
export async function fetchAcksForEventIds(
|
|
||||||
eventIds: string[],
|
|
||||||
): Promise<Map<string, string | undefined>> {
|
|
||||||
if (eventIds.length === 0) return new Map();
|
|
||||||
try {
|
|
||||||
// nostr-tools filter: `#e` is the indexed `e` tag values.
|
|
||||||
const events = await pool().querySync(RELAYS, {
|
|
||||||
kinds: [KEZ_ACK_KIND],
|
|
||||||
"#e": eventIds,
|
|
||||||
});
|
|
||||||
// Map event_id → (optional) kez-sig hex. Caller verifies the sig
|
|
||||||
// against the conversation peer's KEZ primary before flipping
|
|
||||||
// bubble state — same path as the live-stream onAck handler.
|
|
||||||
const found = new Map<string, string | undefined>();
|
|
||||||
for (const ev of events) {
|
|
||||||
const id = ev.tags.find((t) => t[0] === "e")?.[1];
|
|
||||||
if (!id) continue;
|
|
||||||
const sig = ev.tags.find((t) => t[0] === "kez-sig")?.[1];
|
|
||||||
// If two acks for the same id arrive (replays), prefer the
|
|
||||||
// one with a sig over the one without.
|
|
||||||
const existing = found.get(id);
|
|
||||||
if (existing !== undefined && sig === undefined) continue;
|
|
||||||
found.set(id, sig);
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("fetchAcksForEventIds failed:", e);
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -833,19 +183,9 @@ export async function pollInbox(opts: {
|
|||||||
const messages: InboxMessage[] = [];
|
const messages: InboxMessage[] = [];
|
||||||
let maxSeq = 0;
|
let maxSeq = 0;
|
||||||
for (const ev of events.sort((a, b) => a.created_at - b.created_at)) {
|
for (const ev of events.sort((a, b) => a.created_at - b.created_at)) {
|
||||||
if (!isCreatedAtSane(ev.created_at)) continue;
|
|
||||||
if (!markSeen(opts.handle, ev.id)) continue;
|
if (!markSeen(opts.handle, ev.id)) continue;
|
||||||
const m = toInboxMessage(ev);
|
const m = toInboxMessage(ev);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
// Stash the same correlator fields the streamInbox path sets, so
|
|
||||||
// messages caught up via heartbeat poll ALSO trigger delivery
|
|
||||||
// acks AND let the inbox-service learn the sender's nostr
|
|
||||||
// pubkey for peer-profile fetches. (Earlier code only did this
|
|
||||||
// on the live stream — polled messages went un-acked, which
|
|
||||||
// showed up as the "no check-in-circle" report from users.)
|
|
||||||
m.event_id = ev.id;
|
|
||||||
m.sender_nostr_pubkey = ev.pubkey;
|
|
||||||
m.via_relay = firstRelayForEvent(ev.id);
|
|
||||||
messages.push(m);
|
messages.push(m);
|
||||||
bumpSince(opts.handle, ev.created_at);
|
bumpSince(opts.handle, ev.created_at);
|
||||||
if (m.seq > maxSeq) maxSeq = m.seq;
|
if (m.seq > maxSeq) maxSeq = m.seq;
|
||||||
@ -861,13 +201,6 @@ export function streamInbox(opts: {
|
|||||||
handle: string;
|
handle: string;
|
||||||
seed: Uint8Array;
|
seed: Uint8Array;
|
||||||
onMessage: (msg: InboxMessage) => void;
|
onMessage: (msg: InboxMessage) => void;
|
||||||
/** Fires when the recipient publishes an ack for one of OUR outbound
|
|
||||||
* messages — used to flip the bubble status from "sent" to
|
|
||||||
* "delivered". The second arg is the recipient's hex ed25519
|
|
||||||
* signature over the acked event id (TODO.md Day 3 #9); the caller
|
|
||||||
* verifies it against the conversation peer's KEZ primary. May
|
|
||||||
* be undefined for legacy acks during the transition window. */
|
|
||||||
onAck?: (acked_event_id: string, ack_sig_hex?: string) => void;
|
|
||||||
onStatus?: (status: "connecting" | "live" | "reconnecting") => void;
|
onStatus?: (status: "connecting" | "live" | "reconnecting") => void;
|
||||||
}): StreamHandle {
|
}): StreamHandle {
|
||||||
const myPrimary = identityFromSeed(opts.seed).identity;
|
const myPrimary = identityFromSeed(opts.seed).identity;
|
||||||
@ -877,50 +210,14 @@ export function streamInbox(opts: {
|
|||||||
opts.onStatus?.("connecting");
|
opts.onStatus?.("connecting");
|
||||||
const sub = pool().subscribeMany(
|
const sub = pool().subscribeMany(
|
||||||
RELAYS,
|
RELAYS,
|
||||||
{
|
{ kinds: [KEZ_DM_KIND], [`#${ADDR_TAG}`]: [addr], since: readSince(opts.handle) },
|
||||||
// Subscribe to BOTH the regular DM events addressed to us AND
|
|
||||||
// the ack events for messages we sent — single subscription
|
|
||||||
// keeps relay connection count + bandwidth flat.
|
|
||||||
kinds: [KEZ_DM_KIND, KEZ_ACK_KIND],
|
|
||||||
[`#${ADDR_TAG}`]: [addr],
|
|
||||||
since: readSince(opts.handle),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
onevent(ev: Event) {
|
onevent(ev: Event) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
// Reject events with impossibly old or future timestamps —
|
|
||||||
// a relay or malicious publisher can backdate to replay an
|
|
||||||
// old event, or future-date to game the `since` cursor and
|
|
||||||
// hide subsequent legit events. TODO.md Day 2 #2.
|
|
||||||
if (!isCreatedAtSane(ev.created_at)) return;
|
|
||||||
if (!markSeen(opts.handle, ev.id)) return;
|
if (!markSeen(opts.handle, ev.id)) return;
|
||||||
bumpSince(opts.handle, ev.created_at);
|
|
||||||
if (ev.kind === KEZ_ACK_KIND) {
|
|
||||||
// Pull the `e` tag value — the original event id this acks
|
|
||||||
// — and the `kez-sig` tag, the recipient's ed25519 signature
|
|
||||||
// over the event id (so the sender can verify the ack is
|
|
||||||
// unforgeable, TODO.md Day 3 #9). Old-build acks lack the
|
|
||||||
// sig tag; we accept those during the transition window
|
|
||||||
// since v0.1 didn't have unforgeable acks at all.
|
|
||||||
const ackedId = ev.tags.find((t) => t[0] === "e")?.[1];
|
|
||||||
const sigHex = ev.tags.find((t) => t[0] === "kez-sig")?.[1];
|
|
||||||
if (ackedId) opts.onAck?.(ackedId, sigHex);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const m = toInboxMessage(ev);
|
const m = toInboxMessage(ev);
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
// For DM events we forward the InboxMessage AND stash:
|
bumpSince(opts.handle, ev.created_at);
|
||||||
// • event_id — used to correlate the recipient's ack back
|
|
||||||
// to this message so the sender's UI can flip to ✓◯
|
|
||||||
// • sender_nostr_pubkey — kept so we can later fetch the
|
|
||||||
// sender's NIP-01 kind:0 profile (used for peer avatars,
|
|
||||||
// including descrambling visually-encrypted pictures).
|
|
||||||
// • via_relay — the FIRST relay we saw this event on, used
|
|
||||||
// to bias the reply publish toward the same path
|
|
||||||
// (typically the geographically/network-wise fastest one).
|
|
||||||
m.event_id = ev.id;
|
|
||||||
m.sender_nostr_pubkey = ev.pubkey;
|
|
||||||
m.via_relay = firstRelayForEvent(ev.id);
|
|
||||||
opts.onMessage(m);
|
opts.onMessage(m);
|
||||||
},
|
},
|
||||||
oneose() {
|
oneose() {
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
// Svelte 5 reactive mirror of peer-profile-store.ts. Components read
|
|
||||||
// `peerProfiles.byPrimary[peer_primary]?.picture` and re-render
|
|
||||||
// automatically when a fetch completes.
|
|
||||||
//
|
|
||||||
// This is a separate file from peer-profile-store.ts because
|
|
||||||
// .svelte.ts files get the runes transform applied, and we want the
|
|
||||||
// store layer (which is also imported by non-component code) to stay
|
|
||||||
// rune-free.
|
|
||||||
|
|
||||||
import {
|
|
||||||
fetchPeerProfile,
|
|
||||||
getCachedPeerProfile,
|
|
||||||
hydratePeerProfileCache,
|
|
||||||
type CachedPeerProfile,
|
|
||||||
} from "./peer-profile-store.js";
|
|
||||||
import type { Identity } from "./kez.js";
|
|
||||||
|
|
||||||
class PeerProfilesCell {
|
|
||||||
/** primary → CachedPeerProfile. Reactive: avatar consumers read
|
|
||||||
* `peerProfiles.byPrimary[primary]?.picture`. */
|
|
||||||
byPrimary = $state<Record<string, CachedPeerProfile>>({});
|
|
||||||
/** True once IDB-backed mirror has loaded once. */
|
|
||||||
hydrated = $state(false);
|
|
||||||
|
|
||||||
async hydrate() {
|
|
||||||
if (this.hydrated) return;
|
|
||||||
await hydratePeerProfileCache();
|
|
||||||
// Pull every entry into the reactive map. Cheap — at most ~hundreds
|
|
||||||
// of profiles per active user.
|
|
||||||
this.hydrated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Trigger a fetch + cache update for a single peer. Idempotent
|
|
||||||
* within the staleness window. Returns the resolved profile if
|
|
||||||
* one was found (cached or fresh). */
|
|
||||||
async refresh(opts: {
|
|
||||||
peer_primary: Identity;
|
|
||||||
peer_nostr_pubkey: string;
|
|
||||||
my_handle: string;
|
|
||||||
my_seed: Uint8Array;
|
|
||||||
forceRefresh?: boolean;
|
|
||||||
}): Promise<CachedPeerProfile | undefined> {
|
|
||||||
const result = await fetchPeerProfile(opts);
|
|
||||||
if (result) {
|
|
||||||
// Mutate the record so Svelte 5's deep reactivity ticks.
|
|
||||||
this.byPrimary = { ...this.byPrimary, [opts.peer_primary]: result };
|
|
||||||
} else {
|
|
||||||
// Maybe an in-memory cache entry from a previous run that
|
|
||||||
// wasn't surfaced yet — bring it through.
|
|
||||||
const cached = getCachedPeerProfile(opts.peer_primary);
|
|
||||||
if (cached && !this.byPrimary[opts.peer_primary]) {
|
|
||||||
this.byPrimary = { ...this.byPrimary, [opts.peer_primary]: cached };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const peerProfiles = new PeerProfilesCell();
|
|
||||||
@ -1,222 +0,0 @@
|
|||||||
// Peer-profile cache.
|
|
||||||
//
|
|
||||||
// When we render a conversation row or thread header, we want to show
|
|
||||||
// the peer's avatar (the profile picture they set in their own Settings,
|
|
||||||
// possibly visually-encrypted with a key they wrapped for us). This
|
|
||||||
// module:
|
|
||||||
//
|
|
||||||
// 1. Fetches the peer's NIP-01 kind:0 metadata event from nostr
|
|
||||||
// 2. Looks for a `kez_visual_keys[<my_primary>]` wrap inside the
|
|
||||||
// content; if present, unwraps the visual key via the same
|
|
||||||
// SealedEnvelope crypto we use for DMs
|
|
||||||
// 3. Descrambles `metadata.picture` with that key
|
|
||||||
// 4. Caches the result so re-renders are instant
|
|
||||||
//
|
|
||||||
// What strangers see when they fetch the same kind:0:
|
|
||||||
// • A scrambled PNG (looks like colored noise — same dimensions,
|
|
||||||
// same histogram, no recognisable content)
|
|
||||||
// • The `kez_visual_keys` map, none of which they can open (each
|
|
||||||
// wrap is encrypted to a specific recipient via ECDH)
|
|
||||||
//
|
|
||||||
// What we (a contact) see:
|
|
||||||
// • The real picture, because the peer explicitly wrapped the
|
|
||||||
// descramble key for us
|
|
||||||
//
|
|
||||||
// Cache invalidation: any cached entry older than 24 h is re-fetched
|
|
||||||
// on access (peers may have updated their picture).
|
|
||||||
|
|
||||||
import { get, set } from "idb-keyval";
|
|
||||||
import { hexToBytes } from "@noble/hashes/utils";
|
|
||||||
import { SimplePool } from "nostr-tools";
|
|
||||||
|
|
||||||
import { openMessage, type SealedEnvelope } from "./crypto.js";
|
|
||||||
import { identityFromSeed } from "./kez.js";
|
|
||||||
import type { Identity } from "./kez.js";
|
|
||||||
import { unscrambleImage } from "./visual-crypto.js";
|
|
||||||
|
|
||||||
const CACHE_KEY = "kez-chat:peer-profiles:v1";
|
|
||||||
// Bulk-scan refresh window — when /chats first paints we re-fetch
|
|
||||||
// every peer whose cached entry is older than this. 6h hits the
|
|
||||||
// sweet spot: noticeable freshness for users who update their
|
|
||||||
// picture, without spamming relays on every reload. Per-peer
|
|
||||||
// `forceRefresh: true` short-circuits this gate.
|
|
||||||
const STALE_AFTER_MS = 6 * 60 * 60 * 1000;
|
|
||||||
const FETCH_TIMEOUT_MS = 8000;
|
|
||||||
|
|
||||||
/** Same default-relay list as nostr-transport.ts. */
|
|
||||||
const RELAYS: string[] = (
|
|
||||||
(import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ??
|
|
||||||
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
|
|
||||||
)
|
|
||||||
.split(",")
|
|
||||||
.map((r) => r.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
export interface CachedPeerProfile {
|
|
||||||
/** The KEZ primary key — canonical identifier; never changes. */
|
|
||||||
peer_primary: Identity;
|
|
||||||
/** Display name from the kind:0 metadata. May equal the handle. */
|
|
||||||
name?: string;
|
|
||||||
/** "about" / bio from the kind:0 metadata. */
|
|
||||||
about?: string;
|
|
||||||
/**
|
|
||||||
* The RENDERABLE picture data URL (descrambled if the peer's
|
|
||||||
* picture was encrypted and we held the key; cleartext otherwise).
|
|
||||||
* Absent when the peer hasn't set a picture, OR when the peer
|
|
||||||
* encrypted it but didn't wrap a key for us yet.
|
|
||||||
*/
|
|
||||||
picture?: string;
|
|
||||||
/** True if we descrambled an encrypted picture vs. read a
|
|
||||||
* cleartext one. UI badges off this. */
|
|
||||||
picture_was_encrypted?: boolean;
|
|
||||||
/** Wall clock of the last successful fetch. */
|
|
||||||
fetched_at: string;
|
|
||||||
/** Event id of the kind:0 we descrambled — debug breadcrumb. */
|
|
||||||
source_event_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Cache = Record<string, CachedPeerProfile>;
|
|
||||||
|
|
||||||
async function readCache(): Promise<Cache> {
|
|
||||||
return (await get<Cache>(CACHE_KEY)) ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeCache(c: Cache): Promise<void> {
|
|
||||||
await set(CACHE_KEY, c);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Synchronous (in-memory) snapshot of the IDB-backed cache, for
|
|
||||||
* fast component reads. Hydrated by `hydratePeerProfileCache()`
|
|
||||||
* on app boot. */
|
|
||||||
const memCache: Cache = {};
|
|
||||||
|
|
||||||
/** Read all cached peer profiles into the in-memory mirror so the
|
|
||||||
* UI can render immediately on first paint without an IDB hop. */
|
|
||||||
export async function hydratePeerProfileCache(): Promise<void> {
|
|
||||||
const c = await readCache();
|
|
||||||
for (const k of Object.keys(c)) memCache[k] = c[k];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCachedPeerProfile(
|
|
||||||
peer_primary: Identity,
|
|
||||||
): CachedPeerProfile | undefined {
|
|
||||||
return memCache[peer_primary];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch (or refresh) the peer's kind:0 profile. Returns the cached
|
|
||||||
* profile (whether freshly fetched or re-used from a recent cache
|
|
||||||
* entry). Pass `forceRefresh: true` to ignore the 24h staleness gate.
|
|
||||||
*
|
|
||||||
* Failures are silent — the function logs and returns undefined; the
|
|
||||||
* UI falls back to the identicon. We never want a missing profile to
|
|
||||||
* break the chat experience.
|
|
||||||
*/
|
|
||||||
export async function fetchPeerProfile(opts: {
|
|
||||||
peer_primary: Identity;
|
|
||||||
peer_nostr_pubkey: string;
|
|
||||||
my_handle: string;
|
|
||||||
my_seed: Uint8Array;
|
|
||||||
forceRefresh?: boolean;
|
|
||||||
}): Promise<CachedPeerProfile | undefined> {
|
|
||||||
// Cache hit?
|
|
||||||
const cached = memCache[opts.peer_primary];
|
|
||||||
if (cached && !opts.forceRefresh) {
|
|
||||||
const age = Date.now() - new Date(cached.fetched_at).getTime();
|
|
||||||
if (age < STALE_AFTER_MS) return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch fresh. Use a one-shot pool we close after, so we don't
|
|
||||||
// hold sockets open per peer — the main pool stays in
|
|
||||||
// nostr-transport.ts for the live DM subscription.
|
|
||||||
const pool = new SimplePool();
|
|
||||||
try {
|
|
||||||
const events = await Promise.race([
|
|
||||||
pool.querySync(RELAYS, {
|
|
||||||
kinds: [0],
|
|
||||||
authors: [opts.peer_nostr_pubkey],
|
|
||||||
limit: 5, // grab a few in case relays disagree on `latest`
|
|
||||||
}),
|
|
||||||
new Promise<never>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error("fetchPeerProfile timed out")), FETCH_TIMEOUT_MS),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
if (!events.length) return cached; // nothing to update
|
|
||||||
const latest = events.sort((a, b) => b.created_at - a.created_at)[0];
|
|
||||||
|
|
||||||
let metadata: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
metadata = JSON.parse(latest.content);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`peer-profile: bad kind:0 content for ${opts.peer_primary}`, e);
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile: CachedPeerProfile = {
|
|
||||||
peer_primary: opts.peer_primary,
|
|
||||||
name: typeof metadata.name === "string" ? metadata.name : undefined,
|
|
||||||
about: typeof metadata.about === "string" ? metadata.about : undefined,
|
|
||||||
fetched_at: new Date().toISOString(),
|
|
||||||
source_event_id: latest.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── descramble path ────────────────────────────────────────
|
|
||||||
// The peer's kind:0 may carry a visually-encrypted picture +
|
|
||||||
// per-recipient key wraps. The map is indexed by recipient
|
|
||||||
// KEZ primary, so we look up OUR primary directly (much
|
|
||||||
// cheaper than openMessage'ing every wrap to find ours).
|
|
||||||
if (
|
|
||||||
metadata.kez_visual_v1 === true &&
|
|
||||||
typeof metadata.picture === "string" &&
|
|
||||||
metadata.kez_visual_keys &&
|
|
||||||
typeof metadata.kez_visual_keys === "object"
|
|
||||||
) {
|
|
||||||
const wraps = metadata.kez_visual_keys as Record<string, unknown>;
|
|
||||||
const myPrimary = identityFromSeed(opts.my_seed).identity;
|
|
||||||
const wrapBlob = wraps[myPrimary];
|
|
||||||
if (wrapBlob) {
|
|
||||||
try {
|
|
||||||
const env = wrapBlob as SealedEnvelope;
|
|
||||||
const plaintext = await openMessage({
|
|
||||||
envelope: env,
|
|
||||||
myHandle: opts.my_handle,
|
|
||||||
mySeed: opts.my_seed,
|
|
||||||
});
|
|
||||||
const parsed = JSON.parse(plaintext.body) as { visual_key?: string };
|
|
||||||
if (parsed.visual_key) {
|
|
||||||
const keyBytes = hexToBytes(parsed.visual_key);
|
|
||||||
const descrambled = await unscrambleImage(metadata.picture, keyBytes);
|
|
||||||
profile.picture = descrambled;
|
|
||||||
profile.picture_was_encrypted = true;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Wrap exists but didn't open — log once for diagnostics
|
|
||||||
// but don't fail the fetch; the user just falls back to
|
|
||||||
// the identicon for this peer.
|
|
||||||
console.warn(
|
|
||||||
`peer-profile: descramble failed for ${opts.peer_primary}`,
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No wrap for us → the peer hasn't given our contact key
|
|
||||||
// access to this picture yet. Strangers fall through here too.
|
|
||||||
} else if (typeof metadata.picture === "string") {
|
|
||||||
// Cleartext picture path.
|
|
||||||
profile.picture = metadata.picture;
|
|
||||||
profile.picture_was_encrypted = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist + mirror.
|
|
||||||
const c = await readCache();
|
|
||||||
c[opts.peer_primary] = profile;
|
|
||||||
await writeCache(c);
|
|
||||||
memCache[opts.peer_primary] = profile;
|
|
||||||
return profile;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`peer-profile: fetch failed for ${opts.peer_primary}`, e);
|
|
||||||
return cached;
|
|
||||||
} finally {
|
|
||||||
pool.close(RELAYS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,306 +0,0 @@
|
|||||||
// Long-lived session persistence.
|
|
||||||
//
|
|
||||||
// Problem: the in-memory `session.unlocked` reactive store is lost
|
|
||||||
// whenever the tab process dies — and on Android Chrome a backgrounded
|
|
||||||
// PWA gets killed aggressively. That means re-typing the passphrase
|
|
||||||
// every time you bring kez-chat back to the foreground, which makes
|
|
||||||
// Web Push effectively useless (the user is never in a state where the
|
|
||||||
// push could even arrive without first re-unlocking).
|
|
||||||
//
|
|
||||||
// Fix: on successful unlock, encrypt the 32-byte seed under a fresh
|
|
||||||
// AES-GCM key that lives in IndexedDB as a **non-extractable** CryptoKey.
|
|
||||||
// The wrapped blob + an expiry timestamp go into localStorage. On boot,
|
|
||||||
// if the entry is still fresh, we open the CryptoKey, decrypt the blob,
|
|
||||||
// and rebuild the session — zero user interaction.
|
|
||||||
//
|
|
||||||
// Trust model:
|
|
||||||
// • The CryptoKey is marked non-extractable: WebCrypto refuses to
|
|
||||||
// export it. An attacker who copies the IDB file off-device can't
|
|
||||||
// decrypt the blob, because they can't move the key with it.
|
|
||||||
// • An attacker who can run JS in the origin (root device, malicious
|
|
||||||
// extension) CAN call decrypt. So this is no weaker than the
|
|
||||||
// biometric-unlock path that already ships — and stronger than
|
|
||||||
// plaintext sessionStorage which we deliberately don't use.
|
|
||||||
// • Explicit Lock blows away both the key and the localStorage entry.
|
|
||||||
// • TTL caps damage: a stolen device 31 days later won't auto-unlock.
|
|
||||||
//
|
|
||||||
// Sliding window: every time the SPA boots and successfully auto-unlocks,
|
|
||||||
// we bump the expiry forward. So an active user effectively never sees a
|
|
||||||
// passphrase prompt; an inactive user is asked again after 30 days.
|
|
||||||
|
|
||||||
import type { UnlockedIdentity } from "./identity-store.js";
|
|
||||||
import type { Identity } from "./kez.js";
|
|
||||||
|
|
||||||
const DB_NAME = "kez-chat-session";
|
|
||||||
const DB_VERSION = 1;
|
|
||||||
const STORE_NAME = "keys";
|
|
||||||
const KEY_ID = "session-aes";
|
|
||||||
|
|
||||||
const LS_KEY = "kez-chat:session-blob:v1";
|
|
||||||
|
|
||||||
/** How long an unlocked session survives before re-prompting for the
|
|
||||||
* passphrase. Slides forward every time the user actually opens the
|
|
||||||
* app, so this is effectively only a "I haven't touched it in 30 days"
|
|
||||||
* guard. Tune via `setSessionTtl()` if you want shorter/longer. */
|
|
||||||
const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
interface PersistedBlob {
|
|
||||||
/** Schema marker so we can evolve the format. */
|
|
||||||
v: 1;
|
|
||||||
/** The non-extractable CryptoKey's IDB key. Lets us evolve to per-handle
|
|
||||||
* keys later without breaking older blobs. */
|
|
||||||
keyId: string;
|
|
||||||
/** Unix ms after which this blob must NOT be auto-decrypted, regardless
|
|
||||||
* of whether the AES key is still usable. */
|
|
||||||
expiresAt: number;
|
|
||||||
handle: string;
|
|
||||||
server: string;
|
|
||||||
primary: string; // Identity in string form ("ed25519:hex")
|
|
||||||
iv: string; // base64 (no padding) of the 12-byte AES-GCM IV
|
|
||||||
ciphertext: string; // base64 (no padding) of seed ciphertext + tag
|
|
||||||
/** Optional encrypted recovery phrase, so the Settings "reveal phrase"
|
|
||||||
* path keeps working without a fresh passphrase prompt. Encrypted under
|
|
||||||
* the same AES key but with a distinct IV. */
|
|
||||||
phraseIv?: string;
|
|
||||||
phraseCiphertext?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── IndexedDB helpers ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function openDb(): Promise<IDBDatabase> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
||||||
req.onupgradeneeded = () => {
|
|
||||||
const db = req.result;
|
|
||||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
||||||
db.createObjectStore(STORE_NAME);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
req.onsuccess = () => resolve(req.result);
|
|
||||||
req.onerror = () => reject(req.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function idbGet<T>(key: string): Promise<T | undefined> {
|
|
||||||
return openDb().then(
|
|
||||||
(db) =>
|
|
||||||
new Promise<T | undefined>((resolve, reject) => {
|
|
||||||
const tx = db.transaction(STORE_NAME, "readonly");
|
|
||||||
const req = tx.objectStore(STORE_NAME).get(key);
|
|
||||||
req.onsuccess = () => resolve(req.result as T | undefined);
|
|
||||||
req.onerror = () => reject(req.error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function idbPut(key: string, value: unknown): Promise<void> {
|
|
||||||
return openDb().then(
|
|
||||||
(db) =>
|
|
||||||
new Promise<void>((resolve, reject) => {
|
|
||||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
||||||
tx.objectStore(STORE_NAME).put(value, key);
|
|
||||||
tx.oncomplete = () => resolve();
|
|
||||||
tx.onerror = () => reject(tx.error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function idbDelete(key: string): Promise<void> {
|
|
||||||
return openDb().then(
|
|
||||||
(db) =>
|
|
||||||
new Promise<void>((resolve, reject) => {
|
|
||||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
||||||
tx.objectStore(STORE_NAME).delete(key);
|
|
||||||
tx.oncomplete = () => resolve();
|
|
||||||
tx.onerror = () => reject(tx.error);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── base64 (small, dependency-free) ───────────────────────────────────────
|
|
||||||
|
|
||||||
function b64(bytes: Uint8Array): string {
|
|
||||||
let bin = "";
|
|
||||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
||||||
return btoa(bin).replace(/=+$/, "");
|
|
||||||
}
|
|
||||||
function fromB64(s: string): Uint8Array {
|
|
||||||
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
|
|
||||||
const bin = atob(s + pad);
|
|
||||||
const out = new Uint8Array(bin.length);
|
|
||||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AES key lifecycle ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Get the session AES key, generating + persisting it on first use. */
|
|
||||||
async function getOrCreateKey(): Promise<CryptoKey> {
|
|
||||||
const existing = await idbGet<CryptoKey>(KEY_ID);
|
|
||||||
if (existing) return existing;
|
|
||||||
// The KEY argument `extractable=false` is the whole security story:
|
|
||||||
// even raw filesystem access to the IDB cannot pull this key out.
|
|
||||||
const key = await crypto.subtle.generateKey(
|
|
||||||
{ name: "AES-GCM", length: 256 },
|
|
||||||
/* extractable */ false,
|
|
||||||
["encrypt", "decrypt"],
|
|
||||||
);
|
|
||||||
await idbPut(KEY_ID, key);
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── public API ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist the current unlocked session so a later page load (or app
|
|
||||||
* relaunch after Android killed the PWA) can restore it without
|
|
||||||
* re-prompting for the passphrase. Idempotent — call after every
|
|
||||||
* unlock; safe to call again on visibility-change to bump the
|
|
||||||
* sliding-window expiry.
|
|
||||||
*/
|
|
||||||
export async function persistSession(
|
|
||||||
id: UnlockedIdentity,
|
|
||||||
ttlMs: number = DEFAULT_TTL_MS,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const key = await getOrCreateKey();
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
// Casts side-step a TS DOM-lib quirk where the strict
|
|
||||||
// ArrayBufferView<ArrayBuffer> type doesn't accept our generic
|
|
||||||
// Uint8Array<ArrayBufferLike>; the runtime is happy with both.
|
|
||||||
const seedCt = new Uint8Array(
|
|
||||||
await crypto.subtle.encrypt(
|
|
||||||
{ name: "AES-GCM", iv: iv as BufferSource },
|
|
||||||
key,
|
|
||||||
id.seed as BufferSource,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let phraseIv: string | undefined;
|
|
||||||
let phraseCt: string | undefined;
|
|
||||||
if (id.phrase) {
|
|
||||||
const pIv = crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
const pCt = new Uint8Array(
|
|
||||||
await crypto.subtle.encrypt(
|
|
||||||
{ name: "AES-GCM", iv: pIv as BufferSource },
|
|
||||||
key,
|
|
||||||
new TextEncoder().encode(id.phrase) as BufferSource,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
phraseIv = b64(pIv);
|
|
||||||
phraseCt = b64(pCt);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob: PersistedBlob = {
|
|
||||||
v: 1,
|
|
||||||
keyId: KEY_ID,
|
|
||||||
expiresAt: Date.now() + ttlMs,
|
|
||||||
handle: id.handle,
|
|
||||||
server: id.server,
|
|
||||||
primary: id.primary,
|
|
||||||
iv: b64(iv),
|
|
||||||
ciphertext: b64(seedCt),
|
|
||||||
phraseIv,
|
|
||||||
phraseCiphertext: phraseCt,
|
|
||||||
};
|
|
||||||
localStorage.setItem(LS_KEY, JSON.stringify(blob));
|
|
||||||
} catch (e) {
|
|
||||||
// Never let session persistence failure break the unlock flow.
|
|
||||||
// Worst case: user has the same short session they had before.
|
|
||||||
console.warn("persistSession failed (continuing unauthenticated-persist):", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to restore a previously persisted session. Returns the
|
|
||||||
* unlocked identity or null if there's nothing to restore (no blob,
|
|
||||||
* expired, key missing, or decrypt failed). Bumps the expiry on
|
|
||||||
* success so an active user effectively never re-prompts.
|
|
||||||
*/
|
|
||||||
export async function restoreSession(): Promise<UnlockedIdentity | null> {
|
|
||||||
let parsed: PersistedBlob | null = null;
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LS_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
parsed = JSON.parse(raw) as PersistedBlob;
|
|
||||||
if (parsed.v !== 1) return null;
|
|
||||||
if (Date.now() > parsed.expiresAt) {
|
|
||||||
// TTL elapsed — drop the blob; user will be prompted for the passphrase.
|
|
||||||
await clearPersistedSession();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const key = await idbGet<CryptoKey>(parsed.keyId);
|
|
||||||
if (!key) {
|
|
||||||
// Key is gone (user wiped browser data, profile mismatch, etc.);
|
|
||||||
// blob is unusable — drop it.
|
|
||||||
await clearPersistedSession();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const seedBytes = new Uint8Array(
|
|
||||||
await crypto.subtle.decrypt(
|
|
||||||
{ name: "AES-GCM", iv: fromB64(parsed.iv) as BufferSource },
|
|
||||||
key,
|
|
||||||
fromB64(parsed.ciphertext) as BufferSource,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let phrase: string | undefined;
|
|
||||||
if (parsed.phraseIv && parsed.phraseCiphertext) {
|
|
||||||
try {
|
|
||||||
const phraseBytes = new Uint8Array(
|
|
||||||
await crypto.subtle.decrypt(
|
|
||||||
{ name: "AES-GCM", iv: fromB64(parsed.phraseIv) as BufferSource },
|
|
||||||
key,
|
|
||||||
fromB64(parsed.phraseCiphertext) as BufferSource,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
phrase = new TextDecoder().decode(phraseBytes);
|
|
||||||
} catch {
|
|
||||||
// Phrase decrypt failure is non-fatal; seed unlocked OK.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const restored: UnlockedIdentity = {
|
|
||||||
handle: parsed.handle,
|
|
||||||
server: parsed.server,
|
|
||||||
primary: parsed.primary as Identity,
|
|
||||||
seed: seedBytes,
|
|
||||||
phrase,
|
|
||||||
};
|
|
||||||
// Sliding window: every successful auto-unlock bumps the expiry.
|
|
||||||
await persistSession(restored);
|
|
||||||
return restored;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("restoreSession failed:", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wipe the persisted session — invoked by explicit Lock, and also by
|
|
||||||
* restoreSession's expired-TTL / key-missing branches. Safe to call
|
|
||||||
* with nothing persisted.
|
|
||||||
*/
|
|
||||||
export async function clearPersistedSession(): Promise<void> {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(LS_KEY);
|
|
||||||
await idbDelete(KEY_ID);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("clearPersistedSession failed:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Does a persisted, non-expired session blob exist? Cheap check —
|
|
||||||
* doesn't touch IndexedDB. Useful for UI ("Welcome back" hint).
|
|
||||||
*/
|
|
||||||
export function hasPersistedSession(): boolean {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(LS_KEY);
|
|
||||||
if (!raw) return false;
|
|
||||||
const parsed = JSON.parse(raw) as PersistedBlob;
|
|
||||||
return parsed.v === 1 && Date.now() <= parsed.expiresAt;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,387 +0,0 @@
|
|||||||
// Local user-profile state — the profile picture the user picked in
|
|
||||||
// Settings and any other display metadata we add later (name, about).
|
|
||||||
//
|
|
||||||
// Two halves:
|
|
||||||
// 1. Local persistence (IndexedDB via idb-keyval) so the picture
|
|
||||||
// survives reloads and is available everywhere the Avatar
|
|
||||||
// component renders.
|
|
||||||
// 2. Nostr publish: when the user sets / changes their picture, we
|
|
||||||
// emit a NIP-01 kind:0 metadata event from their derived nostr
|
|
||||||
// key. This is the standard nostr profile shape (every nostr
|
|
||||||
// client recognises it). Other kez-chat users (later) will
|
|
||||||
// subscribe to kind:0 events for their peers to fetch peer
|
|
||||||
// avatars.
|
|
||||||
//
|
|
||||||
// Storage shape:
|
|
||||||
//
|
|
||||||
// StoredProfile = {
|
|
||||||
// picture?: string; // data URL, JPEG, 256×256
|
|
||||||
// name?: string; // future — currently mirrored from handle
|
|
||||||
// about?: string; // future
|
|
||||||
// updated_at: string; // ISO timestamp
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// Why not a Svelte 5 $state class? Because this file is imported by
|
|
||||||
// non-component code (the publish path) and we want a plain TS module
|
|
||||||
// surface. Components subscribe via the small `useMyProfile()`
|
|
||||||
// helper which keeps a $state cell in sync.
|
|
||||||
|
|
||||||
import { get, set, del } from "idb-keyval";
|
|
||||||
import { hexToBytes } from "@noble/hashes/utils";
|
|
||||||
import {
|
|
||||||
finalizeEvent,
|
|
||||||
getPublicKey,
|
|
||||||
SimplePool,
|
|
||||||
type EventTemplate,
|
|
||||||
} from "nostr-tools";
|
|
||||||
import { openMessage, sealMessage, type SealedEnvelope } from "./crypto.js";
|
|
||||||
import { nostrSecretFromSeed } from "./nostr-id.js";
|
|
||||||
import { scrambleImage, unscrambleImage } from "./visual-crypto.js";
|
|
||||||
import { listConversations } from "./conversations-store.js";
|
|
||||||
import type { Identity } from "./kez.js";
|
|
||||||
|
|
||||||
const PROFILE_KEY = "kez-chat:my-profile:v1";
|
|
||||||
|
|
||||||
/** Same default-relay list as nostr-transport.ts. Profile publish is
|
|
||||||
* best-effort; if relays change, we just re-publish on next save. */
|
|
||||||
const RELAYS: string[] = (
|
|
||||||
(import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ??
|
|
||||||
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
|
|
||||||
)
|
|
||||||
.split(",")
|
|
||||||
.map((r) => r.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
export interface StoredProfile {
|
|
||||||
/** The picture WE see locally — always cleartext. When `encrypted`
|
|
||||||
* is true this is what we render in our own Avatar; the nostr
|
|
||||||
* publish carries the scrambled version. */
|
|
||||||
picture?: string;
|
|
||||||
/**
|
|
||||||
* True when this profile is set to visually-encrypt the picture
|
|
||||||
* before publishing. Defaults to true on new profiles — the
|
|
||||||
* opinionated stance is "your face is private unless you opt out".
|
|
||||||
* Only contacts who've been keyed in can descramble.
|
|
||||||
*/
|
|
||||||
encrypted?: boolean;
|
|
||||||
/**
|
|
||||||
* The 32-byte symmetric key used to scramble `picture`, hex-encoded.
|
|
||||||
* Local-only; never published cleartext. Each contact receives an
|
|
||||||
* individually-wrapped copy of this key embedded in our kind:0
|
|
||||||
* event content. When the picture changes, this key is rerolled.
|
|
||||||
*/
|
|
||||||
picture_key?: string;
|
|
||||||
/** Display name. For now we mirror the handle; future: separate. */
|
|
||||||
name?: string;
|
|
||||||
/** Short bio (NIP-01 calls this `about`). Not surfaced yet. */
|
|
||||||
about?: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadMyProfile(): Promise<StoredProfile | null> {
|
|
||||||
return (await get<StoredProfile>(PROFILE_KEY)) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveMyProfile(profile: StoredProfile): Promise<void> {
|
|
||||||
await set(PROFILE_KEY, profile);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearMyProfile(): Promise<void> {
|
|
||||||
await del(PROFILE_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a minimal kind:0 metadata event the first time we sign in
|
|
||||||
* on this device. Some relays silently drop writes from pubkeys with
|
|
||||||
* no kind:0 ("unknown author" rejection); a single tiny publish on
|
|
||||||
* first use unblocks subsequent DM publishes for new users who haven't
|
|
||||||
* set a picture yet. Idempotent: cached flag in localStorage means we
|
|
||||||
* only do it once. TODO.md Day 3 Option B #12.
|
|
||||||
*
|
|
||||||
* Also publishes a NIP-65 (kind:10002) "relay list metadata" event in
|
|
||||||
* the same shot — listing our 3 default relays as read+write. NIP-65-
|
|
||||||
* aware clients (Damus, Amethyst, etc.) use this to know where to
|
|
||||||
* reach us. TODO.md Day 3 Option B #10.
|
|
||||||
*/
|
|
||||||
const BASELINE_PUBLISHED_KEY = "kez-chat:nostr-baseline-published:v1";
|
|
||||||
|
|
||||||
export async function publishKind0BaselineIfNeeded(
|
|
||||||
seed: Uint8Array,
|
|
||||||
handle: string,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (localStorage.getItem(BASELINE_PUBLISHED_KEY) === "1") return;
|
|
||||||
} catch {
|
|
||||||
/* private mode — proceed anyway, worst case we publish once per tab */
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const sk = nostrSecretFromSeed(seed);
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
// 1. Minimal kind:0 (TODO.md Day 3 Option B #12).
|
|
||||||
const kind0: EventTemplate = {
|
|
||||||
kind: 0,
|
|
||||||
created_at: now,
|
|
||||||
tags: [],
|
|
||||||
content: JSON.stringify({ name: handle }),
|
|
||||||
};
|
|
||||||
const signedKind0 = finalizeEvent(kind0, sk);
|
|
||||||
|
|
||||||
// 2. NIP-65 kind:10002 relay list (TODO.md Day 3 Option B #10).
|
|
||||||
// Tag format: ["r", "wss://...", marker?]. Marker = "read",
|
|
||||||
// "write", or omitted (meaning both). For v0.1 we say all 3
|
|
||||||
// of our relays are read+write — when we later support
|
|
||||||
// per-relay specialisation we'll split.
|
|
||||||
const kind10002: EventTemplate = {
|
|
||||||
kind: 10002,
|
|
||||||
created_at: now,
|
|
||||||
tags: RELAYS.map((url) => ["r", url]),
|
|
||||||
content: "",
|
|
||||||
};
|
|
||||||
const signedKind10002 = finalizeEvent(kind10002, sk);
|
|
||||||
|
|
||||||
const pool = new SimplePool();
|
|
||||||
try {
|
|
||||||
await Promise.allSettled(pool.publish(RELAYS, signedKind0));
|
|
||||||
await Promise.allSettled(pool.publish(RELAYS, signedKind10002));
|
|
||||||
} finally {
|
|
||||||
pool.close(RELAYS);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
localStorage.setItem(BASELINE_PUBLISHED_KEY, "1");
|
|
||||||
} catch {
|
|
||||||
/* private mode */
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("publishKind0BaselineIfNeeded failed:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the user's OWN published kind:0 from nostr, descramble using
|
|
||||||
* the self-wrap, and return a StoredProfile ready to save. Used on a
|
|
||||||
* fresh device (no local IDB picture) so the user sees their face
|
|
||||||
* everywhere immediately after unlock — no "set it again on every
|
|
||||||
* device" friction.
|
|
||||||
*
|
|
||||||
* Returns null if there's no published profile, or if descrambling
|
|
||||||
* fails (e.g., this device's seed somehow doesn't match the wrap —
|
|
||||||
* shouldn't happen, but never crash on it).
|
|
||||||
*/
|
|
||||||
export async function fetchMyProfileFromNostr(
|
|
||||||
seed: Uint8Array,
|
|
||||||
myPrimary: Identity,
|
|
||||||
myHandle: string,
|
|
||||||
): Promise<StoredProfile | null> {
|
|
||||||
try {
|
|
||||||
const sk = nostrSecretFromSeed(seed);
|
|
||||||
const myNostrPubkey = getPublicKey(sk);
|
|
||||||
|
|
||||||
const pool = new SimplePool();
|
|
||||||
let events: { id: string; content: string; created_at: number }[] = [];
|
|
||||||
try {
|
|
||||||
const result = await Promise.race([
|
|
||||||
pool.querySync(RELAYS, {
|
|
||||||
kinds: [0],
|
|
||||||
authors: [myNostrPubkey],
|
|
||||||
limit: 3,
|
|
||||||
}),
|
|
||||||
new Promise<never>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error("fetchMyProfile timed out")), 8000),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
events = result;
|
|
||||||
} finally {
|
|
||||||
pool.close(RELAYS);
|
|
||||||
}
|
|
||||||
if (!events.length) return null;
|
|
||||||
const latest = events.sort((a, b) => b.created_at - a.created_at)[0];
|
|
||||||
|
|
||||||
let metadata: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
metadata = JSON.parse(latest.content);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("fetchMyProfileFromNostr: bad JSON in kind:0 content", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile: StoredProfile = {
|
|
||||||
updated_at: new Date(latest.created_at * 1000).toISOString(),
|
|
||||||
name: typeof metadata.name === "string" ? metadata.name : undefined,
|
|
||||||
about: typeof metadata.about === "string" ? metadata.about : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── encrypted-self path ───────────────────────────────────
|
|
||||||
if (
|
|
||||||
metadata.kez_visual_v1 === true &&
|
|
||||||
typeof metadata.picture === "string" &&
|
|
||||||
metadata.kez_visual_keys &&
|
|
||||||
typeof metadata.kez_visual_keys === "object"
|
|
||||||
) {
|
|
||||||
const wraps = metadata.kez_visual_keys as Record<string, unknown>;
|
|
||||||
const selfWrap = wraps[myPrimary];
|
|
||||||
if (selfWrap) {
|
|
||||||
try {
|
|
||||||
const env = selfWrap as SealedEnvelope;
|
|
||||||
const plaintext = await openMessage({
|
|
||||||
envelope: env,
|
|
||||||
myHandle,
|
|
||||||
mySeed: seed,
|
|
||||||
});
|
|
||||||
const parsed = JSON.parse(plaintext.body) as { visual_key?: string };
|
|
||||||
if (parsed.visual_key) {
|
|
||||||
const keyBytes = hexToBytes(parsed.visual_key);
|
|
||||||
profile.picture = await unscrambleImage(metadata.picture, keyBytes);
|
|
||||||
profile.encrypted = true;
|
|
||||||
profile.picture_key = parsed.visual_key;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Self-wrap exists but failed to open — log and continue
|
|
||||||
// with whatever we have (name, about). The user can re-set
|
|
||||||
// their picture if they want to refresh it.
|
|
||||||
console.warn("fetchMyProfileFromNostr: self-wrap open failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// (No `else` here — if the encrypted picture has no self-wrap
|
|
||||||
// available, the user simply re-sets their picture on this
|
|
||||||
// device to populate one.)
|
|
||||||
} else if (typeof metadata.picture === "string") {
|
|
||||||
profile.picture = metadata.picture;
|
|
||||||
profile.encrypted = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return profile;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("fetchMyProfileFromNostr failed:", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a kind:0 metadata event so any nostr client (and any
|
|
||||||
* future kez-chat client) can find this user's profile picture and
|
|
||||||
* display name. NIP-01:
|
|
||||||
*
|
|
||||||
* { kind: 0,
|
|
||||||
* content: JSON.stringify({ name, picture, about, ... }),
|
|
||||||
* tags: [],
|
|
||||||
* ... }
|
|
||||||
*
|
|
||||||
* Fails silently — push to logs only. The picture is already
|
|
||||||
* stored locally; nostr publish is just for peer discovery.
|
|
||||||
*
|
|
||||||
* Returns the published event id so callers can confirm.
|
|
||||||
*/
|
|
||||||
export async function publishMyProfile(
|
|
||||||
seed: Uint8Array,
|
|
||||||
senderPrimary: Identity,
|
|
||||||
// `senderHandle` is here for symmetry with sealMessage (which uses
|
|
||||||
// it as the recipientHandle on outgoing DMs). For profile key
|
|
||||||
// wraps we don't reference our OWN handle, so it's currently
|
|
||||||
// unused. Kept in the signature so a future call (e.g. signing
|
|
||||||
// the wrap with our handle as AAD) doesn't break the surface.
|
|
||||||
_senderHandle: string,
|
|
||||||
profile: StoredProfile,
|
|
||||||
): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const sk = nostrSecretFromSeed(seed);
|
|
||||||
const metadata: Record<string, unknown> = {};
|
|
||||||
if (profile.name) metadata.name = profile.name;
|
|
||||||
if (profile.about) metadata.about = profile.about;
|
|
||||||
|
|
||||||
// ─── encrypted picture path ─────────────────────────────────
|
|
||||||
// If the user opted to encrypt (default), scramble the picture
|
|
||||||
// under `picture_key` and publish the scrambled image as the
|
|
||||||
// standard `picture` field — clients that don't understand
|
|
||||||
// kez-visual-v1 will just render colored noise, which is the
|
|
||||||
// entire point. The descramble key is wrapped per-contact in
|
|
||||||
// a custom `kez_visual_keys` map: { <contact_primary>: <sealed
|
|
||||||
// envelope containing { key } > }.
|
|
||||||
if (
|
|
||||||
profile.encrypted &&
|
|
||||||
profile.picture &&
|
|
||||||
profile.picture_key
|
|
||||||
) {
|
|
||||||
const keyBytes = hexToBytes(profile.picture_key);
|
|
||||||
const scrambled = await scrambleImage(profile.picture, keyBytes);
|
|
||||||
metadata.picture = scrambled;
|
|
||||||
// Mark the picture as kez-encrypted so a forward-compatible
|
|
||||||
// client (or a future Damus plugin) knows what we did.
|
|
||||||
metadata.kez_visual_v1 = true;
|
|
||||||
|
|
||||||
// Wrap the picture key for each contact, using the same
|
|
||||||
// SealedEnvelope crypto our DMs already use (so we know it
|
|
||||||
// works + the threat model is already understood). The
|
|
||||||
// envelope's PLAINTEXT is just the visual key, hex-encoded.
|
|
||||||
const contacts = await listConversations();
|
|
||||||
const wraps: Record<string, unknown> = {};
|
|
||||||
const wrapBody = JSON.stringify({ visual_key: profile.picture_key });
|
|
||||||
|
|
||||||
// ─── self-wrap ────────────────────────────────────────────
|
|
||||||
// Always include a wrap to OURSELVES. Without this, opening
|
|
||||||
// kez-chat on a fresh device (no local IDB) means we publish
|
|
||||||
// a scrambled picture we can't read back — even though we
|
|
||||||
// own the seed. Self-wrap lets `fetchMyProfile` on a new
|
|
||||||
// device descramble its own kind:0 and rehydrate the
|
|
||||||
// picture_key.
|
|
||||||
try {
|
|
||||||
const selfEnv = await sealMessage({
|
|
||||||
senderSeed: seed,
|
|
||||||
senderPrimary,
|
|
||||||
recipientHandle: _senderHandle || "self",
|
|
||||||
recipientPrimary: senderPrimary,
|
|
||||||
body: wrapBody,
|
|
||||||
});
|
|
||||||
wraps[senderPrimary] = selfEnv;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("profile: self-wrap failed (own-device decrypt won't work)", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const conv of contacts) {
|
|
||||||
// Skip if this is us — already self-wrapped above.
|
|
||||||
if (conv.peer_primary === senderPrimary) continue;
|
|
||||||
try {
|
|
||||||
const env = await sealMessage({
|
|
||||||
senderSeed: seed,
|
|
||||||
senderPrimary,
|
|
||||||
recipientHandle: conv.peer_handle || conv.peer_primary,
|
|
||||||
recipientPrimary: conv.peer_primary,
|
|
||||||
body: wrapBody,
|
|
||||||
});
|
|
||||||
wraps[conv.peer_primary] = env;
|
|
||||||
} catch (e) {
|
|
||||||
// A single bad contact (e.g. unparseable primary) shouldn't
|
|
||||||
// stop the whole publish — just skip and continue.
|
|
||||||
console.warn(`profile: wrap key for ${conv.peer_primary} failed`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(wraps).length > 0) {
|
|
||||||
metadata.kez_visual_keys = wraps;
|
|
||||||
}
|
|
||||||
} else if (profile.picture) {
|
|
||||||
// Cleartext picture — user explicitly opted out of encryption.
|
|
||||||
metadata.picture = profile.picture;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tmpl: EventTemplate = {
|
|
||||||
kind: 0,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [],
|
|
||||||
content: JSON.stringify(metadata),
|
|
||||||
};
|
|
||||||
const signed = finalizeEvent(tmpl, sk);
|
|
||||||
|
|
||||||
// Best-effort fan-out. Profile pictures aren't safety-critical;
|
|
||||||
// if every relay rejects we still succeeded locally.
|
|
||||||
const pool = new SimplePool();
|
|
||||||
try {
|
|
||||||
await Promise.allSettled(pool.publish(RELAYS, signed));
|
|
||||||
} finally {
|
|
||||||
pool.close(RELAYS);
|
|
||||||
}
|
|
||||||
return signed.id;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("publishMyProfile failed:", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -204,65 +204,6 @@ export async function isPushSubscribed(): Promise<boolean> {
|
|||||||
return sub !== null;
|
return sub !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Self-heal check, run after every unlock + session restore.
|
|
||||||
*
|
|
||||||
* If this device has a local PushSubscription but the chat-server
|
|
||||||
* doesn't know about it (DB lost, 410 cleanup ran, new server, etc.),
|
|
||||||
* silently re-register it. If the device has no local sub at all,
|
|
||||||
* there's nothing to do — the user has to opt in from Settings.
|
|
||||||
*
|
|
||||||
* Returns a brief status string for the caller to log; never throws.
|
|
||||||
*/
|
|
||||||
export async function verifyPushRegistration(
|
|
||||||
handle: string,
|
|
||||||
seed: Uint8Array,
|
|
||||||
): Promise<"ok" | "no-local-sub" | "reregistered" | "unsupported" | "failed"> {
|
|
||||||
if (!pushSupported()) return "unsupported";
|
|
||||||
try {
|
|
||||||
const reg = await navigator.serviceWorker.ready;
|
|
||||||
const localSub = await reg.pushManager.getSubscription();
|
|
||||||
if (!localSub) return "no-local-sub";
|
|
||||||
|
|
||||||
// Ask the server what it has for this handle.
|
|
||||||
const ts = Math.floor(Date.now() / 1000);
|
|
||||||
const msg = `GET\n/v1/push/subscriptions/${handle}\n${ts}`;
|
|
||||||
const sig = ed25519.sign(new TextEncoder().encode(msg), seed);
|
|
||||||
const resp = await fetch(
|
|
||||||
url(`/v1/push/subscriptions/${encodeURIComponent(handle)}`),
|
|
||||||
{
|
|
||||||
headers: { "X-KEZ-Auth": `${ts}:${bytesToHex(sig)}` },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!resp.ok) return "failed";
|
|
||||||
const body = (await resp.json()) as { endpoint_tails: string[] };
|
|
||||||
const myTail = localSub.endpoint.slice(-16);
|
|
||||||
if (body.endpoint_tails.includes(myTail)) {
|
|
||||||
return "ok";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server doesn't know about my sub — re-register without
|
|
||||||
// tearing down the local one (cheaper than full re-subscribe).
|
|
||||||
const payload = subscriptionPayload(localSub);
|
|
||||||
const authHeader = signPushAuth("subscribe", handle, payload.endpoint, seed);
|
|
||||||
const reReg = await fetch(
|
|
||||||
url(`/v1/push/subscribe/${encodeURIComponent(handle)}`),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"X-KEZ-Auth": authHeader,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return reReg.ok ? "reregistered" : "failed";
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("verifyPushRegistration:", e);
|
|
||||||
return "failed";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* iOS PWA detection — Safari only exposes PushManager once installed
|
* iOS PWA detection — Safari only exposes PushManager once installed
|
||||||
* to the home screen. We use this to render a "Tap Share → Add to Home
|
* to the home screen. We use this to render a "Tap Share → Add to Home
|
||||||
|
|||||||
@ -7,246 +7,19 @@
|
|||||||
|
|
||||||
import type { UnlockedIdentity } from "./identity-store.js";
|
import type { UnlockedIdentity } from "./identity-store.js";
|
||||||
import { inboxService } from "./inbox-service.svelte.js";
|
import { inboxService } from "./inbox-service.svelte.js";
|
||||||
import { bytesToHex } from "@noble/hashes/utils";
|
|
||||||
import {
|
|
||||||
fetchMyProfileFromNostr,
|
|
||||||
loadMyProfile,
|
|
||||||
publishKind0BaselineIfNeeded,
|
|
||||||
publishMyProfile,
|
|
||||||
saveMyProfile,
|
|
||||||
type StoredProfile,
|
|
||||||
} from "./profile-store.js";
|
|
||||||
import { generateVisualKey } from "./visual-crypto.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hydrate the user's profile cell on unlock. Tries IDB first; if
|
|
||||||
* empty (fresh device), fetches the user's own kind:0 from nostr
|
|
||||||
* and descrambles via the self-wrap so the avatar lights up
|
|
||||||
* automatically without making the user re-pick their picture per
|
|
||||||
* device.
|
|
||||||
*/
|
|
||||||
async function hydrateMyProfile(id: UnlockedIdentity): Promise<void> {
|
|
||||||
const local = await loadMyProfile();
|
|
||||||
if (local) {
|
|
||||||
session.myProfile = local;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// No local copy — try recovering from nostr. This is also the
|
|
||||||
// path that runs on the FIRST device after a passphrase-restore,
|
|
||||||
// since the persisted seed gives us all we need.
|
|
||||||
const remote = await fetchMyProfileFromNostr(
|
|
||||||
id.seed,
|
|
||||||
id.primary,
|
|
||||||
id.handle,
|
|
||||||
);
|
|
||||||
if (remote) {
|
|
||||||
await saveMyProfile(remote);
|
|
||||||
session.myProfile = remote;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
import {
|
|
||||||
persistSession,
|
|
||||||
clearPersistedSession,
|
|
||||||
restoreSession,
|
|
||||||
} from "./persistent-session.js";
|
|
||||||
import {
|
|
||||||
enablePush,
|
|
||||||
isPushSubscribed,
|
|
||||||
isStandalonePwa,
|
|
||||||
isIos,
|
|
||||||
pushSupported,
|
|
||||||
verifyPushRegistration,
|
|
||||||
} from "./push.js";
|
|
||||||
|
|
||||||
/** Suppression flag set when the user explicitly disables push from
|
|
||||||
* Settings — auto-enable on next unlock would be annoying after
|
|
||||||
* they just opted out. Auto-enable also stops if a previous
|
|
||||||
* permission prompt was denied. */
|
|
||||||
const PUSH_AUTOENABLE_OFF_KEY = "kez-chat:push-autoenable-off";
|
|
||||||
|
|
||||||
async function maybeAutoEnablePush(handle: string, seed: Uint8Array) {
|
|
||||||
try {
|
|
||||||
if (!pushSupported()) return;
|
|
||||||
if (localStorage.getItem(PUSH_AUTOENABLE_OFF_KEY) === "1") return;
|
|
||||||
// iOS only allows push from installed PWAs — the nudge banner
|
|
||||||
// tells the user to add-to-home-screen instead.
|
|
||||||
if (isIos() && !isStandalonePwa()) return;
|
|
||||||
if (Notification.permission === "denied") return;
|
|
||||||
if (await isPushSubscribed()) return;
|
|
||||||
|
|
||||||
// The system permission prompt only appears for permission==="default".
|
|
||||||
// We call enablePush which handles the prompt + server subscribe.
|
|
||||||
// Failures are silent — the in-chat nudge banner is the fallback UI.
|
|
||||||
const ok = await enablePush(handle, seed);
|
|
||||||
if (!ok) {
|
|
||||||
// User clicked "Block" or browser policy denied — don't auto-try
|
|
||||||
// again on next unlock; the banner will let them opt in if they
|
|
||||||
// change their mind.
|
|
||||||
try {
|
|
||||||
localStorage.setItem(PUSH_AUTOENABLE_OFF_KEY, "1");
|
|
||||||
} catch {
|
|
||||||
/* private mode */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("auto-enable push failed:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setPushAutoEnableDisabled(disabled: boolean) {
|
|
||||||
try {
|
|
||||||
if (disabled) localStorage.setItem(PUSH_AUTOENABLE_OFF_KEY, "1");
|
|
||||||
else localStorage.removeItem(PUSH_AUTOENABLE_OFF_KEY);
|
|
||||||
} catch {
|
|
||||||
/* private mode */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Session {
|
class Session {
|
||||||
unlocked = $state<UnlockedIdentity | null>(null);
|
unlocked = $state<UnlockedIdentity | null>(null);
|
||||||
/** True once we've checked persisted storage on boot. Lets the UI
|
|
||||||
* show a brief "restoring…" state instead of flashing the unlock
|
|
||||||
* prompt before auto-unlock has had a chance to run. */
|
|
||||||
bootRestoreChecked = $state(false);
|
|
||||||
/** User's own profile (picture, name). Loaded from IndexedDB on
|
|
||||||
* unlock; null when the user hasn't set one yet. Components that
|
|
||||||
* render the user's Avatar read this and pass `picture` through. */
|
|
||||||
myProfile = $state<StoredProfile | null>(null);
|
|
||||||
|
|
||||||
setUnlocked(id: UnlockedIdentity) {
|
setUnlocked(id: UnlockedIdentity) {
|
||||||
this.unlocked = id;
|
this.unlocked = id;
|
||||||
inboxService.start(id.handle, id.seed);
|
inboxService.start(id.handle, id.seed);
|
||||||
// Hydrate the user's profile cell — IDB first, then nostr
|
|
||||||
// fallback if this is a new device.
|
|
||||||
void hydrateMyProfile(id);
|
|
||||||
// Publish a minimal kind:0 once per device so relays that drop
|
|
||||||
// writes from "unknown" pubkeys accept our DMs going forward.
|
|
||||||
void publishKind0BaselineIfNeeded(id.seed, id.handle);
|
|
||||||
// Fire-and-forget — failure just means the user types the
|
|
||||||
// passphrase again on next launch, not a security problem.
|
|
||||||
void persistSession(id);
|
|
||||||
// Auto-enable Web Push on first unlock on a new device. If push
|
|
||||||
// is already on, this is a no-op via isPushSubscribed(). If the
|
|
||||||
// user opted out from Settings, it respects that.
|
|
||||||
void maybeAutoEnablePush(id.handle, id.seed);
|
|
||||||
// Self-heal Web Push: if the server lost our subscription
|
|
||||||
// (cleanup after 410, DB rebuild, fresh server, etc.) but the
|
|
||||||
// browser still has it locally, silently re-register.
|
|
||||||
void verifyPushRegistration(id.handle, id.seed).then((status) => {
|
|
||||||
if (status === "reregistered") {
|
|
||||||
console.info("push: re-registered subscription with server");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lock() {
|
lock() {
|
||||||
inboxService.stop();
|
inboxService.stop();
|
||||||
this.unlocked = null;
|
this.unlocked = null;
|
||||||
void clearPersistedSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called once on app boot. If a non-expired session blob is in
|
|
||||||
* localStorage and the non-extractable AES key in IndexedDB can
|
|
||||||
* still decrypt it, restore the session straight to "unlocked"
|
|
||||||
* without prompting for the passphrase. */
|
|
||||||
async tryRestoreFromStorage(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const restored = await restoreSession();
|
|
||||||
if (restored) {
|
|
||||||
this.unlocked = restored;
|
|
||||||
inboxService.start(restored.handle, restored.seed);
|
|
||||||
// Hydrate profile after restore — IDB first, nostr fallback
|
|
||||||
// if the local copy is missing (e.g. browser data cleared).
|
|
||||||
void hydrateMyProfile(restored);
|
|
||||||
// Same self-heal + auto-enable behaviour as setUnlocked —
|
|
||||||
// restoring from disk is exactly when we want to confirm the
|
|
||||||
// server still has us on its push fanout list AND (if the
|
|
||||||
// user is on a fresh device) prompt for push permission.
|
|
||||||
void maybeAutoEnablePush(restored.handle, restored.seed);
|
|
||||||
void verifyPushRegistration(restored.handle, restored.seed).then(
|
|
||||||
(status) => {
|
|
||||||
if (status === "reregistered") {
|
|
||||||
console.info("push: re-registered subscription after restore");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
this.bootRestoreChecked = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const session = new Session();
|
export const session = new Session();
|
||||||
|
|
||||||
/**
|
|
||||||
* Patch the user's profile (picture or name): persist locally, push
|
|
||||||
* to nostr (best-effort), and update the reactive cell so every
|
|
||||||
* Avatar repaints. Returns the nostr event id of the published
|
|
||||||
* kind:0 event, or null if publish failed (local save still succeeded).
|
|
||||||
*
|
|
||||||
* Pass `profile.picture = null` (or omit it) to remove the picture.
|
|
||||||
*/
|
|
||||||
export async function setMyProfile(
|
|
||||||
patch: Partial<StoredProfile>,
|
|
||||||
): Promise<string | null> {
|
|
||||||
if (!session.unlocked) throw new Error("not unlocked");
|
|
||||||
const current = session.myProfile ?? { updated_at: new Date().toISOString() };
|
|
||||||
// Default to encrypted=true on the first save — the opinionated
|
|
||||||
// privacy default the user explicitly asked for ("make the
|
|
||||||
// encrypted option default"). Subsequent saves keep whatever the
|
|
||||||
// user toggled.
|
|
||||||
const encrypted =
|
|
||||||
patch.encrypted !== undefined
|
|
||||||
? patch.encrypted
|
|
||||||
: (current.encrypted ?? true);
|
|
||||||
|
|
||||||
// Visual-key state machine: never reuse a key across pictures
|
|
||||||
// (so cryptanalysis of one image can't carry to the next), always
|
|
||||||
// have a key when one is needed, never keep a stale key around.
|
|
||||||
let picture_key = current.picture_key;
|
|
||||||
const pictureChanged = "picture" in patch;
|
|
||||||
const nextPicture = pictureChanged ? patch.picture : current.picture;
|
|
||||||
if (pictureChanged) {
|
|
||||||
if (!patch.picture) {
|
|
||||||
// User removed the picture — drop the key.
|
|
||||||
picture_key = undefined;
|
|
||||||
} else if (encrypted) {
|
|
||||||
// New picture replacing an old one (or no previous picture) —
|
|
||||||
// mint a fresh key.
|
|
||||||
picture_key = bytesToHex(generateVisualKey());
|
|
||||||
} else {
|
|
||||||
// Cleartext picture; no key needed.
|
|
||||||
picture_key = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Edge: user flipped encryption ON while a picture was already
|
|
||||||
// set — mint a key for the existing picture.
|
|
||||||
if (encrypted && nextPicture && !picture_key) {
|
|
||||||
picture_key = bytesToHex(generateVisualKey());
|
|
||||||
}
|
|
||||||
// Edge: user flipped encryption OFF — drop the now-unused key.
|
|
||||||
if (!encrypted && picture_key) {
|
|
||||||
picture_key = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged: StoredProfile = {
|
|
||||||
...current,
|
|
||||||
...patch,
|
|
||||||
encrypted,
|
|
||||||
picture_key,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
// Local write first — guarantees the picture is saved even if we
|
|
||||||
// can't reach a single relay.
|
|
||||||
await saveMyProfile(merged);
|
|
||||||
session.myProfile = merged;
|
|
||||||
return await publishMyProfile(
|
|
||||||
session.unlocked.seed,
|
|
||||||
session.unlocked.primary,
|
|
||||||
session.unlocked.handle,
|
|
||||||
merged,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -21,33 +21,8 @@ export const sendMessage = impl.sendMessage;
|
|||||||
export const pollInbox = impl.pollInbox;
|
export const pollInbox = impl.pollInbox;
|
||||||
export const streamInbox = impl.streamInbox;
|
export const streamInbox = impl.streamInbox;
|
||||||
export const decrypt = impl.decrypt;
|
export const decrypt = impl.decrypt;
|
||||||
/** Publish a delivery receipt for a message we just decrypted, so the
|
|
||||||
* original sender's UI can flip the bubble from "sent" to "delivered".
|
|
||||||
* No-op on the server transport for now. */
|
|
||||||
export const sendAck = impl.sendAck;
|
|
||||||
/** Retry any acks that failed to publish on first attempt. Called on
|
|
||||||
* session start so a flaky relay moment doesn't permanently strand
|
|
||||||
* receipts. */
|
|
||||||
export const flushPendingAcks = impl.flushPendingAcks;
|
|
||||||
/** Catch-up query: given a list of recently-sent event ids, returns
|
|
||||||
* the subset for which a recipient ack has been published. Lets the
|
|
||||||
* sender's UI self-heal "delivered" state on conversation open
|
|
||||||
* instead of relying solely on the live stream. */
|
|
||||||
export const fetchAcksForEventIds = impl.fetchAcksForEventIds;
|
|
||||||
/** Wire the user's seed into the relay pool so NIP-42 AUTH challenges
|
|
||||||
* get signed transparently. No-op on the server transport. */
|
|
||||||
export const attachSigner = impl.attachSigner;
|
|
||||||
export const detachSigner = impl.detachSigner;
|
|
||||||
/** Send a file (image or other). Inline if ≤80KB, chunked otherwise.
|
|
||||||
* Throws on the server transport (not implemented). */
|
|
||||||
export const sendFile = impl.sendFile;
|
|
||||||
/** Snapshot of every configured relay (or the single chat-server) +
|
|
||||||
* whether the socket is currently open. Drives the "● live (N)"
|
|
||||||
* indicator and its popover. */
|
|
||||||
export const getRelayStatuses = impl.getRelayStatuses;
|
|
||||||
|
|
||||||
export type { InboxMessage, StreamHandle, SealedEnvelope, MessagePlaintext } from "./messages.js";
|
export type { InboxMessage, StreamHandle, SealedEnvelope, MessagePlaintext } from "./messages.js";
|
||||||
export type { RelayStatus } from "./messages.js";
|
|
||||||
|
|
||||||
/** Which transport this build is using — handy for a debug line in the UI. */
|
/** Which transport this build is using — handy for a debug line in the UI. */
|
||||||
export const activeTransport = TRANSPORT;
|
export const activeTransport = TRANSPORT;
|
||||||
|
|||||||
@ -1,282 +0,0 @@
|
|||||||
// Visually-encrypted images.
|
|
||||||
//
|
|
||||||
// The idea: an image scrambled under a symmetric key still LOOKS like
|
|
||||||
// an image — same dimensions, same approximate color distribution,
|
|
||||||
// just rearranged pixels. To a stranger it's colored noise; to anyone
|
|
||||||
// holding the key it descrambles to the original.
|
|
||||||
//
|
|
||||||
// We use this for profile pictures so the kind:0 metadata event we
|
|
||||||
// publish to public nostr relays doesn't expose every user's face to
|
|
||||||
// the entire network. Only contacts who have been given the picture
|
|
||||||
// key (via per-recipient AES wraps embedded in the kind:0 content)
|
|
||||||
// can descramble.
|
|
||||||
//
|
|
||||||
// Algorithm:
|
|
||||||
// • Pixel-permutation cipher. Key + image-hash → seed a ChaCha-style
|
|
||||||
// PRNG → produce a Fisher-Yates shuffle of pixel positions →
|
|
||||||
// permute the RGBA buffer. Reverse permutation = decryption.
|
|
||||||
// • Output is PNG (lossless). JPEG re-encoding would destroy the
|
|
||||||
// permutation, but nostr relays don't transcode event content so
|
|
||||||
// we stay safe.
|
|
||||||
//
|
|
||||||
// What this protects against:
|
|
||||||
// • Random scrapers / strangers seeing your face from a public
|
|
||||||
// kind:0 event.
|
|
||||||
// • A relay operator deciding to build a "user gallery" out of
|
|
||||||
// scraped profile pictures.
|
|
||||||
//
|
|
||||||
// What this does NOT protect against:
|
|
||||||
// • Color-histogram attacks: pixel permutation preserves the global
|
|
||||||
// histogram. An adversary can tell "mostly skin tones" vs "mostly
|
|
||||||
// sky" without descrambling. For v0.1 that's an acceptable leak;
|
|
||||||
// v0.2 may add AES-CTR-over-pixels for a uniform-noise output
|
|
||||||
// (less "magical" looking, stronger).
|
|
||||||
// • Key compromise: anyone who gets the key sees the picture. The
|
|
||||||
// key wrap to each recipient is the actual access-control layer.
|
|
||||||
|
|
||||||
import { hkdf } from "@noble/hashes/hkdf";
|
|
||||||
import { sha256 } from "@noble/hashes/sha2";
|
|
||||||
|
|
||||||
const VISUAL_INFO = new TextEncoder().encode("kez-chat:visual-v1");
|
|
||||||
|
|
||||||
// ─── key generation ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Fresh 32-byte symmetric key. One key per profile picture. */
|
|
||||||
export function generateVisualKey(): Uint8Array {
|
|
||||||
return crypto.getRandomValues(new Uint8Array(32));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── tiny PRNG (xoshiro256**) ──────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// We need a deterministic 64-bit PRNG seeded from the key + image salt.
|
|
||||||
// Web Crypto's only random source is non-deterministic, so we roll a
|
|
||||||
// small algorithm by hand. xoshiro256** is fast, well-mixed, and the
|
|
||||||
// reference implementation is ~30 lines.
|
|
||||||
|
|
||||||
class Xoshiro256ss {
|
|
||||||
private s: BigUint64Array;
|
|
||||||
constructor(seed: Uint8Array) {
|
|
||||||
if (seed.length !== 32) throw new Error("xoshiro seed must be 32 bytes");
|
|
||||||
this.s = new BigUint64Array(4);
|
|
||||||
const dv = new DataView(seed.buffer, seed.byteOffset, seed.byteLength);
|
|
||||||
for (let i = 0; i < 4; i++) this.s[i] = dv.getBigUint64(i * 8, true);
|
|
||||||
// Ensure we don't start with all-zero state (xoshiro requires that).
|
|
||||||
if (this.s[0] === 0n && this.s[1] === 0n && this.s[2] === 0n && this.s[3] === 0n) {
|
|
||||||
this.s[0] = 1n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private rotl(x: bigint, k: bigint): bigint {
|
|
||||||
const mask = (1n << 64n) - 1n;
|
|
||||||
return (((x << k) & mask) | (x >> (64n - k))) & mask;
|
|
||||||
}
|
|
||||||
next(): bigint {
|
|
||||||
const mask = (1n << 64n) - 1n;
|
|
||||||
const result = (this.rotl((this.s[1] * 5n) & mask, 7n) * 9n) & mask;
|
|
||||||
const t = (this.s[1] << 17n) & mask;
|
|
||||||
this.s[2] ^= this.s[0];
|
|
||||||
this.s[3] ^= this.s[1];
|
|
||||||
this.s[1] ^= this.s[2];
|
|
||||||
this.s[0] ^= this.s[3];
|
|
||||||
this.s[2] ^= t;
|
|
||||||
this.s[3] = this.rotl(this.s[3], 45n);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
/** Uniform integer in [0, n). Used by Fisher-Yates. */
|
|
||||||
nextBelow(n: number): number {
|
|
||||||
// Rejection sample to avoid modulo bias when n doesn't divide 2^64.
|
|
||||||
const bn = BigInt(n);
|
|
||||||
const bound = ((1n << 64n) / bn) * bn;
|
|
||||||
let r: bigint;
|
|
||||||
do {
|
|
||||||
r = this.next();
|
|
||||||
} while (r >= bound);
|
|
||||||
return Number(r % bn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedPrng(key: Uint8Array, salt: Uint8Array): Xoshiro256ss {
|
|
||||||
// HKDF binds key + image salt into the PRNG seed, so the same key
|
|
||||||
// applied to two different images produces two different
|
|
||||||
// permutations — no leakage between pictures.
|
|
||||||
const seed = hkdf(sha256, key, salt, VISUAL_INFO, 32);
|
|
||||||
return new Xoshiro256ss(seed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── permutation ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Fisher-Yates: produces a uniformly random permutation of [0, n). */
|
|
||||||
function buildPermutation(n: number, prng: Xoshiro256ss): Uint32Array {
|
|
||||||
const p = new Uint32Array(n);
|
|
||||||
for (let i = 0; i < n; i++) p[i] = i;
|
|
||||||
for (let i = n - 1; i > 0; i--) {
|
|
||||||
const j = prng.nextBelow(i + 1);
|
|
||||||
const tmp = p[i];
|
|
||||||
p[i] = p[j];
|
|
||||||
p[j] = tmp;
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inverse permutation: `inv[p[i]] = i` so the descramble walk
|
|
||||||
* is symmetric to the scramble. */
|
|
||||||
function invertPermutation(p: Uint32Array): Uint32Array {
|
|
||||||
const inv = new Uint32Array(p.length);
|
|
||||||
for (let i = 0; i < p.length; i++) inv[p[i]] = i;
|
|
||||||
return inv;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── image I/O ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function loadImage(dataUrl: string): Promise<HTMLImageElement> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = () => reject(new Error("could not decode image"));
|
|
||||||
img.src = dataUrl;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function pixelsFromImage(
|
|
||||||
img: HTMLImageElement,
|
|
||||||
): { ctx: CanvasRenderingContext2D; data: ImageData } {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = img.width;
|
|
||||||
canvas.height = img.height;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) throw new Error("canvas 2d context unavailable");
|
|
||||||
ctx.drawImage(img, 0, 0);
|
|
||||||
return { ctx, data: ctx.getImageData(0, 0, img.width, img.height) };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── scramble + unscramble ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scramble an image so the output is a valid PNG with the same
|
|
||||||
* dimensions but visually meaningless. `key` is 32 bytes; the same
|
|
||||||
* key reverses the operation via `unscrambleImage`.
|
|
||||||
*
|
|
||||||
* Output is always PNG — JPEG would re-quantize and destroy the
|
|
||||||
* pixel permutation, making decryption impossible. Caller should
|
|
||||||
* accept this as the price of survivability.
|
|
||||||
*/
|
|
||||||
export async function scrambleImage(
|
|
||||||
imageDataUrl: string,
|
|
||||||
key: Uint8Array,
|
|
||||||
): Promise<string> {
|
|
||||||
const img = await loadImage(imageDataUrl);
|
|
||||||
const { ctx, data } = pixelsFromImage(img);
|
|
||||||
|
|
||||||
// Salt the PRNG with the image content so re-scrambling the same
|
|
||||||
// picture twice (under the same key) still produces different
|
|
||||||
// outputs — and so a stranger can't compare "before vs after"
|
|
||||||
// shuffles to map permutations.
|
|
||||||
//
|
|
||||||
// Cast: canvas ImageData.data is Uint8ClampedArray; sha256 wants
|
|
||||||
// Uint8Array. They share the same underlying buffer so a view
|
|
||||||
// reinterpretation is free.
|
|
||||||
const salt = sha256(new Uint8Array(data.data.buffer, data.data.byteOffset, data.data.byteLength));
|
|
||||||
const prng = seedPrng(key, salt);
|
|
||||||
|
|
||||||
const nPixels = data.width * data.height;
|
|
||||||
const perm = buildPermutation(nPixels, prng);
|
|
||||||
|
|
||||||
const src = new Uint8ClampedArray(data.data);
|
|
||||||
const dst = data.data;
|
|
||||||
for (let i = 0; i < nPixels; i++) {
|
|
||||||
const j = perm[i];
|
|
||||||
// Move pixel j into slot i. RGBA = 4 bytes per pixel.
|
|
||||||
dst[i * 4 + 0] = src[j * 4 + 0];
|
|
||||||
dst[i * 4 + 1] = src[j * 4 + 1];
|
|
||||||
dst[i * 4 + 2] = src[j * 4 + 2];
|
|
||||||
dst[i * 4 + 3] = src[j * 4 + 3];
|
|
||||||
}
|
|
||||||
ctx.putImageData(data, 0, 0);
|
|
||||||
|
|
||||||
// Embed the salt in the PNG via a trailing tEXt chunk would be
|
|
||||||
// ideal — but canvas.toDataURL doesn't expose that. Instead we
|
|
||||||
// prepend it as a tiny header URL fragment, decoded by
|
|
||||||
// unscrambleImage.
|
|
||||||
const pngBody = ctx.canvas.toDataURL("image/png");
|
|
||||||
const saltHex = Array.from(salt, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
||||||
// Use a custom URL fragment after the data URL to ferry the salt
|
|
||||||
// out-of-band. Recipients use this when they unscramble; strangers
|
|
||||||
// ignore it (it's a valid PNG with or without the fragment).
|
|
||||||
return `${pngBody}#kez-visual-v1:${saltHex}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inverse of `scrambleImage`. Takes the (data-URL + salt fragment)
|
|
||||||
* produced by scrambleImage and the same key; returns a plain PNG
|
|
||||||
* data URL with the original pixels restored.
|
|
||||||
*
|
|
||||||
* Throws if the salt fragment is missing — meaning the input wasn't
|
|
||||||
* produced by our scrambler.
|
|
||||||
*/
|
|
||||||
export async function unscrambleImage(
|
|
||||||
scrambledDataUrl: string,
|
|
||||||
key: Uint8Array,
|
|
||||||
): Promise<string> {
|
|
||||||
const fragIdx = scrambledDataUrl.indexOf("#kez-visual-v1:");
|
|
||||||
if (fragIdx < 0) {
|
|
||||||
throw new Error("input is not a kez-visual-v1 scrambled image");
|
|
||||||
}
|
|
||||||
const pureDataUrl = scrambledDataUrl.slice(0, fragIdx);
|
|
||||||
const saltHex = scrambledDataUrl.slice(fragIdx + "#kez-visual-v1:".length);
|
|
||||||
if (saltHex.length !== 64) {
|
|
||||||
throw new Error(`malformed salt (expected 64 hex chars, got ${saltHex.length})`);
|
|
||||||
}
|
|
||||||
const salt = new Uint8Array(32);
|
|
||||||
for (let i = 0; i < 32; i++) {
|
|
||||||
salt[i] = parseInt(saltHex.slice(i * 2, i * 2 + 2), 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
const img = await loadImage(pureDataUrl);
|
|
||||||
const { ctx, data } = pixelsFromImage(img);
|
|
||||||
const prng = seedPrng(key, salt);
|
|
||||||
|
|
||||||
const nPixels = data.width * data.height;
|
|
||||||
const perm = buildPermutation(nPixels, prng);
|
|
||||||
const inv = invertPermutation(perm);
|
|
||||||
|
|
||||||
const src = new Uint8ClampedArray(data.data);
|
|
||||||
const dst = data.data;
|
|
||||||
for (let i = 0; i < nPixels; i++) {
|
|
||||||
const j = inv[i];
|
|
||||||
dst[i * 4 + 0] = src[j * 4 + 0];
|
|
||||||
dst[i * 4 + 1] = src[j * 4 + 1];
|
|
||||||
dst[i * 4 + 2] = src[j * 4 + 2];
|
|
||||||
dst[i * 4 + 3] = src[j * 4 + 3];
|
|
||||||
}
|
|
||||||
ctx.putImageData(data, 0, 0);
|
|
||||||
return ctx.canvas.toDataURL("image/png");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── tiny round-trip test (dev only) ───────────────────────────────────────
|
|
||||||
|
|
||||||
/** Quick sanity check — caller passes a known image data URL,
|
|
||||||
* this scrambles + unscrambles + compares the round-trip hash.
|
|
||||||
* Not exported as a unit test; called from the Settings page
|
|
||||||
* the first time the feature is enabled. */
|
|
||||||
export async function visualSelfTest(
|
|
||||||
imageDataUrl: string,
|
|
||||||
key: Uint8Array,
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const scrambled = await scrambleImage(imageDataUrl, key);
|
|
||||||
const restored = await unscrambleImage(scrambled, key);
|
|
||||||
// Compare pixel hashes — base64 round-trip changes the encoding
|
|
||||||
// but pixel data should be byte-identical.
|
|
||||||
const a = await loadImage(imageDataUrl);
|
|
||||||
const b = await loadImage(restored);
|
|
||||||
if (a.width !== b.width || a.height !== b.height) return false;
|
|
||||||
const da = pixelsFromImage(a).data.data;
|
|
||||||
const db = pixelsFromImage(b).data.data;
|
|
||||||
const ha = sha256(new Uint8Array(da.buffer, da.byteOffset, da.byteLength));
|
|
||||||
const hb = sha256(new Uint8Array(db.buffer, db.byteOffset, db.byteLength));
|
|
||||||
for (let i = 0; i < 32; i++) if (ha[i] !== hb[i]) return false;
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("visualSelfTest failed:", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -118,15 +118,7 @@ export async function setupBiometricUnlock(opts: {
|
|||||||
// if the authenticator doesn't support it, registration succeeds
|
// if the authenticator doesn't support it, registration succeeds
|
||||||
// but getClientExtensionResults().prf.enabled will be false and we
|
// but getClientExtensionResults().prf.enabled will be false and we
|
||||||
// bail.
|
// bail.
|
||||||
//
|
const userId = new TextEncoder().encode(opts.primary);
|
||||||
// user.id is an opaque per-user identifier. WebAuthn spec caps it at
|
|
||||||
// 64 BYTES (not chars). An earlier version of this code used the full
|
|
||||||
// KEZ identity string ("ed25519:<64 hex>") which is 72 bytes — Android
|
|
||||||
// Chrome rejected it with "user handle exceeds 64 bytes". Use the
|
|
||||||
// 32 raw bytes of the ed25519 pubkey instead: well-defined, stable,
|
|
||||||
// and unique per account.
|
|
||||||
const pubkeyHex = opts.primary.replace(/^ed25519:/, "");
|
|
||||||
const userId = hexToBytes(pubkeyHex);
|
|
||||||
const challenge = crypto.getRandomValues(new Uint8Array(32));
|
const challenge = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
|
||||||
const cred = (await navigator.credentials.create({
|
const cred = (await navigator.credentials.create({
|
||||||
|
|||||||
@ -288,7 +288,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold text-text">Add a claim</h1>
|
<h1 class="text-2xl font-bold text-text">Add a claim</h1>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -156,7 +156,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold text-text">Claims</h1>
|
<h1 class="text-2xl font-bold text-text">Claims</h1>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|||||||
@ -154,7 +154,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if session.unlocked}
|
{#if session.unlocked}
|
||||||
<div class="max-w-2xl mx-auto px-4 py-6 space-y-8">
|
<div class="space-y-8">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
|
||||||
<section class="border border-gray-200 rounded-lg p-6 bg-white">
|
<section class="border border-gray-200 rounded-lg p-6 bg-white">
|
||||||
|
|||||||
@ -113,16 +113,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if session.unlocked}
|
{#if session.unlocked}
|
||||||
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
<!-- Identity card -->
|
<!-- Identity card -->
|
||||||
<section class="bg-surface border border-border rounded-xl p-6">
|
<section class="bg-surface border border-border rounded-xl p-6">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<Avatar
|
<Avatar seed={session.unlocked.primary} size={64} ring />
|
||||||
seed={session.unlocked.primary}
|
|
||||||
size={64}
|
|
||||||
ring
|
|
||||||
picture={session.myProfile?.picture}
|
|
||||||
/>
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span class="font-mono text-lg font-semibold text-text truncate inline-flex items-center gap-1">
|
<span class="font-mono text-lg font-semibold text-text truncate inline-flex items-center gap-1">
|
||||||
|
|||||||
@ -2,20 +2,7 @@
|
|||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { push } from "svelte-spa-router";
|
import { push } from "svelte-spa-router";
|
||||||
import { session } from "../lib/store.svelte.js";
|
import { session } from "../lib/store.svelte.js";
|
||||||
import {
|
import { sendMessage } from "../lib/transport.js";
|
||||||
sendMessage,
|
|
||||||
sendFile,
|
|
||||||
getRelayStatuses,
|
|
||||||
activeTransport,
|
|
||||||
fetchAcksForEventIds,
|
|
||||||
type RelayStatus,
|
|
||||||
} from "../lib/transport.js";
|
|
||||||
import { MAX_FILE_BYTES, INLINE_LIMIT } from "../lib/file-transfer.js";
|
|
||||||
import {
|
|
||||||
loadAttachment,
|
|
||||||
saveAttachment,
|
|
||||||
type StoredAttachment,
|
|
||||||
} from "../lib/attachment-store.js";
|
|
||||||
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
||||||
import { inboxService } from "../lib/inbox-service.svelte.js";
|
import { inboxService } from "../lib/inbox-service.svelte.js";
|
||||||
import { verifySubject } from "../lib/verify.js";
|
import { verifySubject } from "../lib/verify.js";
|
||||||
@ -23,22 +10,10 @@
|
|||||||
import EmojiButton from "../lib/EmojiButton.svelte";
|
import EmojiButton from "../lib/EmojiButton.svelte";
|
||||||
import Avatar from "../lib/Avatar.svelte";
|
import Avatar from "../lib/Avatar.svelte";
|
||||||
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
|
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
|
||||||
import { peerProfiles } from "../lib/peer-profile-cell.svelte.js";
|
|
||||||
import {
|
|
||||||
pushSupported,
|
|
||||||
isPushSubscribed,
|
|
||||||
enablePush,
|
|
||||||
isStandalonePwa,
|
|
||||||
isIos,
|
|
||||||
} from "../lib/push.js";
|
|
||||||
import {
|
import {
|
||||||
appendOutbound,
|
appendOutbound,
|
||||||
appendOutboundAttachment,
|
|
||||||
ensureConversation,
|
ensureConversation,
|
||||||
listConversations,
|
listConversations,
|
||||||
markConversationRead,
|
|
||||||
markDeliveredByEventId,
|
|
||||||
markOutboundStatus,
|
|
||||||
setVerified,
|
setVerified,
|
||||||
type Conversation,
|
type Conversation,
|
||||||
} from "../lib/conversations-store.js";
|
} from "../lib/conversations-store.js";
|
||||||
@ -51,131 +26,9 @@
|
|||||||
? conversations.find((c) => c.peer_primary === activePrimary) ?? null
|
? conversations.find((c) => c.peer_primary === activePrimary) ?? null
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Force-refresh the active peer's profile when the user opens
|
|
||||||
// their thread. The bulk-scan refresh on /chats mount honours a
|
|
||||||
// 6h staleness gate (cheap), but clicking into someone's thread
|
|
||||||
// is a strong "I care right now" signal — skip the cache and
|
|
||||||
// refetch their kind:0. Catches "they just updated their picture"
|
|
||||||
// immediately on the surface that matters most.
|
|
||||||
//
|
|
||||||
// Track only activePrimary (not activeConv) so this fires once
|
|
||||||
// per thread open, not every time conversations[] re-paints —
|
|
||||||
// otherwise every inbound message would trigger a force-refresh.
|
|
||||||
$effect(() => {
|
|
||||||
const pk = activePrimary;
|
|
||||||
if (!pk || !session.unlocked) return;
|
|
||||||
// Untrack `conversations` reads with $state.snapshot wrapped in
|
|
||||||
// queueMicrotask so we don't add it as a dependency. Cheaper:
|
|
||||||
// just look the conversation up directly from the IDB-backed
|
|
||||||
// store on the next microtask.
|
|
||||||
queueMicrotask(async () => {
|
|
||||||
if (!session.unlocked) return;
|
|
||||||
// Opening a thread = "I've seen it" — clear the unread badge
|
|
||||||
// for THIS conversation. Other conversations keep their counts.
|
|
||||||
await markConversationRead(pk);
|
|
||||||
await refresh();
|
|
||||||
const conv = conversations.find((c) => c.peer_primary === pk);
|
|
||||||
if (!conv?.peer_nostr_pubkey) return;
|
|
||||||
void peerProfiles.refresh({
|
|
||||||
peer_primary: conv.peer_primary,
|
|
||||||
peer_nostr_pubkey: conv.peer_nostr_pubkey,
|
|
||||||
my_handle: session.unlocked.handle,
|
|
||||||
my_seed: session.unlocked.seed,
|
|
||||||
forceRefresh: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
let composeText = $state("");
|
let composeText = $state("");
|
||||||
let composing = $state(false);
|
let composing = $state(false);
|
||||||
let composeEl: HTMLInputElement | null = $state(null);
|
let composeEl: HTMLInputElement | null = $state(null);
|
||||||
let fileInput = $state<HTMLInputElement | null>(null);
|
|
||||||
let fileSendError = $state<string | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a File from the picker, route through the transport's
|
|
||||||
* sendFile (inline vs chunked decided automatically by size),
|
|
||||||
* and render an optimistic local echo of the attachment so the
|
|
||||||
* sender sees their image immediately.
|
|
||||||
*/
|
|
||||||
async function onFilePicked(file: File) {
|
|
||||||
if (!session.unlocked || !activeConv) return;
|
|
||||||
fileSendError = null;
|
|
||||||
if (file.size > MAX_FILE_BYTES) {
|
|
||||||
fileSendError = `Files larger than ${MAX_FILE_BYTES / 1024 / 1024} MB aren't supported yet.`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
composing = true;
|
|
||||||
const raw = new Uint8Array(await file.arrayBuffer());
|
|
||||||
const filename = file.name || "file";
|
|
||||||
const mime = file.type || "application/octet-stream";
|
|
||||||
const peer_primary = activeConv.peer_primary;
|
|
||||||
const peer_handle = activeConv.peer_handle;
|
|
||||||
|
|
||||||
// ─── optimistic local echo ─────────────────────────────────
|
|
||||||
// Save the raw bytes locally so the bubble can render
|
|
||||||
// instantly + survives a reload. Decide inline vs chunked the
|
|
||||||
// same way the transport will.
|
|
||||||
const localSeq = await appendOutboundAttachment({
|
|
||||||
peer_primary,
|
|
||||||
peer_handle,
|
|
||||||
from: session.unlocked.primary,
|
|
||||||
body: `📎 ${filename}`,
|
|
||||||
attachment: {
|
|
||||||
filename,
|
|
||||||
mime,
|
|
||||||
size: raw.length,
|
|
||||||
state: "ready", // sender always has the bytes — render immediately
|
|
||||||
},
|
|
||||||
status: "sending",
|
|
||||||
});
|
|
||||||
// Save bytes via the same attachment store the receiver uses,
|
|
||||||
// so the bubble rendering code is shared.
|
|
||||||
const dataUrl = await fileToDataUrl(file);
|
|
||||||
await saveAttachment(peer_primary, localSeq, {
|
|
||||||
filename,
|
|
||||||
mime,
|
|
||||||
size: raw.length,
|
|
||||||
data_url: dataUrl,
|
|
||||||
});
|
|
||||||
await refresh();
|
|
||||||
|
|
||||||
composing = false;
|
|
||||||
|
|
||||||
// ─── actual publish ────────────────────────────────────────
|
|
||||||
try {
|
|
||||||
const result = await sendFile({
|
|
||||||
senderHandle: session.unlocked.handle,
|
|
||||||
senderSeed: session.unlocked.seed,
|
|
||||||
senderPrimary: session.unlocked.primary,
|
|
||||||
recipientHandle: peer_handle || peer_primary,
|
|
||||||
recipientPrimary: peer_primary,
|
|
||||||
filename,
|
|
||||||
mime,
|
|
||||||
raw,
|
|
||||||
preferRelay: activeConv?.peer_via_relay,
|
|
||||||
});
|
|
||||||
await markOutboundStatus(peer_primary, localSeq, "sent", {
|
|
||||||
event_id: result.pointer_event_id,
|
|
||||||
accepted_by: result.accepted_by,
|
|
||||||
});
|
|
||||||
await refresh();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("sendFile failed:", e);
|
|
||||||
await markOutboundStatus(peer_primary, localSeq, "failed");
|
|
||||||
fileSendError = (e as Error).message;
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileToDataUrl(file: File): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const r = new FileReader();
|
|
||||||
r.onload = () => resolve(r.result as string);
|
|
||||||
r.onerror = () => reject(new Error("could not read file"));
|
|
||||||
r.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert an emoji at the current cursor position in the compose input
|
* Insert an emoji at the current cursor position in the compose input
|
||||||
@ -281,131 +134,11 @@
|
|||||||
// Toast for the share-link copy action.
|
// Toast for the share-link copy action.
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
|
|
||||||
// Tap-to-zoom for the thread-header avatar. Renders a fullscreen
|
|
||||||
// overlay with the picture upscaled. Click anywhere outside (or
|
|
||||||
// Escape) to dismiss.
|
|
||||||
let zoomedAvatarOpen = $state(false);
|
|
||||||
function closeZoomedAvatar() {
|
|
||||||
zoomedAvatarOpen = false;
|
|
||||||
}
|
|
||||||
function onZoomKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") closeZoomedAvatar();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Relay state ──────────────────────────────────────────────────────
|
|
||||||
// The "● live (N)" header is a tiny live view onto the transport's
|
|
||||||
// relay pool. We poll every 2s rather than subscribing because (a)
|
|
||||||
// nostr-tools' SimplePool doesn't fire a "connectionstate" event and
|
|
||||||
// (b) at 3 relays this costs nothing. The popover renders the same
|
|
||||||
// RelayStatus[] array on demand.
|
|
||||||
let relayStatuses = $state<RelayStatus[]>(getRelayStatuses());
|
|
||||||
let relayPopoverOpen = $state(false);
|
|
||||||
let relayPopoverEl = $state<HTMLDivElement | null>(null);
|
|
||||||
let relayButtonEl = $state<HTMLButtonElement | null>(null);
|
|
||||||
let relayPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
let liveRelayCount = $derived(relayStatuses.filter((r) => r.connected).length);
|
|
||||||
let totalRelayCount = $derived(relayStatuses.length);
|
|
||||||
|
|
||||||
function toggleRelayPopover() {
|
|
||||||
relayPopoverOpen = !relayPopoverOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDocumentClick(e: MouseEvent) {
|
|
||||||
if (!relayPopoverOpen) return;
|
|
||||||
const t = e.target as Node;
|
|
||||||
if (relayPopoverEl?.contains(t)) return;
|
|
||||||
if (relayButtonEl?.contains(t)) return;
|
|
||||||
relayPopoverOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Push-notification nudge ──────────────────────────────────────────
|
|
||||||
// The previous default was "off until the user finds Settings and
|
|
||||||
// toggles it" — almost nobody did, so people thought push was broken.
|
|
||||||
// Now we show a friendly banner at the top of /chats whenever push is
|
|
||||||
// supported AND the user hasn't subscribed yet. Dismissals are sticky
|
|
||||||
// for 7 days so we don't nag, and explicit Enable shows the system
|
|
||||||
// prompt right there.
|
|
||||||
const PUSH_NUDGE_DISMISS_KEY = "kez-chat:push-nudge-dismissed-until";
|
|
||||||
|
|
||||||
let pushNudgeVisible = $state(false);
|
|
||||||
let pushNudgeBusy = $state(false);
|
|
||||||
let pushNudgeError = $state<string | null>(null);
|
|
||||||
let pushNudgeNeedsPwa = $state(false);
|
|
||||||
|
|
||||||
async function evaluatePushNudge() {
|
|
||||||
pushNudgeVisible = false;
|
|
||||||
pushNudgeError = null;
|
|
||||||
pushNudgeNeedsPwa = false;
|
|
||||||
if (!session.unlocked) return;
|
|
||||||
// Suppressed within the 7-day "maybe later" window?
|
|
||||||
try {
|
|
||||||
const until = parseInt(
|
|
||||||
localStorage.getItem(PUSH_NUDGE_DISMISS_KEY) ?? "0",
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
if (until > Date.now()) return;
|
|
||||||
} catch {
|
|
||||||
/* private mode */
|
|
||||||
}
|
|
||||||
// iOS-not-installed: banner mentions the install step instead of
|
|
||||||
// pretending the user can enable from here (they can't).
|
|
||||||
if (isIos() && !isStandalonePwa()) {
|
|
||||||
pushNudgeNeedsPwa = true;
|
|
||||||
pushNudgeVisible = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!pushSupported()) return;
|
|
||||||
// Already subscribed? Nothing to nudge about.
|
|
||||||
const subscribed = await isPushSubscribed();
|
|
||||||
if (subscribed) return;
|
|
||||||
pushNudgeVisible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enablePushFromNudge() {
|
|
||||||
if (!session.unlocked) return;
|
|
||||||
pushNudgeBusy = true;
|
|
||||||
pushNudgeError = null;
|
|
||||||
try {
|
|
||||||
const ok = await enablePush(session.unlocked.handle, session.unlocked.seed);
|
|
||||||
if (ok) {
|
|
||||||
pushNudgeVisible = false;
|
|
||||||
} else {
|
|
||||||
// User clicked "Block" in the system permission prompt — no
|
|
||||||
// way to recover without browser-settings intervention. Don't
|
|
||||||
// hide the banner so they see a hint about it.
|
|
||||||
pushNudgeError =
|
|
||||||
"Permission blocked. Re-enable in your browser's site settings, then refresh.";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
pushNudgeError = (e as Error).message;
|
|
||||||
} finally {
|
|
||||||
pushNudgeBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissPushNudge() {
|
|
||||||
try {
|
|
||||||
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
localStorage.setItem(
|
|
||||||
PUSH_NUDGE_DISMISS_KEY,
|
|
||||||
String(Date.now() + sevenDaysMs),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* private mode */
|
|
||||||
}
|
|
||||||
pushNudgeVisible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!session.unlocked) {
|
if (!session.unlocked) {
|
||||||
push("/unlock");
|
push("/unlock");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Hydrate the in-memory mirror of peer profiles before the first
|
|
||||||
// refresh() so the conversation rows have avatars from disk on
|
|
||||||
// first paint (avoids a "identicon → real picture" flash).
|
|
||||||
await peerProfiles.hydrate();
|
|
||||||
await refresh();
|
await refresh();
|
||||||
// Kick off verification for every existing conversation (24h cache per
|
// Kick off verification for every existing conversation (24h cache per
|
||||||
// peer), so the verified badge shows in the list without opening each
|
// peer), so the verified badge shows in the list without opening each
|
||||||
@ -418,77 +151,14 @@
|
|||||||
unsubscribe = inboxService.onMessage(() => void refresh());
|
unsubscribe = inboxService.onMessage(() => void refresh());
|
||||||
// Landing here = the user has seen new messages; reset the badge.
|
// Landing here = the user has seen new messages; reset the badge.
|
||||||
inboxService.markAllRead();
|
inboxService.markAllRead();
|
||||||
// Relay state poll.
|
|
||||||
relayPollTimer = setInterval(() => {
|
|
||||||
relayStatuses = getRelayStatuses();
|
|
||||||
}, 2_000);
|
|
||||||
document.addEventListener("click", onDocumentClick);
|
|
||||||
void evaluatePushNudge();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
if (relayPollTimer) clearInterval(relayPollTimer);
|
|
||||||
document.removeEventListener("click", onDocumentClick);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
conversations = await listConversations();
|
conversations = await listConversations();
|
||||||
// Kick off peer-profile fetches for any conversation whose peer
|
|
||||||
// nostr pubkey we know — most-recent-first so the visible rows
|
|
||||||
// light up fastest. Fire-and-forget; the reactive cell repaints
|
|
||||||
// when each fetch returns. Cached entries inside the staleness
|
|
||||||
// window short-circuit at the peer-profile-store layer.
|
|
||||||
if (session.unlocked) {
|
|
||||||
const seed = session.unlocked.seed;
|
|
||||||
const myHandle = session.unlocked.handle;
|
|
||||||
for (const c of conversations) {
|
|
||||||
if (!c.peer_nostr_pubkey) continue;
|
|
||||||
void peerProfiles.refresh({
|
|
||||||
peer_primary: c.peer_primary,
|
|
||||||
peer_nostr_pubkey: c.peer_nostr_pubkey,
|
|
||||||
my_handle: myHandle,
|
|
||||||
my_seed: seed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Catch-up ack scan: any outbound bubble still showing "sent"
|
|
||||||
// (single check, no circle) might have already been acked by
|
|
||||||
// the recipient — we just missed the ack event in the live
|
|
||||||
// stream (offline window, relay flap, etc.). Query relays for
|
|
||||||
// any kind-4244 events that reference our pending event ids
|
|
||||||
// and mark the matching bubbles as delivered.
|
|
||||||
//
|
|
||||||
// Cap at 200 recent ids to keep the filter small; older ones
|
|
||||||
// would be slow to query and unlikely to still be in any relay's
|
|
||||||
// cache anyway.
|
|
||||||
const pending: string[] = [];
|
|
||||||
for (const c of conversations) {
|
|
||||||
for (const m of c.messages) {
|
|
||||||
if (m.direction === "out" && m.status === "sent" && m.event_id) {
|
|
||||||
pending.push(m.event_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (pending.length > 0) {
|
|
||||||
const idsToCheck = pending.slice(-200);
|
|
||||||
try {
|
|
||||||
const acked = await fetchAcksForEventIds(idsToCheck);
|
|
||||||
if (acked.size > 0) {
|
|
||||||
// markDeliveredByEventId verifies the sig (if any) against
|
|
||||||
// the conversation peer's KEZ primary. Unsigned acks from
|
|
||||||
// pre-Day-3 clients still flip the bubble (graceful
|
|
||||||
// degradation); signed-but-forged acks are dropped silently.
|
|
||||||
for (const [id, sigHex] of acked.entries()) {
|
|
||||||
await markDeliveredByEventId(id, sigHex);
|
|
||||||
}
|
|
||||||
conversations = await listConversations(); // repaint
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("catch-up ack scan failed:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the active peer whenever a conversation is opened (covers
|
// Verify the active peer whenever a conversation is opened (covers
|
||||||
@ -566,63 +236,28 @@
|
|||||||
async function send() {
|
async function send() {
|
||||||
if (!session.unlocked || !activeConv || !composeText.trim()) return;
|
if (!session.unlocked || !activeConv || !composeText.trim()) return;
|
||||||
composing = true;
|
composing = true;
|
||||||
|
try {
|
||||||
const body = composeText;
|
const body = composeText;
|
||||||
composeText = "";
|
composeText = "";
|
||||||
|
await sendMessage({
|
||||||
// 1. Optimistic local echo — the bubble appears INSTANTLY in
|
|
||||||
// "sending" state. No matter how slow the relay handshake is,
|
|
||||||
// the user sees their own message immediately. Status icon
|
|
||||||
// flips to "sent" once at least one relay accepts, then to
|
|
||||||
// "delivered" when the recipient's client publishes an ack.
|
|
||||||
const peer_primary = activeConv.peer_primary;
|
|
||||||
const peer_handle = activeConv.peer_handle;
|
|
||||||
let localSeq: number;
|
|
||||||
try {
|
|
||||||
localSeq = await appendOutbound({
|
|
||||||
peer_primary,
|
|
||||||
peer_handle,
|
|
||||||
from: session.unlocked.primary,
|
|
||||||
body,
|
|
||||||
status: "sending",
|
|
||||||
});
|
|
||||||
await refresh();
|
|
||||||
} catch (e) {
|
|
||||||
alert(`Local append failed: ${(e as Error).message}`);
|
|
||||||
composing = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fire the actual publish in the background. The compose
|
|
||||||
// field is already free so the user can keep typing.
|
|
||||||
composing = false;
|
|
||||||
try {
|
|
||||||
const result = await sendMessage({
|
|
||||||
senderHandle: session.unlocked.handle,
|
senderHandle: session.unlocked.handle,
|
||||||
senderSeed: session.unlocked.seed,
|
senderSeed: session.unlocked.seed,
|
||||||
senderPrimary: session.unlocked.primary,
|
senderPrimary: session.unlocked.primary,
|
||||||
recipient: peer_handle || peer_primary,
|
recipient: activeConv.peer_handle || activeConv.peer_primary,
|
||||||
body,
|
body,
|
||||||
// We already have the recipient's primary from the
|
|
||||||
// conversation row — pass it so the nostr transport can skip
|
|
||||||
// the /v1/u/:handle lookup. Chat over nostr should NOT
|
|
||||||
// depend on the chat-server; if the server's down, we still
|
|
||||||
// publish to relays and the recipient still gets the message.
|
|
||||||
recipientPrimary: peer_primary,
|
|
||||||
// Reply over the same relay that delivered the recipient's
|
|
||||||
// most recent message to us — usually the lowest-latency
|
|
||||||
// path for the round-trip. Falls back to our default set if
|
|
||||||
// unset.
|
|
||||||
preferRelay: activeConv?.peer_via_relay,
|
|
||||||
});
|
});
|
||||||
await markOutboundStatus(peer_primary, localSeq, "sent", {
|
await appendOutbound({
|
||||||
event_id: result.event_id,
|
peer_primary: activeConv.peer_primary,
|
||||||
accepted_by: result.accepted_by,
|
peer_handle: activeConv.peer_handle,
|
||||||
|
from: session.unlocked.primary,
|
||||||
|
body,
|
||||||
});
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("sendMessage failed:", e);
|
alert(`Send failed: ${(e as Error).message}`);
|
||||||
await markOutboundStatus(peer_primary, localSeq, "failed");
|
composeText = composeText; // no-op, keep linter happy
|
||||||
await refresh();
|
} finally {
|
||||||
|
composing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -662,59 +297,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col h-full bg-bg">
|
<div class="flex h-full bg-bg">
|
||||||
<!--
|
|
||||||
Push-enable nudge. Sits above both sidebar + thread so users get
|
|
||||||
the prompt regardless of which view they're in. Silent + skipped
|
|
||||||
entirely when push is already on, was dismissed in the last
|
|
||||||
7 days, or isn't supported.
|
|
||||||
-->
|
|
||||||
{#if pushNudgeVisible}
|
|
||||||
<div class="shrink-0 px-3 py-2 sm:py-2.5 bg-accent/10 border-b border-accent/30 flex items-start sm:items-center gap-3 text-sm">
|
|
||||||
<span class="text-base sm:text-lg shrink-0 leading-none" aria-hidden="true">🔔</span>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
{#if pushNudgeNeedsPwa}
|
|
||||||
<p class="text-text">
|
|
||||||
<strong class="font-semibold">Want notifications?</strong>
|
|
||||||
Tap <strong>Share</strong> in Safari, then
|
|
||||||
<strong>Add to Home Screen</strong> — iOS only delivers
|
|
||||||
push to installed apps.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<p class="text-text">
|
|
||||||
<strong class="font-semibold">Get notified about new messages</strong>
|
|
||||||
— even when kez-chat is closed.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{#if pushNudgeError}
|
|
||||||
<p class="text-xs text-danger mt-1">{pushNudgeError}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
|
||||||
{#if !pushNudgeNeedsPwa}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-1.5 text-xs font-semibold bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
|
|
||||||
disabled={pushNudgeBusy}
|
|
||||||
onclick={enablePushFromNudge}
|
|
||||||
>
|
|
||||||
{pushNudgeBusy ? "…" : "Enable"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-2 py-1.5 text-xs text-text-muted hover:text-text"
|
|
||||||
onclick={dismissPushNudge}
|
|
||||||
aria-label="Dismiss notification prompt"
|
|
||||||
title="Dismiss for 7 days"
|
|
||||||
>
|
|
||||||
{pushNudgeNeedsPwa ? "Got it" : "Later"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex flex-1 min-h-0">
|
|
||||||
<!-- Sidebar (conversation list). On mobile it's full-width and hides
|
<!-- Sidebar (conversation list). On mobile it's full-width and hides
|
||||||
when a conversation is open. -->
|
when a conversation is open. -->
|
||||||
<aside class={`${activeConv ? "hidden" : "flex"} sm:flex w-full sm:w-80 shrink-0 border-r border-border bg-surface flex-col`}>
|
<aside class={`${activeConv ? "hidden" : "flex"} sm:flex w-full sm:w-80 shrink-0 border-r border-border bg-surface flex-col`}>
|
||||||
@ -722,25 +305,7 @@
|
|||||||
<div class="p-3 border-b border-border">
|
<div class="p-3 border-b border-border">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<h1 class="text-sm font-semibold text-text uppercase tracking-wider">Chats</h1>
|
<h1 class="text-sm font-semibold text-text uppercase tracking-wider">Chats</h1>
|
||||||
<!--
|
<span class="text-xs">
|
||||||
Live indicator — also a button that pops a small panel
|
|
||||||
listing every configured relay (or the single chat-server
|
|
||||||
on server transport) and whether its socket is currently
|
|
||||||
open. The count in parens is the *connected* relay count,
|
|
||||||
not the configured total: 0 → red, partial → yellow,
|
|
||||||
full → green.
|
|
||||||
-->
|
|
||||||
<div class="relative">
|
|
||||||
<button
|
|
||||||
bind:this={relayButtonEl}
|
|
||||||
type="button"
|
|
||||||
onclick={toggleRelayPopover}
|
|
||||||
class="text-xs font-mono px-1.5 py-0.5 rounded hover:bg-elevated transition-colors"
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-expanded={relayPopoverOpen}
|
|
||||||
aria-label="{liveRelayCount} of {totalRelayCount} {activeTransport === 'nostr' ? 'relays' : 'servers'} connected — click for details"
|
|
||||||
title="{activeTransport === 'nostr' ? 'Nostr relays' : 'Chat server'}: {liveRelayCount}/{totalRelayCount} connected"
|
|
||||||
>
|
|
||||||
{#if inboxService.status === "live"}
|
{#if inboxService.status === "live"}
|
||||||
<span class="text-accent">● live</span>
|
<span class="text-accent">● live</span>
|
||||||
{:else if inboxService.status === "reconnecting"}
|
{:else if inboxService.status === "reconnecting"}
|
||||||
@ -750,56 +315,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="text-text-muted">○ off</span>
|
<span class="text-text-muted">○ off</span>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- "(7)" — only show the count when the transport actually
|
|
||||||
has multiple relays, OR when nostr is active (since the
|
|
||||||
user explicitly asked for it). Hiding "(1)" on server
|
|
||||||
transport keeps the header uncluttered. -->
|
|
||||||
{#if activeTransport === "nostr" || totalRelayCount > 1}
|
|
||||||
<span class="text-text-muted">({liveRelayCount})</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if relayPopoverOpen}
|
|
||||||
<div
|
|
||||||
bind:this={relayPopoverEl}
|
|
||||||
class="absolute right-0 top-full mt-1 z-30 w-72 bg-surface border border-border rounded-lg shadow-lg p-3"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Relay status"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<p class="text-[10px] uppercase tracking-wider text-text-muted font-semibold">
|
|
||||||
{activeTransport === "nostr" ? "Nostr relays" : "Chat server"}
|
|
||||||
</p>
|
|
||||||
<p class="text-[10px] text-text-muted font-mono">
|
|
||||||
{liveRelayCount}/{totalRelayCount} up
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
{#each relayStatuses as r (r.url)}
|
|
||||||
<li class="flex items-center gap-2 text-xs">
|
|
||||||
<span
|
|
||||||
class={r.connected
|
|
||||||
? "text-accent shrink-0"
|
|
||||||
: "text-text-muted shrink-0"}
|
|
||||||
aria-hidden="true"
|
|
||||||
>{r.connected ? "●" : "○"}</span>
|
|
||||||
<span class="font-mono text-text truncate" title={r.url}>
|
|
||||||
{r.url.replace(/^wss?:\/\//, "")}
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
{#if relayStatuses.length === 0}
|
|
||||||
<li class="text-xs text-text-muted italic">
|
|
||||||
No relays configured.
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
<p class="mt-3 pt-2 border-t border-border text-[10px] text-text-muted">
|
|
||||||
Transport: <span class="font-mono text-text">{activeTransport}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{#if session.unlocked}
|
{#if session.unlocked}
|
||||||
<button
|
<button
|
||||||
@ -862,11 +378,7 @@
|
|||||||
onclick={() => (activePrimary = c.peer_primary)}
|
onclick={() => (activePrimary = c.peer_primary)}
|
||||||
>
|
>
|
||||||
{#if active}<span class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent"></span>{/if}
|
{#if active}<span class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent"></span>{/if}
|
||||||
<Avatar
|
<Avatar seed={c.peer_primary} size={40} />
|
||||||
seed={c.peer_primary}
|
|
||||||
size={40}
|
|
||||||
picture={peerProfiles.byPrimary[c.peer_primary]?.picture}
|
|
||||||
/>
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
||||||
<span class="truncate">{displayName(c)}</span>
|
<span class="truncate">{displayName(c)}</span>
|
||||||
@ -880,21 +392,7 @@
|
|||||||
<p class="text-xs text-text-muted italic">No messages yet</p>
|
<p class="text-xs text-text-muted italic">No messages yet</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-end shrink-0 self-start gap-1 mt-0.5">
|
{#if last}<span class="text-[10px] text-text-muted shrink-0 self-start mt-0.5">{formatTime(last.ts)}</span>{/if}
|
||||||
{#if last}
|
|
||||||
<span class="text-[10px] text-text-muted">{formatTime(last.ts)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if (c.unread_count ?? 0) > 0}
|
|
||||||
<!-- Unread badge — accent dot with the count.
|
|
||||||
Sized like a chip; rounds gracefully past 99. -->
|
|
||||||
<span
|
|
||||||
class="min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full bg-accent text-accent-contrast text-[10px] font-semibold leading-none"
|
|
||||||
aria-label="{c.unread_count} unread message{c.unread_count === 1 ? '' : 's'}"
|
|
||||||
>
|
|
||||||
{c.unread_count > 99 ? "99+" : c.unread_count}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@ -926,29 +424,7 @@
|
|||||||
<button class="sm:hidden text-text-secondary hover:text-text -ml-1" onclick={() => (activePrimary = null)} aria-label="Back">
|
<button class="sm:hidden text-text-secondary hover:text-text -ml-1" onclick={() => (activePrimary = null)} aria-label="Back">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<!--
|
|
||||||
Tap-to-zoom: clicking the header avatar opens a fullscreen
|
|
||||||
overlay with the picture upscaled. Only interactive when
|
|
||||||
the peer actually has a picture; the identicon falls back
|
|
||||||
to a plain Avatar render (nothing useful to zoom to).
|
|
||||||
-->
|
|
||||||
{#if peerProfiles.byPrimary[activeConv.peer_primary]?.picture}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="shrink-0 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
|
|
||||||
onclick={() => (zoomedAvatarOpen = true)}
|
|
||||||
aria-label="View {displayName(activeConv)}'s profile picture"
|
|
||||||
title="View profile picture"
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
seed={activeConv.peer_primary}
|
|
||||||
size={36}
|
|
||||||
picture={peerProfiles.byPrimary[activeConv.peer_primary]?.picture}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<Avatar seed={activeConv.peer_primary} size={36} />
|
<Avatar seed={activeConv.peer_primary} size={36} />
|
||||||
{/if}
|
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
||||||
<span class="truncate">{displayName(activeConv)}</span>
|
<span class="truncate">{displayName(activeConv)}</span>
|
||||||
@ -985,106 +461,23 @@
|
|||||||
: "bg-bubble-recv text-text rounded-2xl rounded-bl-md",
|
: "bg-bubble-recv text-text rounded-2xl rounded-bl-md",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{#if m.attachment}
|
|
||||||
<!-- File attachment branch. Image MIMEs render an
|
|
||||||
inline preview when bytes are ready; chunked
|
|
||||||
files in flight show a "Receiving N/M…" hint
|
|
||||||
until assembly completes. -->
|
|
||||||
{#await loadAttachment(activeConv.peer_primary, m.seq) then att}
|
|
||||||
{#if m.attachment.state === "pending"}
|
|
||||||
<div class="flex items-center gap-2 py-1">
|
|
||||||
<svg class="animate-spin shrink-0" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"/>
|
|
||||||
<path d="M22 12a10 10 0 0 0-10-10" stroke-linecap="round"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs opacity-90">
|
|
||||||
Receiving {m.attachment.received_chunks ?? 0}/{m.attachment.total_chunks ?? "?"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{:else if m.attachment.state === "failed"}
|
|
||||||
<div class="flex items-center gap-2 py-1 text-danger">
|
|
||||||
<span class="text-xs">⚠ {m.attachment.filename}: assembly failed</span>
|
|
||||||
</div>
|
|
||||||
{:else if att?.data_url && m.attachment.mime?.startsWith("image/")}
|
|
||||||
<!-- Image preview. Capped at 320px in the chat;
|
|
||||||
tap could open a full-screen viewer (TODO). -->
|
|
||||||
<img
|
|
||||||
src={att.data_url}
|
|
||||||
alt={m.attachment.filename}
|
|
||||||
class="max-w-full max-h-80 rounded-lg block"
|
|
||||||
style="margin: -2px -8px 4px -8px;"
|
|
||||||
/>
|
|
||||||
{:else if att?.data_url}
|
|
||||||
<!-- Non-image: filename + size + download link. -->
|
|
||||||
<a
|
|
||||||
href={att.data_url}
|
|
||||||
download={m.attachment.filename}
|
|
||||||
class="flex items-center gap-2 underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
<span class="text-base">📎</span>
|
|
||||||
<span class="break-all">{m.attachment.filename}</span>
|
|
||||||
<span class="opacity-70 text-xs">
|
|
||||||
{(m.attachment.size / 1024).toFixed(0)} KB
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs opacity-70">
|
|
||||||
📎 {m.attachment.filename} (loading…)
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/await}
|
|
||||||
{:else}
|
|
||||||
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
|
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
|
||||||
{/if}
|
|
||||||
<!--
|
<!--
|
||||||
Inline timestamp + delivery status. `float-right` +
|
Inline timestamp. `float-right` + a leading non-
|
||||||
a leading non-breaking space pulls the cluster onto
|
breaking space pulls the time onto the same baseline
|
||||||
the same baseline as the last line of text when
|
as the last line of text when there's room, and
|
||||||
there's room, and drops to its own line when the
|
drops to its own line when the text wraps right up
|
||||||
text wraps right up to it. Lower opacity so it
|
to it. Lower opacity so it doesn't compete.
|
||||||
doesn't compete.
|
|
||||||
-->
|
-->
|
||||||
<span
|
<span
|
||||||
class={[
|
class={[
|
||||||
"float-right ml-2 mt-1 text-[10px] leading-none select-none flex items-center gap-1",
|
"float-right ml-2 mt-1 text-[10px] leading-none select-none",
|
||||||
out ? "text-accent-contrast/70" : "text-text-muted",
|
out ? "text-accent-contrast/70" : "text-text-muted",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>{formatTime(m.ts)}</span>
|
||||||
<span>{formatTime(m.ts)}</span>
|
|
||||||
{#if out}
|
|
||||||
{#if m.status === "sending"}
|
|
||||||
<!-- Hollow circle: publish in flight. -->
|
|
||||||
<svg viewBox="0 0 16 16" class="w-3 h-3 inline opacity-80" fill="none" stroke="currentColor" stroke-width="1.5" aria-label="Sending"><circle cx="8" cy="8" r="6"/></svg>
|
|
||||||
{:else if m.status === "failed"}
|
|
||||||
<!-- Red exclamation in circle. -->
|
|
||||||
<svg viewBox="0 0 16 16" class="w-3 h-3 inline" fill="none" stroke="#ff6b6b" stroke-width="1.5" aria-label="Failed to send"><circle cx="8" cy="8" r="6"/><line x1="8" y1="5" x2="8" y2="9" stroke-linecap="round"/><circle cx="8" cy="11.5" r="0.6" fill="#ff6b6b" stroke="none"/></svg>
|
|
||||||
{:else if m.status === "delivered"}
|
|
||||||
<!-- Check inside a circle = received by recipient. -->
|
|
||||||
<svg viewBox="0 0 16 16" class="w-3.5 h-3.5 inline" fill="none" stroke="currentColor" stroke-width="1.5" aria-label="Delivered"><circle cx="8" cy="8" r="6.5"/><path d="M4.8 8.4 L7 10.5 L11.2 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
{:else}
|
|
||||||
<!-- Single check = sent to nostr (at least 1 relay). -->
|
|
||||||
<svg viewBox="0 0 16 16" class="w-3 h-3 inline" fill="none" stroke="currentColor" stroke-width="1.8" aria-label="Sent"><path d="M3 8.5 L6.5 12 L13 5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<!-- Screen-reader timestamp (the visual one is decorative). -->
|
<!-- Screen-reader timestamp (the visual one is decorative). -->
|
||||||
<span class="sr-only">{formatTime(m.ts)}{#if out && m.status} — {m.status}{/if}</span>
|
<span class="sr-only">{formatTime(m.ts)}</span>
|
||||||
{#if out && m.accepted_by}
|
|
||||||
<!-- "via X" — tiny hint that surfaces which relay
|
|
||||||
carried this message. Float-right clears the
|
|
||||||
float'd timestamp so it lands on its own line
|
|
||||||
beneath, in a quieter color. -->
|
|
||||||
<span
|
|
||||||
class="block float-right clear-right mt-0.5 text-[9px] leading-none select-none {out
|
|
||||||
? 'text-accent-contrast/60'
|
|
||||||
: 'text-text-muted'}"
|
|
||||||
title="Relay this message was published through"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
via {m.accepted_by.replace(/^wss?:\/\//, "")}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@ -1097,39 +490,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Compose -->
|
<!-- Compose -->
|
||||||
<div class="border-t border-border bg-surface">
|
<form class="p-3 border-t border-border bg-surface flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}>
|
||||||
{#if fileSendError}
|
|
||||||
<p class="px-3 pt-2 text-xs text-danger">{fileSendError}</p>
|
|
||||||
{/if}
|
|
||||||
<form class="p-3 flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}>
|
|
||||||
<EmojiButton onpick={insertEmoji} />
|
<EmojiButton onpick={insertEmoji} />
|
||||||
<!-- Paperclip — image picker. Hidden file input driven
|
|
||||||
by a styled button. accept=image/* keeps the OS picker
|
|
||||||
showing photos only on mobile; if you want arbitrary
|
|
||||||
files later, drop the accept. -->
|
|
||||||
<input
|
|
||||||
bind:this={fileInput}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
onchange={(e) => {
|
|
||||||
const f = (e.currentTarget as HTMLInputElement).files?.[0];
|
|
||||||
if (f) void onFilePicked(f);
|
|
||||||
(e.currentTarget as HTMLInputElement).value = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="shrink-0 text-text-secondary hover:text-text disabled:opacity-50 p-1"
|
|
||||||
disabled={composing}
|
|
||||||
onclick={() => fileInput?.click()}
|
|
||||||
aria-label="Attach a file"
|
|
||||||
title="Attach a file (max {MAX_FILE_BYTES / 1024 / 1024}MB)"
|
|
||||||
>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={composeText}
|
bind:value={composeText}
|
||||||
@ -1147,49 +509,6 @@
|
|||||||
{composing ? "…" : "Send"}
|
{composing ? "…" : "Send"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--
|
|
||||||
Avatar zoom overlay. Only renders when the user tapped the thread
|
|
||||||
header avatar. Black backdrop fills the viewport; the picture
|
|
||||||
scales to ~70% of the shorter viewport edge so it has breathing
|
|
||||||
room. Click anywhere (backdrop OR picture) to dismiss — easier
|
|
||||||
than hunting for an X button on mobile.
|
|
||||||
-->
|
|
||||||
{#if zoomedAvatarOpen && activeConv && peerProfiles.byPrimary[activeConv.peer_primary]?.picture}
|
|
||||||
{@const peerPic = peerProfiles.byPrimary[activeConv.peer_primary]?.picture}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
|
|
||||||
onclick={closeZoomedAvatar}
|
|
||||||
onkeydown={onZoomKeydown}
|
|
||||||
aria-label="Close profile picture"
|
|
||||||
>
|
|
||||||
<div class="relative flex flex-col items-center gap-3 max-w-[90vw]">
|
|
||||||
<!-- The picture itself. We render inline here rather than
|
|
||||||
reusing the Avatar component because Avatar bakes size
|
|
||||||
into the img's width attribute, which clashes with the
|
|
||||||
responsive CSS sizing we want at zoom time. min(70vw,
|
|
||||||
70vh) keeps it comfortably inside the viewport in both
|
|
||||||
orientations. -->
|
|
||||||
<img
|
|
||||||
src={peerPic}
|
|
||||||
alt="profile picture"
|
|
||||||
class="object-cover rounded-2xl shadow-2xl"
|
|
||||||
style="width: min(70vw, 70vh); height: min(70vw, 70vh);"
|
|
||||||
/>
|
|
||||||
<p class="font-mono text-sm text-white/90 truncate max-w-full">
|
|
||||||
{displayName(activeConv)}
|
|
||||||
</p>
|
|
||||||
{#if peerProfiles.byPrimary[activeConv.peer_primary]?.picture_was_encrypted}
|
|
||||||
<p class="text-[10px] text-white/60">
|
|
||||||
🔒 visually-encrypted on nostr — descrambled for you
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@ -2,16 +2,8 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { push } from "svelte-spa-router";
|
import { push } from "svelte-spa-router";
|
||||||
import { bytesToHex } from "@noble/hashes/utils";
|
import { bytesToHex } from "@noble/hashes/utils";
|
||||||
import {
|
import { session } from "../lib/store.svelte.js";
|
||||||
session,
|
import { hasStoredPhrase } from "../lib/identity-store.js";
|
||||||
setMyProfile,
|
|
||||||
setPushAutoEnableDisabled,
|
|
||||||
} from "../lib/store.svelte.js";
|
|
||||||
import { resizeToAvatarDataUrl, dataUrlBytes } from "../lib/image-utils.js";
|
|
||||||
import { scrambleImage } from "../lib/visual-crypto.js";
|
|
||||||
import { hexToBytes } from "@noble/hashes/utils";
|
|
||||||
import Avatar from "../lib/Avatar.svelte";
|
|
||||||
import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js";
|
|
||||||
import {
|
import {
|
||||||
hasStoredBiometric,
|
hasStoredBiometric,
|
||||||
getStoredBiometricMeta,
|
getStoredBiometricMeta,
|
||||||
@ -58,79 +50,6 @@
|
|||||||
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
||||||
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
|
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
|
||||||
|
|
||||||
// ─── Profile picture ──────────────────────────────────────────────
|
|
||||||
let pictureBusy = $state(false);
|
|
||||||
let pictureError = $state<string | null>(null);
|
|
||||||
let pictureFileInput = $state<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
// "What strangers see" preview of the encrypted picture. Computed
|
|
||||||
// lazily — whenever picture or picture_key changes (or encryption
|
|
||||||
// gets turned on), regenerate the scrambled thumbnail. Memoised
|
|
||||||
// by `previewForKey` so we don't re-scramble on every keystroke.
|
|
||||||
let scrambledPreview = $state<string | null>(null);
|
|
||||||
let previewForKey = $state<string | null>(null);
|
|
||||||
let previewBusy = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// Re-read into locals so Svelte tracks them as deps.
|
|
||||||
const enc = session.myProfile?.encrypted;
|
|
||||||
const pic = session.myProfile?.picture;
|
|
||||||
const key = session.myProfile?.picture_key;
|
|
||||||
if (!enc || !pic || !key) {
|
|
||||||
scrambledPreview = null;
|
|
||||||
previewForKey = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Cheap memo key: same picture+same key = same scramble (modulo
|
|
||||||
// the salt the scrambler picks per-call, but we want a STABLE
|
|
||||||
// preview here — so we only recompute when inputs change).
|
|
||||||
const cacheKey = `${key}|${pic.length}`;
|
|
||||||
if (cacheKey === previewForKey && scrambledPreview) return;
|
|
||||||
|
|
||||||
previewBusy = true;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const scrambled = await scrambleImage(pic, hexToBytes(key));
|
|
||||||
scrambledPreview = scrambled;
|
|
||||||
previewForKey = cacheKey;
|
|
||||||
} catch (e) {
|
|
||||||
// Non-fatal: just hide the preview.
|
|
||||||
console.warn("encrypted-preview failed:", e);
|
|
||||||
scrambledPreview = null;
|
|
||||||
} finally {
|
|
||||||
previewBusy = false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onPicturePicked(file: File) {
|
|
||||||
pictureBusy = true;
|
|
||||||
pictureError = null;
|
|
||||||
try {
|
|
||||||
const dataUrl = await resizeToAvatarDataUrl(file);
|
|
||||||
await setMyProfile({ picture: dataUrl });
|
|
||||||
} catch (e) {
|
|
||||||
pictureError = (e as Error).message;
|
|
||||||
} finally {
|
|
||||||
pictureBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removePicture() {
|
|
||||||
pictureBusy = true;
|
|
||||||
pictureError = null;
|
|
||||||
try {
|
|
||||||
// saveMyProfile keeps the picture field as undefined → Avatar
|
|
||||||
// falls back to the identicon. We also re-publish the kind:0
|
|
||||||
// event WITHOUT the picture so peers stop seeing the old one.
|
|
||||||
await setMyProfile({ picture: undefined });
|
|
||||||
} catch (e) {
|
|
||||||
pictureError = (e as Error).message;
|
|
||||||
} finally {
|
|
||||||
pictureBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let webPushOk = $state(false); // browser supports it at all
|
let webPushOk = $state(false); // browser supports it at all
|
||||||
let webPushOn = $state(false); // currently subscribed
|
let webPushOn = $state(false); // currently subscribed
|
||||||
let webPushBusy = $state(false);
|
let webPushBusy = $state(false);
|
||||||
@ -203,15 +122,10 @@
|
|||||||
if (webPushOn) {
|
if (webPushOn) {
|
||||||
await disablePush(session.unlocked.handle, session.unlocked.seed);
|
await disablePush(session.unlocked.handle, session.unlocked.seed);
|
||||||
webPushOn = false;
|
webPushOn = false;
|
||||||
// Remember the explicit opt-out so we don't auto-enable on
|
|
||||||
// the next unlock — would be annoying right after the user
|
|
||||||
// turned it off.
|
|
||||||
setPushAutoEnableDisabled(true);
|
|
||||||
} else {
|
} else {
|
||||||
const ok = await enablePush(session.unlocked.handle, session.unlocked.seed);
|
const ok = await enablePush(session.unlocked.handle, session.unlocked.seed);
|
||||||
webPushOn = ok;
|
webPushOn = ok;
|
||||||
if (!ok) webPushError = "Permission denied.";
|
if (!ok) webPushError = "Permission denied.";
|
||||||
else setPushAutoEnableDisabled(false);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
webPushError = (e as Error).message;
|
webPushError = (e as Error).message;
|
||||||
@ -220,75 +134,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Reveal recovery phrase: gated by fresh passphrase ──────────────
|
async function showSeed() {
|
||||||
// 30 seconds of access to an unlocked phone shouldn't reveal the
|
|
||||||
// recovery phrase — yet that's exactly what the old `showSeed`
|
|
||||||
// alert() did (it just popped the in-session cached phrase, no
|
|
||||||
// re-auth). Now we gate behind a fresh passphrase prompt that
|
|
||||||
// verifies by attempting to decrypt the IDB-stored blob — same
|
|
||||||
// path the initial unlock uses, so we know the auth is real.
|
|
||||||
// See TODO.md Day 2 #4.
|
|
||||||
let revealPromptOpen = $state(false);
|
|
||||||
let revealPromptPassphrase = $state("");
|
|
||||||
let revealPromptError = $state<string | null>(null);
|
|
||||||
let revealPromptBusy = $state(false);
|
|
||||||
|
|
||||||
function openRevealPrompt() {
|
|
||||||
if (!session.unlocked) return;
|
if (!session.unlocked) return;
|
||||||
revealPromptPassphrase = "";
|
const phrase = session.unlocked.phrase;
|
||||||
revealPromptError = null;
|
|
||||||
revealPromptOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeRevealPrompt() {
|
|
||||||
// Zero out the buffer before nulling so the passphrase doesn't
|
|
||||||
// linger as a JS-engine intern. Probably overkill given JS string
|
|
||||||
// semantics, but it's free defense-in-depth.
|
|
||||||
revealPromptPassphrase = "";
|
|
||||||
revealPromptError = null;
|
|
||||||
revealPromptOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmRevealPrompt() {
|
|
||||||
if (!session.unlocked || revealPromptBusy) return;
|
|
||||||
revealPromptBusy = true;
|
|
||||||
revealPromptError = null;
|
|
||||||
try {
|
|
||||||
// unlockIdentity throws "wrong passphrase" on failure — that's
|
|
||||||
// our verification. We don't keep the freshly-unlocked struct;
|
|
||||||
// we just use the SAME session that's already in memory.
|
|
||||||
const fresh = await unlockIdentity(revealPromptPassphrase);
|
|
||||||
revealPromptOpen = false;
|
|
||||||
revealPromptPassphrase = "";
|
|
||||||
// Prefer the freshly-decrypted phrase (works even if this
|
|
||||||
// session was originally biometric-unlocked — the passphrase
|
|
||||||
// PRF key wasn't available before but is now).
|
|
||||||
const phrase = fresh.phrase ?? session.unlocked.phrase;
|
|
||||||
if (phrase) {
|
if (phrase) {
|
||||||
alert(
|
alert(
|
||||||
`Your 12-word recovery phrase (KEEP SECRET):\n\n${phrase}\n\n` +
|
`Your 12-word recovery phrase (KEEP SECRET):\n\n${phrase}\n\n` +
|
||||||
`Write these 12 words down in order — they're the ONLY way to ` +
|
`Write these 12 words down in order — they're the ONLY way to ` +
|
||||||
`recover this account on another device.`,
|
`recover this account on another device.`,
|
||||||
);
|
);
|
||||||
} else if (await hasStoredPhrase()) {
|
return;
|
||||||
|
}
|
||||||
|
// Phrase not in this session — distinguish two cases:
|
||||||
|
// 1. Account HAS a stored phrase but this session unlocked via
|
||||||
|
// biometric (PRF key doesn't decrypt the passphrase-keyed blob).
|
||||||
|
// 2. Genuinely pre-mnemonic legacy account — show hex.
|
||||||
|
if (await hasStoredPhrase()) {
|
||||||
alert(
|
alert(
|
||||||
`Your recovery phrase couldn't be decrypted in this session. ` +
|
`Your recovery phrase isn't available in this session.\n\n` +
|
||||||
`Lock and unlock again with your passphrase to reveal it.`,
|
`Biometric unlock doesn't decrypt the phrase. Lock and unlock ` +
|
||||||
|
`again with your passphrase to reveal it.`,
|
||||||
);
|
);
|
||||||
} else {
|
return;
|
||||||
const hex = bytesToHex(fresh.seed);
|
}
|
||||||
|
const hex = bytesToHex(session.unlocked.seed);
|
||||||
alert(
|
alert(
|
||||||
`Your recovery seed — hex form (KEEP SECRET):\n\n${hex}\n\n` +
|
`Your recovery seed — hex form (KEEP SECRET):\n\n${hex}\n\n` +
|
||||||
`This account was created before 12-word phrases were supported. ` +
|
`This account was created before 12-word phrases were supported. ` +
|
||||||
`The 64-character hex above is still your full recovery.`,
|
`The 64-character hex above is still your full recovery — write ` +
|
||||||
|
`it down somewhere safe.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
revealPromptError = (e as Error).message;
|
|
||||||
} finally {
|
|
||||||
revealPromptBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function lock() {
|
function lock() {
|
||||||
session.lock();
|
session.lock();
|
||||||
@ -299,170 +175,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if session.unlocked}
|
{#if session.unlocked}
|
||||||
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
<!--
|
|
||||||
Profile picture. Renders the user's own Avatar (which now
|
|
||||||
honours `picture` if set) next to a file picker that resizes
|
|
||||||
and stores the image both locally and as a nostr kind:0
|
|
||||||
event so peers can fetch it later. Falls back to the
|
|
||||||
identicon when no picture is set.
|
|
||||||
-->
|
|
||||||
<section class="bg-surface border border-border rounded-xl p-6">
|
|
||||||
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-3">
|
|
||||||
Profile
|
|
||||||
</h2>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<!-- Stacked: real avatar (big) + "strangers see this" thumb
|
|
||||||
tucked into the bottom-right corner. Only renders when
|
|
||||||
encryption is on AND we have a picture. -->
|
|
||||||
<div class="relative shrink-0">
|
|
||||||
<Avatar
|
|
||||||
seed={session.unlocked.primary}
|
|
||||||
size={80}
|
|
||||||
ring
|
|
||||||
picture={session.myProfile?.picture}
|
|
||||||
/>
|
|
||||||
{#if session.myProfile?.encrypted && session.myProfile?.picture && scrambledPreview}
|
|
||||||
<!-- Strip the URL-fragment salt so the <img> doesn't
|
|
||||||
trigger Chrome's "weird URL" warning. The salt isn't
|
|
||||||
needed for rendering, only for descrambling. -->
|
|
||||||
{@const previewSrc = scrambledPreview.split("#")[0]}
|
|
||||||
<div
|
|
||||||
class="absolute -bottom-1 -right-1 w-9 h-9 rounded-md overflow-hidden border-2 border-surface shadow-md"
|
|
||||||
title="What strangers see on public nostr clients"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={previewSrc}
|
|
||||||
width="36"
|
|
||||||
height="36"
|
|
||||||
alt="Encrypted preview — what strangers see"
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if session.myProfile?.encrypted && session.myProfile?.picture && previewBusy}
|
|
||||||
<div
|
|
||||||
class="absolute -bottom-1 -right-1 w-9 h-9 rounded-md border-2 border-surface bg-elevated flex items-center justify-center text-[10px] text-text-muted"
|
|
||||||
title="Rendering preview…"
|
|
||||||
>
|
|
||||||
…
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="font-mono text-sm font-semibold text-text truncate">
|
|
||||||
{session.unlocked.handle}@{session.unlocked.server}
|
|
||||||
</p>
|
|
||||||
{#if session.myProfile?.picture}
|
|
||||||
<p class="text-xs text-text-muted mt-1">
|
|
||||||
Custom picture · {Math.ceil(
|
|
||||||
dataUrlBytes(session.myProfile.picture) / 1024,
|
|
||||||
)} KB · stored locally + published to nostr
|
|
||||||
</p>
|
|
||||||
{#if session.myProfile?.encrypted && scrambledPreview}
|
|
||||||
<p class="text-[10px] text-text-muted mt-1">
|
|
||||||
The little box is what strangers see on public nostr —
|
|
||||||
visually-scrambled noise. Your contacts see the real
|
|
||||||
picture.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs text-text-muted mt-1">
|
|
||||||
Showing your auto-generated identicon. Pick a picture
|
|
||||||
to replace it.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden file input; buttons drive it so we can style freely. -->
|
|
||||||
<input
|
|
||||||
bind:this={pictureFileInput}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
onchange={(e) => {
|
|
||||||
const f = (e.currentTarget as HTMLInputElement).files?.[0];
|
|
||||||
if (f) void onPicturePicked(f);
|
|
||||||
(e.currentTarget as HTMLInputElement).value = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
|
|
||||||
disabled={pictureBusy}
|
|
||||||
onclick={() => pictureFileInput?.click()}
|
|
||||||
>
|
|
||||||
{pictureBusy
|
|
||||||
? "Working…"
|
|
||||||
: session.myProfile?.picture
|
|
||||||
? "Replace picture"
|
|
||||||
: "Choose picture"}
|
|
||||||
</button>
|
|
||||||
{#if session.myProfile?.picture}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:text-danger hover:border-danger disabled:opacity-50"
|
|
||||||
disabled={pictureBusy}
|
|
||||||
onclick={removePicture}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if pictureError}
|
|
||||||
<p class="mt-2 text-xs text-danger">{pictureError}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Privacy toggle. When ON (the default), the picture published
|
|
||||||
to nostr is visually scrambled — strangers see colored noise,
|
|
||||||
contacts you've messaged can descramble. When OFF, the
|
|
||||||
picture goes out in cleartext (any nostr client can render).
|
|
||||||
-->
|
|
||||||
<div class="mt-4 pt-4 border-t border-border space-y-3">
|
|
||||||
<label class="flex items-start gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="mt-0.5 shrink-0"
|
|
||||||
checked={session.myProfile?.encrypted ?? true}
|
|
||||||
disabled={pictureBusy}
|
|
||||||
onchange={async (e) => {
|
|
||||||
const checked = (e.currentTarget as HTMLInputElement).checked;
|
|
||||||
pictureBusy = true;
|
|
||||||
pictureError = null;
|
|
||||||
try {
|
|
||||||
await setMyProfile({ encrypted: checked });
|
|
||||||
} catch (err) {
|
|
||||||
pictureError = (err as Error).message;
|
|
||||||
} finally {
|
|
||||||
pictureBusy = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div class="text-sm">
|
|
||||||
<p class="font-semibold text-text">
|
|
||||||
Visually encrypt picture
|
|
||||||
<span class="text-xs text-text-muted font-normal">(recommended)</span>
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-text-secondary mt-0.5">
|
|
||||||
Strangers see colored noise. People you've messaged can
|
|
||||||
descramble and see your real face. Your face is private
|
|
||||||
by default — your contacts are not.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-3 text-[10px] text-text-muted">
|
|
||||||
Pictures are resized to 256×256 and published as a NIP-01
|
|
||||||
kind:0 event. {session.myProfile?.encrypted ?? true
|
|
||||||
? "Visually-encrypted images survive any nostr client that renders a PNG; only kez-chat-aware clients with the right key descramble."
|
|
||||||
: "Stored locally too so it works offline."}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Appearance -->
|
<!-- Appearance -->
|
||||||
<section class="bg-surface border border-border rounded-xl p-6">
|
<section class="bg-surface border border-border rounded-xl p-6">
|
||||||
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-3">Appearance</h2>
|
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-3">Appearance</h2>
|
||||||
@ -526,7 +239,7 @@
|
|||||||
12 words that recover this account anywhere. Write them down on
|
12 words that recover this account anywhere. Write them down on
|
||||||
paper — losing them means losing the account.
|
paper — losing them means losing the account.
|
||||||
</p>
|
</p>
|
||||||
<button class="mt-2 px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={openRevealPrompt}>
|
<button class="mt-2 px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={showSeed}>
|
||||||
Reveal phrase
|
Reveal phrase
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -628,65 +341,3 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!--
|
|
||||||
Fresh-passphrase prompt for Reveal Phrase. Same threat-model
|
|
||||||
argument the OS uses for showing iCloud-stored passwords or 1Password
|
|
||||||
vault items: "you were already unlocked, but this is sensitive enough
|
|
||||||
that we want fresh proof you're really you." Closing the modal
|
|
||||||
without confirming wipes the in-memory passphrase buffer.
|
|
||||||
-->
|
|
||||||
{#if revealPromptOpen}
|
|
||||||
<div
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Confirm passphrase"
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
class="w-full max-w-sm bg-surface border border-border rounded-xl p-5 space-y-4 shadow-2xl"
|
|
||||||
onsubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void confirmRevealPrompt();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h3 class="text-base font-semibold text-text">Confirm passphrase</h3>
|
|
||||||
<p class="text-xs text-text-secondary">
|
|
||||||
Showing your recovery phrase reveals enough to take over the
|
|
||||||
account. Type your passphrase to continue — even though
|
|
||||||
you're already signed in.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
class="w-full px-3 py-2 text-sm bg-elevated border border-border rounded-md text-text placeholder:text-text-muted focus:border-accent focus:outline-none font-mono"
|
|
||||||
placeholder="Your passphrase"
|
|
||||||
bind:value={revealPromptPassphrase}
|
|
||||||
disabled={revealPromptBusy}
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
{#if revealPromptError}
|
|
||||||
<p class="text-xs text-danger">{revealPromptError}</p>
|
|
||||||
{/if}
|
|
||||||
<div class="flex items-center justify-end gap-2 pt-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50"
|
|
||||||
disabled={revealPromptBusy}
|
|
||||||
onclick={closeRevealPrompt}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
|
|
||||||
disabled={revealPromptBusy || !revealPromptPassphrase}
|
|
||||||
>
|
|
||||||
{revealPromptBusy ? "Checking…" : "Reveal"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@ -93,12 +93,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if session.unlocked}
|
{#if session.unlocked}
|
||||||
<!--
|
<div class="max-w-xl mx-auto py-6 space-y-6">
|
||||||
px-4 keeps the cards breathing on mobile (viewport < max-w-xl).
|
|
||||||
On larger screens the mx-auto centering takes over and the
|
|
||||||
horizontal padding is essentially invisible.
|
|
||||||
-->
|
|
||||||
<div class="max-w-xl mx-auto px-4 py-6 space-y-6">
|
|
||||||
<div class="text-center space-y-2">
|
<div class="text-center space-y-2">
|
||||||
<div class="flex justify-center"><Wordmark size={28} /></div>
|
<div class="flex justify-center"><Wordmark size={28} /></div>
|
||||||
<h1 class="text-xl font-semibold text-text">Welcome — let's get you set up</h1>
|
<h1 class="text-xl font-semibold text-text">Welcome — let's get you set up</h1>
|
||||||
@ -106,12 +101,7 @@
|
|||||||
A couple of quick steps. You can skip and come back anytime from Settings.
|
A couple of quick steps. You can skip and come back anytime from Settings.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center justify-center gap-3 pt-1">
|
<div class="flex items-center justify-center gap-3 pt-1">
|
||||||
<Avatar
|
<Avatar seed={session.unlocked.primary} size={44} ring />
|
||||||
seed={session.unlocked.primary}
|
|
||||||
size={44}
|
|
||||||
ring
|
|
||||||
picture={session.myProfile?.picture}
|
|
||||||
/>
|
|
||||||
<span class="font-mono text-sm text-accent">{session.unlocked.handle}@{session.unlocked.server}</span>
|
<span class="font-mono text-sm text-accent">{session.unlocked.handle}@{session.unlocked.server}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-text-muted">{done} of {total} essentials done</p>
|
<p class="text-xs text-text-muted">{done} of {total} essentials done</p>
|
||||||
|
|||||||
@ -55,13 +55,28 @@ self.addEventListener("activate", (event) => {
|
|||||||
// and decrypts there. This keeps the push provider (Apple/Google/Mozilla)
|
// and decrypts there. This keeps the push provider (Apple/Google/Mozilla)
|
||||||
// from ever seeing message content even theoretically.
|
// from ever seeing message content even theoretically.
|
||||||
|
|
||||||
|
interface PushPayload {
|
||||||
|
type?: string;
|
||||||
|
to?: string;
|
||||||
|
seq?: number;
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("push", (event: PushEvent) => {
|
self.addEventListener("push", (event: PushEvent) => {
|
||||||
// Payload is intentionally empty (see TODO.md Day 1 #3 — we used
|
let data: PushPayload = {};
|
||||||
// to send {to, seq} but that leaked the social graph to the push
|
if (event.data) {
|
||||||
// provider). Notification text has to be content-free; the user
|
try {
|
||||||
// opens the app to see who messaged them.
|
data = event.data.json() as PushPayload;
|
||||||
|
} catch {
|
||||||
|
// Some providers send a wake-up "" payload — fall through and
|
||||||
|
// show a generic notification so the user knows to open the app.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const title = "New kez-chat message";
|
const title = "New kez-chat message";
|
||||||
const body = "Open kez-chat to view it.";
|
const body =
|
||||||
|
data.to !== undefined
|
||||||
|
? `You have a new message in @${data.to}`
|
||||||
|
: "Open kez-chat to view it.";
|
||||||
|
|
||||||
// `renotify` is widely supported but isn't in the baseline TS DOM lib;
|
// `renotify` is widely supported but isn't in the baseline TS DOM lib;
|
||||||
// build the options as a plain object and cast.
|
// build the options as a plain object and cast.
|
||||||
@ -69,10 +84,11 @@ self.addEventListener("push", (event: PushEvent) => {
|
|||||||
body,
|
body,
|
||||||
icon: "/pwa-192x192.png",
|
icon: "/pwa-192x192.png",
|
||||||
badge: "/pwa-64x64.png",
|
badge: "/pwa-64x64.png",
|
||||||
// Single tag so successive pushes collapse into one notification
|
// Group same-conversation pings — iOS especially gets spammy
|
||||||
// pill (no spam if a friend sends 5 quick messages).
|
// otherwise. `renotify` lets the next one still vibrate.
|
||||||
tag: "kez-chat:new",
|
tag: data.to ? `kez-chat:${data.to}` : "kez-chat:new",
|
||||||
renotify: true,
|
renotify: true,
|
||||||
|
data,
|
||||||
} as NotificationOptions;
|
} as NotificationOptions;
|
||||||
|
|
||||||
event.waitUntil(self.registration.showNotification(title, options));
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
@ -80,12 +96,8 @@ self.addEventListener("push", (event: PushEvent) => {
|
|||||||
|
|
||||||
self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
// Push payload is empty (TODO.md Day 1 #3) so there's no per-peer
|
const data = event.notification.data as PushPayload | undefined;
|
||||||
// deep link to honour. Land everyone on the conversation list and
|
const targetUrl = data?.to ? `/chats/${encodeURIComponent(data.to)}` : "/chats";
|
||||||
// let them tap through. ?from=push lets App.svelte log a tiny
|
|
||||||
// breadcrumb for "tapped notification → wrong page" reports.
|
|
||||||
const hashTarget = "/chats";
|
|
||||||
const fullUrl = `/?from=push#${hashTarget}`;
|
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -99,11 +111,11 @@ self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
|||||||
// Found an already-open kez-chat tab — focus it and ask the
|
// Found an already-open kez-chat tab — focus it and ask the
|
||||||
// SPA to navigate; cheaper than spawning a fresh window.
|
// SPA to navigate; cheaper than spawning a fresh window.
|
||||||
await client.focus();
|
await client.focus();
|
||||||
client.postMessage({ type: "kez-chat/navigate", to: hashTarget });
|
client.postMessage({ type: "kez-chat/navigate", to: targetUrl });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await self.clients.openWindow(fullUrl);
|
await self.clients.openWindow(targetUrl);
|
||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user