diff --git a/.env.example b/.env.example index 8f605e18..dff33ce8 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,12 @@ NODE_ENV=development # Server port PORT=3001 +# Passkeys: use the production hostname and origin when enabling WebAuthn. +PASSKEY_RP_ID=localhost +PASSKEY_ORIGIN=http://localhost:3000 +PASSKEY_RP_NAME=HelPhone +SESSION_SECRET=replace-with-at-least-32-random-characters + # Database connection DATABASE_URL=postgresql://user:password@localhost:5432/helphone diff --git a/.gitignore b/.gitignore index fff02f4f..a378b661 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ tests/integration/.local-deployment.json # WASM spike reader build output (ADR-014) src/wasm/**/target/ + +# Python tooling cache +__pycache__/ +*.py[cod] diff --git a/circuits/scripts/shard-aegis.py b/circuits/scripts/shard-aegis.py new file mode 100644 index 00000000..eef45dfa --- /dev/null +++ b/circuits/scripts/shard-aegis.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Split a large aegis.json asset into cacheable, bounded-size files.""" + +import argparse +import hashlib +import json +from pathlib import Path + + +def shard(source: Path, output: Path, chunk_bytes: int) -> Path: + if chunk_bytes < 1: + raise ValueError("chunk size must be positive") + if source.stat().st_size == 0: + raise ValueError("cannot shard an empty circuit artifact") + output.mkdir(parents=True, exist_ok=True) + files = [] + with source.open("rb") as stream: + index = 0 + while chunk := stream.read(chunk_bytes): + name = f"aegis.chunk{index:04d}" + (output / name).write_bytes(chunk) + files.append({"file": name, "bytes": len(chunk), "sha256": hashlib.sha256(chunk).hexdigest()}) + index += 1 + digest = hashlib.sha256() + with source.open("rb") as stream: + for part in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(part) + manifest = {"version": 1, "bytes": source.stat().st_size, "sha256": digest.hexdigest(), "chunks": files} + target = output / "aegis.manifest.json" + target.write_text(json.dumps(manifest, separators=(",", ":")) + "\n", encoding="utf-8") + return target + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("source", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--chunk-bytes", type=int, default=5 * 1024 * 1024) + args = parser.parse_args() + print(shard(args.source, args.output, args.chunk_bytes)) + + +if __name__ == "__main__": + main() diff --git a/circuits/scripts/test-shard-aegis.py b/circuits/scripts/test-shard-aegis.py new file mode 100644 index 00000000..c88ac1d2 --- /dev/null +++ b/circuits/scripts/test-shard-aegis.py @@ -0,0 +1,28 @@ +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).with_name("shard-aegis.py") +SPEC = importlib.util.spec_from_file_location("shard_aegis", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class ShardAegisTests(unittest.TestCase): + def test_shards_reassemble_and_manifest_sizes_match(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "aegis.json" + payload = bytes(range(256)) * 9 + source.write_bytes(payload) + manifest_path = MODULE.shard(source, root / "out", 512) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + rebuilt = b"".join((manifest_path.parent / item["file"]).read_bytes() for item in manifest["chunks"]) + self.assertEqual(rebuilt, payload) + self.assertTrue(all(item["bytes"] <= 512 for item in manifest["chunks"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/contract/contracts/helphone-contract/src/compression.rs b/contract/contracts/helphone-contract/src/compression.rs new file mode 100644 index 00000000..cd081cbb --- /dev/null +++ b/contract/contracts/helphone-contract/src/compression.rs @@ -0,0 +1,40 @@ +use soroban_sdk::{BytesN, Env}; + +/// Lossless 16-byte encoding: signed latitude (i32), signed longitude (i32), ledger timestamp (u64). +pub fn pack_location(env: &Env, lat: i32, lng: i32, timestamp: u64) -> BytesN<16> { + let mut bytes = [0u8; 16]; + bytes[..4].copy_from_slice(&lat.to_be_bytes()); + bytes[4..8].copy_from_slice(&lng.to_be_bytes()); + bytes[8..].copy_from_slice(×tamp.to_be_bytes()); + BytesN::from_array(env, &bytes) +} + +pub fn unpack_location(packed: &BytesN<16>) -> (i32, i32, u64) { + let bytes = packed.to_array(); + ( + i32::from_be_bytes(bytes[..4].try_into().expect("four-byte latitude")), + i32::from_be_bytes(bytes[4..8].try_into().expect("four-byte longitude")), + u64::from_be_bytes(bytes[8..].try_into().expect("eight-byte timestamp")), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::Env; + + #[test] + fn round_trips_signed_coordinates_and_full_timestamp() { + let env = Env::default(); + let cases = [ + (-90_000_000, -180_000_000, 0), + (90_000_000, 180_000_000, u64::MAX), + ]; + for (lat, lng, timestamp) in cases { + assert_eq!( + unpack_location(&pack_location(&env, lat, lng, timestamp)), + (lat, lng, timestamp) + ); + } + } +} diff --git a/contract/contracts/helphone-contract/src/lib.rs b/contract/contracts/helphone-contract/src/lib.rs index a0637cfe..ac980516 100644 --- a/contract/contracts/helphone-contract/src/lib.rs +++ b/contract/contracts/helphone-contract/src/lib.rs @@ -5,6 +5,7 @@ use soroban_sdk::{ Env, String, }; +mod compression; mod multisig; mod nonce; mod ring_buffer; @@ -24,7 +25,8 @@ pub use multisig::{Proposal, ProposalAction}; // ("active", u32) → u64 active request IDs by slot index // // Persistent (pay-to-live): -// ("req", u64) → HelpRequest +// ("req2", u64) → StoredHelpRequest (packed coordinates and timestamp) +// ("req", u64) → HelpRequest (legacy entries, read through migration fallback) // ("rcount", request_id) → u32 responder count per request // ("resp", request_id, idx) → ResponderRecord // ("evcount", wallet) → u32 verifications ever recorded per wallet (write cursor) @@ -37,7 +39,7 @@ pub use multisig::{Proposal, ProposalAction}; // repeat invocations. // // * mark_arrived / resolve_request / cancel_request touch only -// ("req", id) / ("resp", request_id, responder_index) — fully determined +// ("req2", id) / ("resp", request_id, responder_index) — fully determined // by the function arguments, so the client pre-bakes their footprint. // * create_request / accept_request / record_expert_verification append to // counter/slot keys whose values depend on on-chain state; their footprint @@ -104,6 +106,61 @@ pub struct HelpRequest { pub resolved_at: Option, } +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +struct StoredHelpRequest { + pub id: u64, + pub requester: Address, + pub packed_location: soroban_sdk::BytesN<16>, + pub emergency_type: String, + pub nickname: String, + pub contact: String, + pub status: Status, + pub resolved_at: Option, +} + +fn load_request(env: &Env, id: u64) -> Option { + if let Some(stored) = env.storage().persistent().get(&(symbol_short!("req2"), id)) { + return Some(stored); + } + let legacy: HelpRequest = env + .storage() + .persistent() + .get(&(symbol_short!("req"), id))?; + Some(StoredHelpRequest { + id: legacy.id, + requester: legacy.requester, + packed_location: compression::pack_location(env, legacy.lat, legacy.lng, legacy.created_at), + emergency_type: legacy.emergency_type, + nickname: legacy.nickname, + contact: legacy.contact, + status: legacy.status, + resolved_at: legacy.resolved_at, + }) +} + +fn save_request(env: &Env, request: &StoredHelpRequest) { + env.storage() + .persistent() + .set(&(symbol_short!("req2"), request.id), request); +} + +fn request_view(request: StoredHelpRequest) -> HelpRequest { + let (lat, lng, created_at) = compression::unpack_location(&request.packed_location); + HelpRequest { + id: request.id, + requester: request.requester, + lat, + lng, + emergency_type: request.emergency_type, + nickname: request.nickname, + contact: request.contact, + status: request.status, + created_at, + resolved_at: request.resolved_at, + } +} + #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub struct ResponderRecord { @@ -266,7 +323,7 @@ impl HelPhone { } pub fn get_request(env: Env, id: u64) -> Option { - env.storage().persistent().get(&(symbol_short!("req"), id)) + load_request(&env, id).map(request_view) } pub fn get_responder_count(env: Env, request_id: u64) -> u32 { @@ -342,21 +399,17 @@ impl HelPhone { requester.require_auth(); let count = Self::get_request_count(env.clone()) + 1; env.storage().instance().set(&key_req_count(), &count); - let req = HelpRequest { + let req = StoredHelpRequest { id: count, requester, - lat, - lng, + packed_location: compression::pack_location(&env, lat, lng, env.ledger().timestamp()), emergency_type, nickname, contact, status: Status::Pending, - created_at: env.ledger().timestamp(), resolved_at: None, }; - env.storage() - .persistent() - .set(&(symbol_short!("req"), count), &req); + save_request(&env, &req); // Append to active list let active_count = Self::get_active_count(env.clone()); env.storage() @@ -377,18 +430,12 @@ impl HelPhone { eta_seconds: u32, ) -> Result { responder.require_auth(); - let mut req: HelpRequest = env - .storage() - .persistent() - .get(&(symbol_short!("req"), request_id)) - .ok_or(Error::NotFound)?; + let mut req = load_request(&env, request_id).ok_or(Error::NotFound)?; if req.status != Status::Pending { return Err(Error::WrongStatus); } req.status = Status::Enroute; - env.storage() - .persistent() - .set(&(symbol_short!("req"), request_id), &req); + save_request(&env, &req); let idx: u32 = env .storage() @@ -439,11 +486,7 @@ impl HelPhone { pub fn resolve_request(env: Env, requester: Address, request_id: u64) -> Result<(), Error> { requester.require_auth(); - let mut req: HelpRequest = env - .storage() - .persistent() - .get(&(symbol_short!("req"), request_id)) - .ok_or(Error::NotFound)?; + let mut req = load_request(&env, request_id).ok_or(Error::NotFound)?; if req.requester != requester { return Err(Error::NotAuthorized); } @@ -452,19 +495,13 @@ impl HelPhone { } req.status = Status::Resolved; req.resolved_at = Some(env.ledger().timestamp()); - env.storage() - .persistent() - .set(&(symbol_short!("req"), request_id), &req); + save_request(&env, &req); Ok(()) } pub fn cancel_request(env: Env, requester: Address, request_id: u64) -> Result<(), Error> { requester.require_auth(); - let mut req: HelpRequest = env - .storage() - .persistent() - .get(&(symbol_short!("req"), request_id)) - .ok_or(Error::NotFound)?; + let mut req = load_request(&env, request_id).ok_or(Error::NotFound)?; if req.requester != requester { return Err(Error::NotAuthorized); } @@ -472,9 +509,7 @@ impl HelPhone { return Err(Error::WrongStatus); } req.status = Status::Cancelled; - env.storage() - .persistent() - .set(&(symbol_short!("req"), request_id), &req); + save_request(&env, &req); Ok(()) } diff --git a/package-lock.json b/package-lock.json index 55926c32..b0561bb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,8 @@ "@aztec/bb.js": "^0.87.9", "@creit-tech/stellar-wallets-kit": "^2.4.0", "@noir-lang/noir_js": "^1.0.0-beta.9", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/auto-instrumentations-node": "^0.67.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", - "@opentelemetry/sdk-node": "^0.214.0", + "@simplewebauthn/browser": "^14.0.0", + "@simplewebauthn/server": "^14.0.2", "@stellar/stellar-sdk": "^16.0.0", "@supabase/supabase-js": "^2.108.2", "buffer": "^6.0.3", @@ -26,8 +24,6 @@ "express": "^4.21.0", "express-rate-limit": "^8.6.2", "fuse.js": "^7.5.0", - "i18next": "^25.8.13", - "i18next-http-backend": "^3.0.2", "ioredis": "^5.4.1", "mapbox-gl": "^3.25.0", "pg": "^8.23.0", @@ -36,7 +32,7 @@ "react-i18next": "^16.5.4", "react-map-gl": "^8.1.1", "react-router-dom": "^7.18.0", - "ws": "^8.18.3" + "workbox-range-requests": "^7.4.1" }, "devDependencies": { "@eslint/js": "^9.0.0", @@ -5777,11 +5773,16 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.208.0.tgz", - "integrity": "sha512-rhmK46DRWEbQQB77RxmVXGyjs6783crXCnFjYQj+4tDH/Kpv9Rbg3h2kaNyp5Vz2emF1f9HOQQvZoHzwMWOFZQ==", - "license": "Apache-2.0", + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, + "node_modules/@hot-wallet/sdk": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@hot-wallet/sdk/-/sdk-1.0.11.tgz", + "integrity": "sha512-qRDH/4yqnRCnk7L/Qd0/LDOKDUKWcFgvf6eRELJkP0OgxIe65i/iXaG+u2lL0mLbTGkiWYk67uAvEerNUv2gzA==", "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/instrumentation": "0.208.0", @@ -5973,15 +5974,18 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/instrumentation-nestjs-core": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.55.0.tgz", - "integrity": "sha512-JFLNhbbEGnnQrMKOYoXx0nNk5N9cPeghu4xP/oup40a7VaSeYruyOiFbg9nkbS4ZQiI8aMuRqUT3Mo4lQjKEKg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.208.0", - "@opentelemetry/semantic-conventions": "^1.30.0" - }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -6127,12 +6131,36 @@ "integrity": "sha512-3gF9jJ7C3lwlCOer1KzKKdpLr6/c7yOZBP44KI+Xi/TqiZjhsfUlHjetzC6BLDjkSk1DnIGyf+YzJR4aF5dJBQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.208.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, + "@ledgerhq/devices": "8.6.1", + "@ledgerhq/errors": "^6.26.0", + "@ledgerhq/hw-transport": "^6.31.12", + "@ledgerhq/logs": "^6.13.0" + } + }, + "node_modules/@ledgerhq/logs": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/logs/-/logs-6.17.0.tgz", + "integrity": "sha512-yra33g5q/AU7+PwAws+GaVpQGUuxnDREjVBnviJjcaJLVKuLzI4pnj8Bd3nY3fypM5k1yZEYKEXfUuGFUjP2+w==", + "license": "Apache-2.0" + }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/react": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.8.tgz", + "integrity": "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==", + "license": "BSD-3-Clause", + "optional": true, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } @@ -6330,11 +6358,19 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/redis-common": { - "version": "0.38.3", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.3.tgz", - "integrity": "sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==", - "license": "Apache-2.0", + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": "^18.19.0 || >=20.6.0" } @@ -6742,6 +6778,239 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.5.tgz", + "integrity": "sha512-KnNE4l033gbJ3ZzfYk7fJpSZwhd5xT+dPAnrB8bcwqylHiLN2zjZ0OyFao0LDIOI/ZdXRabV0n3Ww/KvpgZq6w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-asym-key": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.5.tgz", + "integrity": "sha512-U3Q2bUWYNVpfk+oOhPl/+OSHxXaBTQBwMf93jJ7oPi/sbmcY5CnARdjGMwLoXT/ggWSryo5t7A7o3/Gfz0U2WA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-pkcs8": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.5.tgz", + "integrity": "sha512-4Ivz0fsyFofe0bslNsYcGN9+iETdHX4v+BFwIPr+K/BAt61sHXECP63ff9FntU9zcfVQELJFdUG9so44OjUlBQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "@peculiar/asn1-x509-attr": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.5.tgz", + "integrity": "sha512-9dO6PcoJeI+1xJ4rG7eINMa6f9Ix/QdRs5UGZ0D7v/R3TzmsbVr5cqAgfYw4xgS9U54ouHYqEz0bIZBbM/UY1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.5.tgz", + "integrity": "sha512-OD+HLlcV7wCL64w0rheX/C0J31DbJce0nf8ulv3UNftQ617JEZE3sQ4cMN6PotXWlJ1M5GQx90G3bywtdKOstw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.5.tgz", + "integrity": "sha512-btcH7lbcD4410qnjHvv7F0kEuMNKbX8CFAZSs9CDPWHKzT1byLPHYeR40AnUw9z4CzsijMY0daBFYUvVx75sdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.5", + "@peculiar/asn1-pkcs8": "^2.9.5", + "@peculiar/asn1-rsa": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.5.tgz", + "integrity": "sha512-c7UghAGQdBfAHL9JdwcJkAc/lDJKNXfCdYXPXlf8BZdD/fVJjJnXKHxX4gyLhkFTvnGhME7caieSgc2ROtqYTQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.5.tgz", + "integrity": "sha512-4R/zMgLSHbQIZRBwP1KzX6iuNHmW3xFWwu9TS9A00N60fmQqp9DkkqnQFyd0mAYMQTrQefuzQ/yP4U8sOLGUgQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.5", + "@peculiar/asn1-pfx": "^2.9.5", + "@peculiar/asn1-pkcs8": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "@peculiar/asn1-x509-attr": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.5.tgz", + "integrity": "sha512-kHwBLVMkm9ljlM521ovS7y4z4mr8xuHTdNyyoPlDvckBwFKSgs9HqKBryLBeqHqL2YpIgl+Vspl7kzNGG2phzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.5.tgz", + "integrity": "sha512-Ez3wLKVjaxdsLcgeWN4OE31QkM7oBOgKuuBJxldRYAkfYw2C+8zJPcSd/SThnbhszsEOlAmoaS5kIIkN29fGKQ==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.5.tgz", + "integrity": "sha512-NP3ecvN6zTDlwCvogIWL7MJN9AZNuskkIbnlhrlkB97Mn2EqirBUQ8GKzsOmdAL3pRvjGu1Uk/a4DNfiZrWxhw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.5.tgz", + "integrity": "sha512-PlbL1a74RZiry7puPCZ5hdk/puvivxYJPbjadr2i7DBjHCJFEKCfTRPKL3DILeZFtzJjn95J5EBtSc4/TB9/6Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-post-quantum": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.5.tgz", + "integrity": "sha512-CTznDkboSgwRpXSFhta+Pe5cQ12JtojdMrmOZTWYkBSvbW/1R5pflPIHkpSlaILIfGcAaxIVpXq880xCfJYzow==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-asym-key": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz", + "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-csr": "^2.9.4", + "@peculiar/asn1-ecc": "^2.9.4", + "@peculiar/asn1-pkcs9": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@phosphor-icons/webcomponents": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@phosphor-icons/webcomponents/-/webcomponents-2.1.5.tgz", @@ -7723,6 +7992,33 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@simplewebauthn/browser": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz", + "integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.2.tgz", + "integrity": "sha512-g+m/xv9/8FRWsDY22CwsTYRzt3WwS9fJrR3P7eY3Ti01FXH5FQ36Xn55v6D+5tb6VajlqOj4hywP/Vxsb6a3Sg==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "@peculiar/x509": "^2.1.0", + "reflect-metadata": "^0.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@solana-program/system": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.10.0.tgz", @@ -10582,6 +10878,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -11878,15 +12188,6 @@ "node": ">=0.10" } }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -17001,6 +17302,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz", @@ -17247,6 +17566,12 @@ "node": ">=4" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -19065,6 +19390,24 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -20254,7 +20597,6 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", - "dev": true, "license": "MIT" }, "node_modules/workbox-expiration": { @@ -20307,7 +20649,6 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz", "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==", - "dev": true, "license": "MIT", "dependencies": { "workbox-core": "7.4.1" diff --git a/package.json b/package.json index 47cd5d9f..5b84faf6 100644 --- a/package.json +++ b/package.json @@ -6,16 +6,17 @@ "type": "module", "scripts": { "dev": "node scripts/dev.mjs", + "predev": "npm run zk:shard", "dev:vite": "vite", "server": "tsx server/index.js", "server:js": "node server/index.js", "start": "tsx server/index.js", "dev:all": "node scripts/dev.mjs", "build": "vite build", + "prebuild": "npm run zk:shard", "preview": "vite preview", "test": "vitest run", - "test:server-features": "node --test server/tests/telemetry-ws.test.js", - "test:ws-cluster": "node server/tests/ws-cluster.js", + "zk:shard": "python circuits/scripts/shard-aegis.py circuits/target/aegis.json public/zk-assets", "test:watch": "vitest", "test:e2e:throttling": "playwright test --project=throttling", "lint": "eslint .", @@ -69,16 +70,16 @@ "@opentelemetry/sdk-node": "^0.214.0", "@creit-tech/stellar-wallets-kit": "^2.4.0", "@noir-lang/noir_js": "^1.0.0-beta.9", + "@simplewebauthn/browser": "^14.0.0", + "@simplewebauthn/server": "^14.0.2", "@stellar/stellar-sdk": "^16.0.0", "@supabase/supabase-js": "^2.108.2", "buffer": "^6.0.3", "cors": "^2.8.5", "dataloader": "^2.2.3", "express": "^4.21.0", - "fuse.js": "^7.5.0", - "i18next": "^25.8.13", - "i18next-http-backend": "^3.0.2", "express-rate-limit": "^8.6.2", + "fuse.js": "^7.5.0", "ioredis": "^5.4.1", "graphql": "^16.14.2", "mapbox-gl": "^3.25.0", @@ -88,6 +89,6 @@ "react-i18next": "^16.5.4", "react-map-gl": "^8.1.1", "react-router-dom": "^7.18.0", - "ws": "^8.18.3" + "workbox-range-requests": "^7.4.1" } } diff --git a/server/index.js b/server/index.js index 46709f10..a8d1e37b 100644 --- a/server/index.js +++ b/server/index.js @@ -24,11 +24,7 @@ import { } from "./middleware/whitelist.js"; import { startMaintenanceScheduler } from "./db/maintenance.js"; import { getMaintenanceConfig } from "./env.js"; -import { telemetryErrorHandler, traceIdFromRequest, shutdownTelemetry } from "./telemetry.js"; -import { requestMetrics, renderMetrics, observeDuration } from "./middleware/metrics.js"; -import { connectEventBus, publishContractEvent, closeEventBus } from "./lib/redisPubSub.js"; -import { attachWebSocketRelay } from "./routes/wsCluster.js"; -import { createHealthRouter } from "./routes/health.js"; +import { createPasskeyAuthRouter } from "./routes/passkey-auth.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -177,6 +173,7 @@ if (process.env.NODE_ENV !== "test") { const rateLimiter = createRateLimiter(); app.use(rateLimiter); } +app.use("/api/auth/passkey", createPasskeyAuthRouter()); // ── Contract event bridge ──────────────────────────────────────── // Issue #177: the frontend used to poll the contract directly on a timer diff --git a/server/index.ts b/server/index.ts index d5e89911..4f618799 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,5 +1,135 @@ -import { startServer } from './index.js' +import express, { Request, Response } from 'express' +import cors from 'cors' +import { readFileSync } from 'fs' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' +import { + rpc, + TransactionBuilder, + Operation, + Account, + Keypair, + BASE_FEE, + Networks, + nativeToScVal, + SorobanDataBuilder, +} from '@stellar/stellar-sdk' +import { SorobanStateExporter, loadLatestSnapshot } from './indexer/exporter.js' +import { authMiddleware } from './middleware/auth.js' +import { createCspMiddleware, createHtmlHandler } from './middleware/csp.js' +import { createPasskeyAuthRouter } from './routes/passkey-auth.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const app = express() +const PORT = process.env.PORT || 3001 + +// Reuse TCP sockets across sequential requests (see middleware/keepAlive.ts) +app.use(keepAliveMiddleware()) + +app.use( + cors({ + origin: process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(',') + : ['https://helphone.com', 'https://staging.helphone.com', 'http://localhost:3000'], + methods: ['GET', 'POST', 'OPTIONS'], + preflightContinue: false, + optionsSuccessStatus: 204, + }) +) +app.use(createCspMiddleware()) +app.use(express.json({ limit: '1mb' })) + +// Behind a proxy (Render), req.ip is the proxy unless TRUST_PROXY is set to the +// number of hops (e.g. "1"); whitelist matching and rate limiting both use it. +if (process.env.TRUST_PROXY) { + const hops = Number(process.env.TRUST_PROXY) + app.set('trust proxy', Number.isNaN(hops) ? process.env.TRUST_PROXY : hops) +} + +// Verified emergency-service subnets / API keys bypass rate limiting. The +// whitelist must run before the limiter, which honours `req.bypassRateLimit`. +export const whitelistStore = createWhitelistStore(createDefaultRedisClient()) +const whitelist = createWhitelistMiddleware(whitelistStore) +app.use(whitelist) +app.use( + '/admin/whitelist', + createWhitelistAdminRouter({ + store: whitelistStore, + adminToken: process.env.WHITELIST_ADMIN_TOKEN, + onChange: () => whitelist.invalidate(), + }) +) +if (process.env.NODE_ENV !== 'test') app.use(generalLimiter) +app.use('/api/auth/passkey', createPasskeyAuthRouter()) + +const stateExporter = new SorobanStateExporter() + +// Health Check Endpoints +app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', server: 'helphone-indexer-server', timestamp: new Date().toISOString() }) +}) + +app.get('/zk/health', (_req: Request, res: Response) => { + res.json({ status: 'ready', ready: true }) +}) + +// Soroban State Export Endpoints +app.post('/api/state/export', async (_req: Request, res: Response) => { + try { + const snapshot = await stateExporter.exportState() + res.json({ success: true, snapshot }) + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }) + } +}) + +app.get('/api/state/snapshots/latest', (_req: Request, res: Response) => { + try { + const snapshot = loadLatestSnapshot() + if (!snapshot) { + return res.status(404).json({ success: false, error: 'No snapshots available' }) + } + res.json({ success: true, snapshot }) + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }) + } +}) + +// Secure Authenticated Endpoint Example using Cryptographic Auth Middleware +app.post('/api/protected/action', authMiddleware, (req: Request, res: Response) => { + res.json({ success: true, message: 'Authenticated payload verified successfully', user: (req as any).authenticatedUser }) +}) + +// Built frontend (nonce-injected HTML). Only active when `vite build` output +// exists, so an API-only deployment keeps behaving exactly as before. +const DIST_DIR = join(__dirname, '..', 'dist') +app.use(express.static(DIST_DIR, { index: false })) +app.get(/^\/(?!api\/|zk\/|health$|metrics).*/, createHtmlHandler({ htmlPath: join(DIST_DIR, 'index.html') })) + +// Automated Daily State Snapshot Cron (Interval fallback) +const CRON_INTERVAL_MS = 24 * 60 * 60 * 1000 +let exporterInterval: NodeJS.Timeout | null = null + +function scheduleStateBackup() { + console.log('[server] Initializing Soroban Contract State Daily Backup Cron...') + stateExporter.exportState().catch((err) => console.error('[server] Initial state backup error:', err)) + + exporterInterval = setInterval(() => { + console.log('[server] Executing scheduled daily state snapshot export...') + stateExporter.exportState().catch((err) => console.error('[server] Scheduled state backup error:', err)) + }, CRON_INTERVAL_MS) +} if (process.env.NODE_ENV !== 'test') { - startServer() + // Apply schema migrations automatically at boot so the database schema + // stays in sync on every deploy (non-fatal; server serves even on failure). + void runMigrationsAtStartup() + + const server = app.listen(PORT, () => { + console.log(`HelPhone Server running on http://localhost:${PORT}`) + // Off-peak VACUUM ANALYZE / REINDEX CONCURRENTLY (opt-in: DB_MAINTENANCE_ENABLED=true) + if (getMaintenanceConfig().enabled) startMaintenanceScheduler() + scheduleStateBackup() + }) + applyKeepAliveTuning(server) } diff --git a/server/lib/redis.ts b/server/lib/redis.ts index 111d36c3..7cc7adae 100644 --- a/server/lib/redis.ts +++ b/server/lib/redis.ts @@ -11,6 +11,8 @@ export interface RedisHashClient { hget(key: string, field: string): Promise hdel(key: string, ...fields: string[]): Promise hgetall(key: string): Promise> + set?(key: string, value: string, mode: 'EX', seconds: number): Promise + getdel?(key: string): Promise } let client: RedisHashClient | null | undefined diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index a1dc765f..45ee1470 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,11 +1,40 @@ import { Request, Response, NextFunction } from 'express' +import { createHmac, timingSafeEqual } from 'node:crypto' import { verifyEd25519Signature, verifyWebAuthnSignature } from '../../src/lib/crypto.js' -export interface AuthenticatedUser { - publicKey: string - algorithm: string - timestamp: number -} +export async function authMiddleware(req: Request, res: Response, next: NextFunction) { + try { + const bearer = req.header('Authorization')?.match(/^Bearer ([A-Za-z0-9_.-]+)$/)?.[1] + if (bearer) { + const parts = bearer.split('.') + if (parts.length !== 3 || !process.env.SESSION_SECRET || process.env.SESSION_SECRET.length < 32) { + return res.status(401).json({ success: false, error: 'Invalid passkey session' }) + } + const unsigned = `${parts[0]}.${parts[1]}` + const expected = createHmac('sha256', process.env.SESSION_SECRET).update(unsigned).digest() + const actual = Buffer.from(parts[2], 'base64url') + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { + return res.status(401).json({ success: false, error: 'Invalid passkey session' }) + } + let header: { alg?: string } + let claims: { sub?: string; username?: string; exp?: number } + try { + header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8')) + claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) + } catch { + return res.status(401).json({ success: false, error: 'Malformed passkey session' }) + } + if (header.alg !== 'HS256' || !claims.sub || !Number.isFinite(claims.exp) || claims.exp <= Date.now() / 1000) { + return res.status(401).json({ success: false, error: 'Expired or invalid passkey session' }) + } + ;(req as any).authenticatedUser = { id: claims.sub, username: claims.username, algorithm: 'webauthn' } + return next() + } + + const signature = req.header('X-Signature') + const publicKey = req.header('X-Public-Key') + const timestampStr = req.header('X-Timestamp') + const algorithm = req.header('X-Algorithm') || 'ed25519' export type AuthResult = | { ok: true; user: AuthenticatedUser } diff --git a/server/routes/passkey-auth.js b/server/routes/passkey-auth.js new file mode 100644 index 00000000..ff0f56da --- /dev/null +++ b/server/routes/passkey-auth.js @@ -0,0 +1,201 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { Router } from 'express'; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from '@simplewebauthn/server'; +import { getRedis } from '../lib/redis.js'; + +const CHALLENGE_TTL_MS = 5 * 60 * 1000; +const challenges = new Map(); +const credentials = new Map(); + +function config() { + if (process.env.NODE_ENV === 'production' && (!process.env.PASSKEY_RP_ID || !process.env.PASSKEY_ORIGIN)) { + throw new Error('PASSKEY_RP_ID and PASSKEY_ORIGIN must be configured in production'); + } + const rpID = process.env.PASSKEY_RP_ID || 'localhost'; + const expectedOrigin = process.env.PASSKEY_ORIGIN || `http://${rpID}:3000`; + return { rpID, expectedOrigin, rpName: process.env.PASSKEY_RP_NAME || 'HelPhone' }; +} + +async function store() { + const redis = await getRedis(); + if (!redis && process.env.NODE_ENV === 'production') throw new Error('REDIS_URL is required for passkey sessions in production'); + return redis; +} + +export async function saveChallenge(challenge, value, ttlMs = CHALLENGE_TTL_MS) { + const redis = await store(); + const entry = JSON.stringify({ ...value, expiresAt: Date.now() + ttlMs }); + if (redis) { + if (!redis.set) throw new Error('Redis client does not support expiring passkey challenges'); + await redis.set(`passkey:challenge:${challenge}`, entry, 'EX', Math.max(1, Math.ceil(ttlMs / 1000))); + } + else challenges.set(challenge, entry); +} + +export async function consumeChallenge(challenge) { + const redis = await store(); + let value; + if (redis) { + if (!redis.getdel) throw new Error('Redis client does not support atomic passkey challenge consumption'); + value = await redis.getdel(`passkey:challenge:${challenge}`); + } else { + value = challenges.get(challenge); + challenges.delete(challenge); + } + if (!value) return null; + const parsed = JSON.parse(value); + return parsed.expiresAt > Date.now() ? parsed : null; +} + +async function saveCredential(id, credential) { + const value = JSON.stringify({ + id, + publicKey: Buffer.from(credential.publicKey).toString('base64url'), + counter: credential.counter, + transports: credential.transports || [], + deviceType: credential.deviceType, + backedUp: credential.backedUp, + userId: credential.userId, + username: credential.username, + }); + const redis = await store(); + if (redis) await redis.hset('passkey:credentials', id, value); + else credentials.set(id, value); +} + +async function getCredential(id) { + const redis = await store(); + const value = redis ? await redis.hget('passkey:credentials', id) : credentials.get(id); + if (!value) return null; + const entry = JSON.parse(value); + return { ...entry, publicKey: new Uint8Array(Buffer.from(entry.publicKey, 'base64url')) }; +} + +async function listCredentials() { + const redis = await store(); + const values = redis ? Object.values(await redis.hgetall('passkey:credentials')) : [...credentials.values()]; + return values.map((value) => JSON.parse(value)); +} + +function issueSession(userId, username) { + const secret = process.env.SESSION_SECRET || ''; + if (secret.length < 32) throw new Error('SESSION_SECRET must be configured with at least 32 characters'); + const now = Math.floor(Date.now() / 1000); + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const unsigned = `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode({ sub: userId, username, iat: now, exp: now + 3600 })}`; + const signature = createHmac('sha256', secret).update(unsigned).digest('base64url'); + return `${unsigned}.${signature}`; +} + +function requireSessionSecret() { + if ((process.env.SESSION_SECRET || '').length < 32) throw new Error('SESSION_SECRET must be configured with at least 32 characters'); +} + +export function createPasskeyAuthRouter() { + const router = Router(); + router.post('/register/options', async (req, res) => { + try { + requireSessionSecret(); + const username = String(req.body?.username || '').trim(); + const displayName = String(req.body?.displayName || username).trim(); + if (!/^[a-zA-Z0-9_.@-]{1,64}$/.test(username) || !displayName || displayName.length > 80) { + return res.status(400).json({ error: 'Invalid account name' }); + } + const { rpID, rpName } = config(); + const userId = randomBytes(24).toString('base64url'); + const options = await generateRegistrationOptions({ + rpName, + rpID, + userName: username, + userDisplayName: displayName, + userID: new Uint8Array(Buffer.from(userId)), + attestationType: 'none', + authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, + excludeCredentials: (await listCredentials()).filter((item) => item.username === username).map((item) => ({ id: item.id, transports: item.transports })), + }); + await saveChallenge(options.challenge, { kind: 'register', userId, username }); + return res.json({ options }); + } catch (error) { + return res.status(503).json({ error: error instanceof Error ? error.message : 'Passkey setup unavailable' }); + } + }); + + router.post('/register/verify', async (req, res) => { + try { + const challenge = String(req.body?.challenge || ''); + const state = await consumeChallenge(challenge); + if (!state || state.kind !== 'register') return res.status(400).json({ error: 'Challenge expired or already used' }); + const verification = await verifyRegistrationResponse({ + response: req.body?.response, + expectedChallenge: challenge, + expectedOrigin: config().expectedOrigin, + expectedRPID: config().rpID, + requireUserVerification: true, + }); + if (!verification.verified || !verification.registrationInfo) return res.status(401).json({ error: 'Passkey registration was not verified' }); + const info = verification.registrationInfo; + await saveCredential(info.credential.id, { + ...info.credential, + deviceType: info.credentialDeviceType, + backedUp: info.credentialBackedUp, + userId: state.userId, + username: state.username, + transports: req.body?.response?.response?.transports || [], + }); + return res.json({ verified: true, sessionToken: issueSession(state.userId, state.username) }); + } catch (error) { + return res.status(401).json({ error: error instanceof Error ? error.message : 'Passkey verification failed' }); + } + }); + + router.post('/login/options', async (req, res) => { + try { + requireSessionSecret(); + const username = req.body?.username ? String(req.body.username) : null; + const known = await listCredentials(); + const allowed = username ? known.filter((credential) => credential.username === username) : known; + const { rpID } = config(); + const options = await generateAuthenticationOptions({ + rpID, + userVerification: 'required', + ...(username ? { allowCredentials: allowed.map((credential) => ({ id: credential.id, transports: credential.transports })) } : {}), + }); + if (username && !allowed.length) return res.status(404).json({ error: 'No passkey is registered for this account' }); + await saveChallenge(options.challenge, { kind: 'login', username }); + return res.json({ options }); + } catch (error) { + return res.status(503).json({ error: error instanceof Error ? error.message : 'Passkey login unavailable' }); + } + }); + + router.post('/login/verify', async (req, res) => { + try { + const challenge = String(req.body?.challenge || ''); + const state = await consumeChallenge(challenge); + if (!state || state.kind !== 'login') return res.status(400).json({ error: 'Challenge expired or already used' }); + const id = String(req.body?.response?.id || ''); + const stored = await getCredential(id); + if (!stored || (state.username && stored.username !== state.username)) return res.status(401).json({ error: 'Unknown passkey' }); + const verification = await verifyAuthenticationResponse({ + response: req.body.response, + expectedChallenge: challenge, + expectedOrigin: config().expectedOrigin, + expectedRPID: config().rpID, + requireUserVerification: true, + credential: { id: stored.id, publicKey: stored.publicKey, counter: stored.counter, transports: stored.transports }, + }); + if (!verification.verified) return res.status(401).json({ error: 'Passkey assertion was not verified' }); + stored.counter = verification.authenticationInfo.newCounter; + await saveCredential(id, stored); + return res.json({ verified: true, sessionToken: issueSession(stored.userId, stored.username) }); + } catch (error) { + return res.status(401).json({ error: error instanceof Error ? error.message : 'Passkey verification failed' }); + } + }); + return router; +} diff --git a/server/tests/passkey-challenge.node-test.mjs b/server/tests/passkey-challenge.node-test.mjs new file mode 100644 index 00000000..2497712e --- /dev/null +++ b/server/tests/passkey-challenge.node-test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { __setRedisForTests } from '../lib/redis.js'; +import { consumeChallenge, saveChallenge } from '../routes/passkey-auth.js'; + +afterEach(() => __setRedisForTests(null)); + +test('passkey challenge can be consumed once only', async () => { + __setRedisForTests(null); + await saveChallenge('one-time-test-challenge', { kind: 'login' }); + assert.equal((await consumeChallenge('one-time-test-challenge')).kind, 'login'); + assert.equal(await consumeChallenge('one-time-test-challenge'), null); +}); + +test('expired passkey challenges are rejected', async () => { + __setRedisForTests(null); + await saveChallenge('expired-test-challenge', { kind: 'login' }, -1); + assert.equal(await consumeChallenge('expired-test-challenge'), null); +}); diff --git a/server/tests/passkey-session.node-test.mjs b/server/tests/passkey-session.node-test.mjs new file mode 100644 index 00000000..8f6bc5f1 --- /dev/null +++ b/server/tests/passkey-session.node-test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import { afterEach, test } from 'node:test'; +import { authMiddleware } from '../middleware/auth.ts'; + +const secret = 'test-session-secret-that-is-at-least-32-characters'; +const previousSecret = process.env.SESSION_SECRET; + +function token(claims) { + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const unsigned = `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(claims)}`; + return `${unsigned}.${createHmac('sha256', secret).update(unsigned).digest('base64url')}`; +} + +function responseStub() { + return { + statusCode: 200, + body: null, + status(code) { this.statusCode = code; return this; }, + json(body) { this.body = body; return this; }, + }; +} + +afterEach(() => { + if (previousSecret === undefined) delete process.env.SESSION_SECRET; + else process.env.SESSION_SECRET = previousSecret; +}); + +test('accepts a valid passkey session bearer token', async () => { + process.env.SESSION_SECRET = secret; + const req = { header: (name) => name === 'Authorization' ? `Bearer ${token({ sub: 'account-1', username: 'alice', exp: Math.floor(Date.now() / 1000) + 60 })}` : null }; + const res = responseStub(); + let called = false; + await authMiddleware(req, res, () => { called = true; }); + assert.equal(called, true); + assert.equal(req.authenticatedUser.id, 'account-1'); +}); + +test('rejects a modified passkey session bearer token', async () => { + process.env.SESSION_SECRET = secret; + const signed = token({ sub: 'account-1', exp: Math.floor(Date.now() / 1000) + 60 }); + const tampered = `${signed.slice(0, -1)}${signed.endsWith('a') ? 'b' : 'a'}`; + const req = { header: (name) => name === 'Authorization' ? `Bearer ${tampered}` : null }; + const res = responseStub(); + let called = false; + await authMiddleware(req, res, () => { called = true; }); + assert.equal(called, false); + assert.equal(res.statusCode, 401); +}); diff --git a/src/hooks/useClusterer.js b/src/hooks/useClusterer.js index d870769c..e42e3f05 100644 --- a/src/hooks/useClusterer.js +++ b/src/hooks/useClusterer.js @@ -1,73 +1,48 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import Supercluster from 'supercluster'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { SpatialIndexClient } from '../lib/spatial.ts'; -/** - * Hook that clusters map points using Supercluster. - * - * @param {Array<{id: number|string, lat: number, lng: number, [key: string]: any}>} points - Points to cluster - * @param {number} zoom - Current map zoom level - * @param {Array} bounds - Map bounds as [west, south, east, north] - * @param {object} options - Supercluster options - * @returns {{ clusters: Array, supercluster: Supercluster|null }} - */ export function useClusterer(points, zoom, bounds, options = {}) { - const superclusterRef = useRef(null); const [clusters, setClusters] = useState([]); - - const defaultOptions = useMemo( - () => ({ - radius: 60, - maxZoom: 17, - ...options, - }), - [JSON.stringify(options)], - ); + const indexRef = useRef(null); + const revision = useRef(0); useEffect(() => { - if (!points || points.length === 0) { + if (typeof Worker === 'undefined') { setClusters([]); - return; + return undefined; } + const index = new SpatialIndexClient(); + indexRef.current = index; + return () => { + index.destroy(); + indexRef.current = null; + }; + }, []); - const features = points - .filter( - (p) => - p && - Number.isFinite(p.lat) && - Number.isFinite(p.lng), - ) - .map((p) => ({ - type: 'Feature', - properties: { ...p, pointId: p.id }, - geometry: { - type: 'Point', - coordinates: [p.lng, p.lat], - }, - })); - - if (features.length === 0) { + useEffect(() => { + const index = indexRef.current; + const requestRevision = ++revision.current; + if (!index || !Array.isArray(points) || points.length === 0) { setClusters([]); return; } - - const sc = new Supercluster(defaultOptions); - sc.load(features); - superclusterRef.current = sc; - - const bbox = bounds && bounds.length === 4 ? bounds : undefined; - const zoomInt = Math.floor(zoom); - const result = sc.getClusters(bbox || [-180, -85, 180, 85], zoomInt); - - setClusters(result); - }, [points, zoom, bounds, defaultOptions]); - - const getClusterExpansionZoom = useCallback( - (clusterId) => { - if (!superclusterRef.current) return 18; - return superclusterRef.current.getClusterExpansionZoom(clusterId); - }, - [], - ); - - return { clusters, supercluster: superclusterRef.current, getClusterExpansionZoom }; + const features = points.filter((point) => point && Number.isFinite(point.lat) && Number.isFinite(point.lng)).map((point) => ({ + type: 'Feature', + id: point.id, + properties: { ...point, pointId: point.id }, + geometry: { type: 'Point', coordinates: [point.lng, point.lat] }, + })); + const bbox = bounds?.length === 4 ? bounds : [-180, -85, 180, 85]; + void index.load(features) + .then(() => index.query(bbox, Math.floor(zoom || 0), options.radius || 60)) + .then((result) => { if (requestRevision === revision.current) setClusters(result); }) + .catch(() => { if (requestRevision === revision.current) setClusters([]); }); + }, [points, zoom, bounds, options.radius]); + + const getClusterExpansionZoom = useCallback((clusterId) => { + const cluster = clusters.find((item) => item.properties?.cluster_id === clusterId); + return cluster ? Math.min(22, Math.floor(zoom || 0) + 2) : 18; + }, [clusters, zoom]); + + return { clusters, supercluster: null, getClusterExpansionZoom }; } diff --git a/src/lib/passkey.ts b/src/lib/passkey.ts index a0f5c130..2f63d385 100644 --- a/src/lib/passkey.ts +++ b/src/lib/passkey.ts @@ -1,127 +1,45 @@ +import { startAuthentication, startRegistration } from '@simplewebauthn/browser' import type { PasskeyCredential, WebAuthnVerificationResult } from '../types/index.js' -import { verifyWebAuthnSignature } from './crypto.js' -export class PasskeyManager { - private rpName: string - private rpId: string - - constructor(rpName = 'HelPhone Emergency Network', rpId = 'helphone.com') { - this.rpName = rpName - this.rpId = rpId - } +type ServerResponse = { options: Record; error?: string } + +async function request(path: string, body: unknown) { + const response = await fetch(`/api/auth/passkey/${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(body), + }) + const result = await response.json() + if (!response.ok) throw new Error(result.error || `Passkey request failed (${response.status})`) + return result +} +export class PasskeyManager { public isWebAuthnSupported(): boolean { - return ( - typeof window !== 'undefined' && - typeof window.PublicKeyCredential !== 'undefined' && - typeof navigator.credentials !== 'undefined' - ) + return typeof window !== 'undefined' && typeof window.PublicKeyCredential !== 'undefined' && typeof navigator.credentials !== 'undefined' } - public generateChallenge(): string { - const randomBytes = new Uint8Array(32) - if (typeof window !== 'undefined' && window.crypto) { - window.crypto.getRandomValues(randomBytes) - } else { - for (let i = 0; i < 32; i++) randomBytes[i] = Math.floor(Math.random() * 256) - } - return Buffer.from(randomBytes).toString('base64url') + public async registerPasskey(username: string, displayName: string): Promise { + if (!this.isWebAuthnSupported()) throw new Error('WebAuthn Passkeys are not supported in this browser') + const { options } = await request('register/options', { username, displayName }) as ServerResponse + const response = await startRegistration({ optionsJSON: options as never }) + const result = await request('register/verify', { challenge: options.challenge, response }) + if (result.sessionToken) sessionStorage.setItem('helphone-passkey-session', result.sessionToken) + return { id: response.id, rawId: response.rawId, type: 'public-key', response: response.response, challenge: String(options.challenge) } as unknown as PasskeyCredential } - public async registerPasskey(userName: string, userDisplayName: string): Promise { - if (!this.isWebAuthnSupported()) { - throw new Error('WebAuthn Passkeys are not supported in this browser environment') - } - - const challenge = this.generateChallenge() - const userIdBytes = new TextEncoder().encode(userName) - - const publicKeyOptions: PublicKeyCredentialCreationOptions = { - challenge: Buffer.from(challenge, 'base64url'), - rp: { name: this.rpName, id: window.location.hostname || this.rpId }, - user: { - id: userIdBytes, - name: userName, - displayName: userDisplayName, - }, - pubKeyCredParams: [ - { alg: -7, type: 'public-key' }, // ES256 (P-256) - { alg: -257, type: 'public-key' }, // RS256 - ], - authenticatorSelection: { - authenticatorAttachment: 'platform', - userVerification: 'preferred', - }, - timeout: 60000, - } - - const credential = (await navigator.credentials.create({ - publicKey: publicKeyOptions, - })) as PublicKeyCredential - - if (!credential) return null - - const response = credential.response as AuthenticatorAttestationResponse - - const authData = (response as any).getAuthenticatorData - ? (response as any).getAuthenticatorData() - : new Uint8Array() - - return { - id: credential.id, - rawId: Buffer.from(credential.rawId).toString('hex'), - type: 'public-key', - response: { - clientDataJSON: Buffer.from(response.clientDataJSON).toString('utf-8'), - authenticatorData: Buffer.from(authData as any).toString('hex'), - signature: 'ATTESTATION_OK', - }, - } - } - - public async authenticatePasskey(challenge: string): Promise { - if (!this.isWebAuthnSupported()) { - throw new Error('WebAuthn Passkeys are not supported in this environment') - } - - const publicKeyOptions: PublicKeyCredentialRequestOptions = { - challenge: Buffer.from(challenge, 'base64url'), - rpId: window.location.hostname || this.rpId, - userVerification: 'preferred', - timeout: 60000, - } - - const assertion = (await navigator.credentials.get({ - publicKey: publicKeyOptions, - })) as PublicKeyCredential - - if (!assertion) return null - - const response = assertion.response as AuthenticatorAssertionResponse - - return { - id: assertion.id, - rawId: Buffer.from(assertion.rawId).toString('hex'), - type: 'public-key', - response: { - clientDataJSON: Buffer.from(response.clientDataJSON).toString('utf-8'), - authenticatorData: Buffer.from(response.authenticatorData).toString('hex'), - signature: Buffer.from(response.signature).toString('hex'), - userHandle: response.userHandle ? Buffer.from(response.userHandle).toString('hex') : undefined, - }, - } + public async authenticatePasskey(_clientChallenge?: string, username?: string): Promise { + if (!this.isWebAuthnSupported()) throw new Error('WebAuthn Passkeys are not supported in this browser') + const { options } = await request('login/options', { username }) as ServerResponse + const response = await startAuthentication({ optionsJSON: options as never }) + return { id: response.id, rawId: response.rawId, type: 'public-key', response: response.response, challenge: String(options.challenge) } as unknown as PasskeyCredential } - public async verifyAssertion( - credential: PasskeyCredential, - expectedChallenge: string - ): Promise { - return verifyWebAuthnSignature( - credential.response.clientDataJSON, - credential.response.authenticatorData, - credential.response.signature, - expectedChallenge - ) + public async verifyAssertion(credential: PasskeyCredential): Promise { + const result = await request('login/verify', { challenge: credential.challenge, response: credential }) + if (result.sessionToken) sessionStorage.setItem('helphone-passkey-session', result.sessionToken) + return { verified: result.verified === true, userHandle: result.userHandle } } } diff --git a/src/lib/spatial.ts b/src/lib/spatial.ts new file mode 100644 index 00000000..d011884d --- /dev/null +++ b/src/lib/spatial.ts @@ -0,0 +1,61 @@ +export type SpatialFeature = { + type: 'Feature' + id?: string | number + properties: Record + geometry: { type: 'Point'; coordinates: [number, number] } +} + +export type SpatialBounds = [west: number, south: number, east: number, north: number] + +type WorkerResponse = { id: number; type: 'loaded' | 'result' | 'error'; count?: number; features?: SpatialFeature[]; error?: string } + +export class SpatialIndexClient { + private worker: Worker + private sequence = 0 + private pending = new Map void; reject: (error: Error) => void }>() + + constructor(worker = new Worker(new URL('../workers/cluster-worker.js', import.meta.url), { type: 'module' })) { + this.worker = worker + this.worker.onmessage = ({ data }: MessageEvent) => { + const pending = this.pending.get(data.id) + if (!pending) return + this.pending.delete(data.id) + if (data.type === 'error') pending.reject(new Error(data.error || 'Spatial index worker failed')) + else pending.resolve(data) + } + this.worker.onerror = (event) => { + for (const pending of this.pending.values()) pending.reject(new Error(event.message || 'Spatial index worker failed')) + this.pending.clear() + } + } + + private request(message: Record, transfer: Transferable[] = []): Promise { + const id = ++this.sequence + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve: resolve as (value: WorkerResponse) => void, reject }) + this.worker.postMessage({ ...message, id }, transfer) + }) + } + + async load(features: SpatialFeature[]): Promise { + const valid = features.filter((feature) => { + const [lng, lat] = feature.geometry.coordinates + return Number.isFinite(lng) && Number.isFinite(lat) && lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90 + }) + const coordinates = new Float64Array(valid.length * 2) + valid.forEach((feature, index) => coordinates.set(feature.geometry.coordinates, index * 2)) + const metadata = valid.map(({ type, id, properties }) => ({ type, id, properties })) + await this.request({ type: 'load', coordinates: coordinates.buffer, features: metadata }, [coordinates.buffer]) + } + + async query(bounds: SpatialBounds, zoom: number, radius = 60): Promise { + const result = await this.request<{ id: number; type: 'result'; features: SpatialFeature[] }>({ type: 'query', bounds, zoom, radius }) + return result.features || [] + } + + destroy(): void { + this.worker.terminate() + for (const pending of this.pending.values()) pending.reject(new Error('Spatial index destroyed')) + this.pending.clear() + } +} diff --git a/src/lib/zk.ts b/src/lib/zk.ts index 9a28e887..7ac5be32 100644 --- a/src/lib/zk.ts +++ b/src/lib/zk.ts @@ -96,8 +96,38 @@ export function decodeBase64Utf8(input: string, label?: string): string { async function getCircuitArtifact() { if (_circuitArtifact) return _circuitArtifact; - const circuitModule = await import("../../circuits/target/aegis.json"); - const circuit = circuitModule.default || circuitModule; + const manifestResponse = await fetch("/zk-assets/aegis.manifest.json", { cache: "force-cache" }); + if (!manifestResponse.ok) throw new Error("Sharded ZK assets are missing; run npm run zk:shard"); + const manifest = await manifestResponse.json(); + if (manifest.version !== 1 || !Array.isArray(manifest.chunks)) throw new Error("Invalid aegis shard manifest"); + const decoder = new TextDecoder(); + let jsonText = ""; + let totalBytes = 0; + for (const chunk of manifest.chunks) { + if (!/^aegis\.chunk\d{4}$/.test(chunk.file)) throw new Error("Invalid aegis shard name"); + const response = await fetch(`/zk-assets/${chunk.file}`, { cache: "force-cache" }); + if (!response.ok || !response.body) throw new Error(`Unable to load circuit shard ${chunk.file}`); + const reader = response.body.getReader(); + const parts: Uint8Array[] = []; + let size = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + parts.push(value); + size += value.byteLength; + } + if (size !== chunk.bytes) throw new Error(`Incorrect size for circuit shard ${chunk.file}`); + totalBytes += size; + const bytes = new Uint8Array(size); + let offset = 0; + for (const part of parts) { bytes.set(part, offset); offset += part.byteLength; } + const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), (byte) => byte.toString(16).padStart(2, '0')).join(''); + if (hash !== chunk.sha256) throw new Error(`Integrity check failed for circuit shard ${chunk.file}`); + jsonText += decoder.decode(bytes, { stream: true }); + } + if (totalBytes !== manifest.bytes) throw new Error("Incorrect total size for circuit artifact"); + jsonText += decoder.decode(); + const circuit = JSON.parse(jsonText); _circuitArtifact = { ...circuit, bytecode: normalizeBase64(circuit.bytecode, "ZK circuit bytecode"), diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx index 0e6376a8..c1599dd2 100644 --- a/src/pages/Help.tsx +++ b/src/pages/Help.tsx @@ -31,11 +31,10 @@ export default function Help() { const handlePasskeyAuth = async () => { try { setStatusMessage('Authenticating with WebAuthn Passkey...') - const challenge = passkeyManager.generateChallenge() - const credential = await passkeyManager.authenticatePasskey(challenge) + const credential = await passkeyManager.authenticatePasskey() if (credential) { - const verification = await passkeyManager.verifyAssertion(credential, challenge) + const verification = await passkeyManager.verifyAssertion(credential) if (verification.verified) { setPasskeyVerified(true) setStatusMessage('✅ Passkey authenticated successfully! Emergency broadcast authorized.') diff --git a/src/service-worker.js b/src/service-worker.js index bc56ac96..8bf6b177 100644 --- a/src/service-worker.js +++ b/src/service-worker.js @@ -1,8 +1,5 @@ import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'; -import { registerRoute } from 'workbox-routing'; -import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies'; -import { ExpirationPlugin } from 'workbox-expiration'; -import { CacheableResponsePlugin } from 'workbox-cacheable-response'; +import { createPartialResponse } from 'workbox-range-requests'; cleanupOutdatedCaches(); @@ -24,7 +21,7 @@ self.addEventListener('activate', (event) => { caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { - if (cacheName !== CACHE_NAME) { + if (cacheName !== CACHE_NAME && cacheName !== 'helphone-zk-assets-v1') { return caches.delete(cacheName); } }) @@ -39,6 +36,26 @@ self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); + if (request.method === 'GET' && url.origin === self.location.origin && /\/zk-assets\/aegis\.chunk\d{4}$/.test(url.pathname)) { + event.respondWith((async () => { + const cache = await caches.open('helphone-zk-assets-v1'); + const key = new Request(url.href, { method: 'GET' }); + let full = await cache.match(key); + if (!full) { + full = await fetch(key); + if (full.ok) await cache.put(key, full.clone()); + } + if (!request.headers.has('range') || !full.ok) return full; + try { + return await createPartialResponse(request, full); + } catch { + return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${full.headers.get('content-length') || 0}` } }); + } + })()); + return; + } + + if (url.pathname === '/') { event.respondWith( fetch(request) diff --git a/src/types/index.ts b/src/types/index.ts index 634a8047..865a4223 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -74,10 +74,13 @@ export interface PasskeyCredential { id: string rawId: string type: 'public-key' + challenge?: string response: { - clientDataJSON: string - authenticatorData: string - signature: string + clientDataJSON?: string + authenticatorData?: string + signature?: string + attestationObject?: string + transports?: string[] userHandle?: string } } @@ -193,29 +196,6 @@ export interface OverlayRenderStats { windowMs: number } -// --- Client routing / compact spatial graph (#582) --- -export interface SpatialGraph { - nodeCount: number - offsets: Uint32Array - targets: Uint32Array - weights: Float32Array -} - -export interface RoadNetworkDocument { - format: 'helphone-csr-v1' - nodeCount: number - offsets: number[] - targets: number[] - weights: number[] - metadata?: Record -} - -export interface RouteResult { - distanceKm: number - path: number[] - visitedNodes: number -} - // ── RPC health (network estimator, #539) ───────────────────────────────────── /** 'unknown' = no estimator registered yet. */ export type NetworkQuality = 'good' | 'degraded' | 'offline' | 'unknown'; diff --git a/src/workers/cluster-worker.js b/src/workers/cluster-worker.js new file mode 100644 index 00000000..dc73148a --- /dev/null +++ b/src/workers/cluster-worker.js @@ -0,0 +1,100 @@ +const FANOUT = 16; +let tree = []; +let pointCount = 0; +let featuresByIndex = []; +let coordinateData = null; + +function boundsOf(children) { + return children.reduce((box, child) => ({ + minX: Math.min(box.minX, child.minX), minY: Math.min(box.minY, child.minY), + maxX: Math.max(box.maxX, child.maxX), maxY: Math.max(box.maxY, child.maxY), + }), { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); +} + +function bulkLoad(points) { + if (!points.length) return []; + let level = points.map((point, index) => ({ ...point, index, leaf: true })); + while (level.length > FANOUT) { + level.sort((a, b) => (a.minX + a.maxX) - (b.minX + b.maxX)); + const groups = []; + const sliceSize = Math.ceil(level.length / FANOUT); + for (let i = 0; i < level.length; i += sliceSize) { + const slice = level.slice(i, i + sliceSize).sort((a, b) => (a.minY + a.maxY) - (b.minY + b.maxY)); + for (let j = 0; j < slice.length; j += FANOUT) { + const children = slice.slice(j, j + FANOUT); + groups.push({ ...boundsOf(children), children, leaf: false }); + } + } + level = groups; + } + return [{ ...boundsOf(level), children: level, leaf: false }]; +} + +function search(bounds) { + const found = []; + const stack = [...tree]; + while (stack.length) { + const node = stack.pop(); + if (node.maxX < bounds[0] || node.minX > bounds[2] || node.maxY < bounds[1] || node.minY > bounds[3]) continue; + if (node.leaf) found.push(node.index); + else stack.push(...node.children); + } + return found; +} + +self.onmessage = ({ data }) => { + try { + if (data.type === 'load') { + const coords = new Float64Array(data.coordinates); + coordinateData = coords; + pointCount = data.features.length; + const points = []; + featuresByIndex = data.features; + for (let i = 0; i < pointCount; i++) { + const lng = coords[i * 2]; + const lat = coords[i * 2 + 1]; + if (!Number.isFinite(lat) || !Number.isFinite(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) continue; + points.push({ minX: lng, maxX: lng, minY: lat, maxY: lat, index: i, leaf: true }); + } + tree = bulkLoad(points); + self.postMessage({ id: data.id, type: 'loaded', count: points.length }); + return; + } + if (data.type === 'query') { + const indices = search(data.bounds); + const cellSize = 360 / (512 * 2 ** Math.max(0, Math.min(data.zoom || 0, 22))) * (data.radius || 60); + const cells = new Map(); + const results = []; + for (const index of indices) { + const metadata = featuresByIndex[index]; + const feature = metadata && { + ...metadata, + geometry: { type: 'Point', coordinates: [coordinateData[index * 2], coordinateData[index * 2 + 1]] }, + }; + if (!feature) continue; + const [lng, lat] = feature.geometry.coordinates; + const key = `${Math.floor(lng / cellSize)}:${Math.floor(lat / cellSize)}`; + const cell = cells.get(key) || []; + cell.push(feature); + cells.set(key, cell); + } + let clusterId = 0; + for (const cell of cells.values()) { + if (cell.length === 1 || (data.zoom || 0) >= 17) { + results.push(...cell); + continue; + } + const coordinates = cell.reduce((sum, feature) => [sum[0] + feature.geometry.coordinates[0], sum[1] + feature.geometry.coordinates[1]], [0, 0]); + results.push({ + type: 'Feature', + id: `cluster-${++clusterId}`, + properties: { cluster: true, cluster_id: clusterId, point_count: cell.length, point_count_abbreviated: String(cell.length) }, + geometry: { type: 'Point', coordinates: [coordinates[0] / cell.length, coordinates[1] / cell.length] }, + }); + } + self.postMessage({ id: data.id, type: 'result', features: results }); + } + } catch (error) { + self.postMessage({ id: data.id, type: 'error', error: error instanceof Error ? error.message : String(error) }); + } +}; diff --git a/test/cluster-worker.node-test.mjs b/test/cluster-worker.node-test.mjs new file mode 100644 index 00000000..d880dca1 --- /dev/null +++ b/test/cluster-worker.node-test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { runInNewContext } from 'node:vm'; +import { test } from 'node:test'; + +test('cluster worker indexes points and returns viewport clusters', async () => { + const source = await readFile(new URL('../src/workers/cluster-worker.js', import.meta.url), 'utf8'); + const messages = []; + const self = { onmessage: null, postMessage: (message) => messages.push(message) }; + runInNewContext(source, { self, performance: { now: () => 1 } }); + const features = [ + { type: 'Feature', id: 'a', properties: { pointId: 'a' } }, + { type: 'Feature', id: 'b', properties: { pointId: 'b' } }, + { type: 'Feature', id: 'c', properties: { pointId: 'c' } }, + ]; + self.onmessage({ data: { id: 1, type: 'load', coordinates: new Float64Array([1, 1, 1.0001, 1.0001, 9, 9]).buffer, features } }); + assert.equal(messages.pop().type, 'loaded'); + self.onmessage({ data: { id: 2, type: 'query', bounds: [-5, -5, 10, 10], zoom: 5, radius: 60 } }); + const result = messages.pop(); + assert.equal(result.type, 'result'); + assert.equal(result.features.find((feature) => feature.properties.cluster)?.properties.point_count, 2); + assert.ok(result.features.some((feature) => feature.properties.pointId === 'c')); +}); diff --git a/test/zk-range.node-test.mjs b/test/zk-range.node-test.mjs new file mode 100644 index 00000000..7fa6476d --- /dev/null +++ b/test/zk-range.node-test.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +globalThis.self = globalThis; +const { createPartialResponse } = await import('workbox-range-requests'); + +test('cached ZK assets support byte-range responses', async () => { + const request = new Request('https://example.test/zk-assets/aegis.chunk0000', { headers: { Range: 'bytes=2-4' } }); + const response = await createPartialResponse(request, new Response('abcdefgh')); + assert.equal(response.status, 206); + assert.equal(response.headers.get('content-range'), 'bytes 2-4/8'); + assert.equal(await response.text(), 'cde'); +});