From 2e04db4b8c0a174e9500aa93e91d5926523abd5b Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Sat, 21 Feb 2026 23:37:12 +0530 Subject: [PATCH 1/8] feat: push notifs --- .../eid-wallet/PUSH_NOTIFICATIONS_SETUP.md | 38 +++ infrastructure/eid-wallet/package.json | 2 +- .../eid-wallet/src-tauri/Cargo.lock | 299 ++++++++++++++++-- .../eid-wallet/src-tauri/Cargo.toml | 2 +- .../src-tauri/capabilities/mobile.json | 2 +- .../gen/android/app/build.gradle.kts | 7 +- .../gen/android/app/google-services.json | 29 ++ .../src-tauri/gen/android/build.gradle.kts | 4 +- .../gen/android/buildSrc/build.gradle.kts | 2 +- .../eid-wallet.xcodeproj/project.pbxproj | 8 +- .../eid-wallet_iOS.entitlements | 5 +- .../eid-wallet/src-tauri/src/lib.rs | 2 +- .../src/lib/services/NotificationService.ts | 23 +- .../src/routes/(auth)/onboarding/+page.svelte | 24 ++ 14 files changed, 399 insertions(+), 48 deletions(-) create mode 100644 infrastructure/eid-wallet/PUSH_NOTIFICATIONS_SETUP.md create mode 100644 infrastructure/eid-wallet/src-tauri/gen/android/app/google-services.json diff --git a/infrastructure/eid-wallet/PUSH_NOTIFICATIONS_SETUP.md b/infrastructure/eid-wallet/PUSH_NOTIFICATIONS_SETUP.md new file mode 100644 index 000000000..a41638f82 --- /dev/null +++ b/infrastructure/eid-wallet/PUSH_NOTIFICATIONS_SETUP.md @@ -0,0 +1,38 @@ +# Push Notifications Setup (Android & iOS) + +The eid-wallet uses [tauri-plugin-notifications](https://github.com/Choochmeque/tauri-plugin-notifications) for push notifications on Android (FCM) and iOS (APNs). + +## Android (Firebase Cloud Messaging) + +1. **Create a Firebase project** at [Firebase Console](https://console.firebase.google.com/). + +2. **Add an Android app** to your Firebase project with package name `foundation.metastate.eid_wallet`. + +3. **Download `google-services.json`** from Firebase Console and place it in: + ``` + src-tauri/gen/android/app/google-services.json + ``` + +4. **Note:** The Google Services classpath and plugin have been added to the Android build. If you regenerate the `gen/` folder (e.g., after `tauri android init`), re-apply these changes to: + - `gen/android/build.gradle.kts`: Add `classpath("com.google.gms:google-services:4.4.2")` to buildscript dependencies + - `gen/android/app/build.gradle.kts`: Add `apply(plugin = "com.google.gms.google-services")` at the bottom + +## iOS (Apple Push Notification service) + +1. **Add Push Notifications capability** in Xcode: + - Open `src-tauri/gen/apple/eid-wallet.xcodeproj` in Xcode + - Select the iOS target → Signing & Capabilities + - Click "+ Capability" and add "Push Notifications" + +2. The `aps-environment` entitlement has been added to `eid-wallet_iOS.entitlements` for development. For production builds, update to `production`. + +3. **Test on a physical device** — the iOS simulator has limited push notification support. + +## Usage + +The `NotificationService` automatically: +- Requests permissions via `requestPermissions()` +- Registers for push via `registerForPushNotifications()` on Android/iOS when `registerDevice()` is called +- Sends the FCM/APNs token to your provisioner as `fcmToken` in the device registration payload + +Your backend can use this token to send push notifications through Firebase Admin SDK (Android) or your APNs provider (iOS). diff --git a/infrastructure/eid-wallet/package.json b/infrastructure/eid-wallet/package.json index 6f0e37c36..67162d77b 100644 --- a/infrastructure/eid-wallet/package.json +++ b/infrastructure/eid-wallet/package.json @@ -29,7 +29,7 @@ "@tauri-apps/plugin-barcode-scanner": "^2.4.2", "@tauri-apps/plugin-biometric": "^2.3.2", "@tauri-apps/plugin-deep-link": "^2.4.5", - "@tauri-apps/plugin-notification": "^2.3.3", + "@choochmeque/tauri-plugin-notifications-api": "^0.4.3", "@tauri-apps/plugin-opener": "^2.5.2", "@tauri-apps/plugin-store": "^2.4.1", "@veriff/incontext-sdk": "^2.4.0", diff --git a/infrastructure/eid-wallet/src-tauri/Cargo.lock b/infrastructure/eid-wallet/src-tauri/Cargo.lock index ff29bab0c..d31be4869 100644 --- a/infrastructure/eid-wallet/src-tauri/Cargo.lock +++ b/infrastructure/eid-wallet/src-tauri/Cargo.lock @@ -55,7 +55,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -471,6 +471,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.42" @@ -587,6 +598,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -863,7 +883,7 @@ dependencies = [ "tauri-plugin-biometric", "tauri-plugin-crypto-hw", "tauri-plugin-deep-link", - "tauri-plugin-notification", + "tauri-plugin-notifications", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-store", @@ -1013,6 +1033,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1296,6 +1322,20 @@ dependencies = [ "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + [[package]] name = "gio" version = "0.18.4" @@ -1456,6 +1496,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.0" @@ -1698,6 +1747,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1904,6 +1959,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2817,6 +2878,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.110", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2945,12 +3016,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.0", ] [[package]] @@ -2973,16 +3045,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.3", -] - [[package]] name = "rand_core" version = "0.5.1" @@ -3003,12 +3065,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "rand_hc" @@ -3453,7 +3512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3609,6 +3668,53 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swift-bridge" +version = "0.1.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384ed39ea10f1cefabb197b7d8e67f0034b15a94ccbb1038b8e020da59bfb0be" +dependencies = [ + "once_cell", + "swift-bridge-build", + "swift-bridge-macro", + "tokio", +] + +[[package]] +name = "swift-bridge-build" +version = "0.1.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71b36df21e7f8a8b5eeb718d2e71f9cfc308477bfb705981cca705de9767dcb7" +dependencies = [ + "proc-macro2", + "swift-bridge-ir", + "syn 1.0.109", + "tempfile", +] + +[[package]] +name = "swift-bridge-ir" +version = "0.1.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c73bd16155df50708b92306945656e57d62d321290a7db490f299f709fb31c83" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "swift-bridge-macro" +version = "0.1.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a13dc0dc875d85341dec5b5344a7d713f20eb5650b71086b27d09a6ece272f" +dependencies = [ + "proc-macro2", + "quote", + "swift-bridge-ir", + "syn 1.0.109", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -3926,17 +4032,19 @@ dependencies = [ ] [[package]] -name = "tauri-plugin-notification" -version = "2.3.3" +name = "tauri-plugin-notifications" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +checksum = "ec9491977d0a3a5903bec9ff1fa989233014f37b923008f4994062e1275586b6" dependencies = [ "log", "notify-rust", - "rand 0.9.2", + "rand 0.10.0", "serde", "serde_json", "serde_repr", + "swift-bridge", + "swift-bridge-build", "tauri", "tauri-plugin", "thiserror 2.0.17", @@ -4534,6 +4642,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.7" @@ -4651,7 +4765,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -4712,6 +4835,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.12.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -4725,6 +4870,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.12.0", + "semver", +] + [[package]] name = "web-sys" version = "0.3.82" @@ -5285,6 +5442,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.12.0", + "prettyplease", + "syn 2.0.110", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.110", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.12.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.12.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/infrastructure/eid-wallet/src-tauri/Cargo.toml b/infrastructure/eid-wallet/src-tauri/Cargo.toml index 8cf2140d1..790282b15 100644 --- a/infrastructure/eid-wallet/src-tauri/Cargo.toml +++ b/infrastructure/eid-wallet/src-tauri/Cargo.toml @@ -21,7 +21,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-deep-link = "2" -tauri-plugin-notification = "2" +tauri-plugin-notifications = { version = "0.4", default-features = false, features = ["push-notifications", "notify-rust"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-store = "2.4.1" diff --git a/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json b/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json index 6af140f45..871c2cede 100644 --- a/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json +++ b/infrastructure/eid-wallet/src-tauri/capabilities/mobile.json @@ -14,7 +14,7 @@ "barcode-scanner:allow-open-app-settings", "deep-link:default", "crypto-hw:default", - "notification:default", + "notifications:default", "process:default", "opener:allow-default-urls" ], diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts b/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts index 4acbd2391..bd53b6a3b 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts @@ -14,13 +14,13 @@ val tauriProperties = Properties().apply { } android { - compileSdk = 35 + compileSdk = 36 namespace = "foundation.metastate.eid_wallet" defaultConfig { manifestPlaceholders["usesCleartextTraffic"] = "false" applicationId = "foundation.metastate.eid_wallet" minSdk = 24 - targetSdk = 35 + targetSdk = 36 versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") } @@ -66,4 +66,5 @@ dependencies { androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") } -apply(from = "tauri.build.gradle.kts") \ No newline at end of file +apply(from = "tauri.build.gradle.kts") +apply(plugin = "com.google.gms.google-services") \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/google-services.json b/infrastructure/eid-wallet/src-tauri/gen/android/app/google-services.json new file mode 100644 index 000000000..f3f72e76f --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "346438559528", + "project_id": "eid-wallet-1b286", + "storage_bucket": "eid-wallet-1b286.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:346438559528:android:133bf9df990583462ed21d", + "android_client_info": { + "package_name": "foundation.metastate.eid_wallet" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyAXofG56YlllvLV-yF4zroGjvmosoVXPIU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/build.gradle.kts b/infrastructure/eid-wallet/src-tauri/gen/android/build.gradle.kts index bf4354869..6303839f5 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/build.gradle.kts +++ b/infrastructure/eid-wallet/src-tauri/gen/android/build.gradle.kts @@ -4,8 +4,9 @@ buildscript { mavenCentral() } dependencies { - classpath("com.android.tools.build:gradle:8.6.0") + classpath("com.android.tools.build:gradle:8.9.1") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + classpath("com.google.gms:google-services:4.4.2") } } @@ -19,4 +20,3 @@ allprojects { tasks.register("clean").configure { delete("build") } - diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/build.gradle.kts b/infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/build.gradle.kts index 6ee846947..5b519d9f9 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/build.gradle.kts +++ b/infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -18,6 +18,6 @@ repositories { dependencies { compileOnly(gradleApi()) - implementation("com.android.tools.build:gradle:8.6.0") + implementation("com.android.tools.build:gradle:8.9.1") } diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj index 55e11ecde..3a4c3da07 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj @@ -389,7 +389,7 @@ CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 0.3.0.0; - DEVELOPMENT_TEAM = 7F2T2WK6DR; + DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; FRAMEWORK_SEARCH_PATHS = ( @@ -416,7 +416,7 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); MARKETING_VERSION = 0.3.0; - PRODUCT_BUNDLE_IDENTIFIER = com.kodski.eid-wallet; + PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -437,7 +437,7 @@ CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 0.3.0.0; - DEVELOPMENT_TEAM = 7F2T2WK6DR; + DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; FRAMEWORK_SEARCH_PATHS = ( @@ -464,7 +464,7 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); MARKETING_VERSION = 0.3.0; - PRODUCT_BUNDLE_IDENTIFIER = com.kodski.eid-wallet; + PRODUCT_BUNDLE_IDENTIFIER = foundation.metastate.eid-wallet; PRODUCT_NAME = "eID for W3DS"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/eid-wallet_iOS.entitlements b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/eid-wallet_iOS.entitlements index 0c67376eb..903def2af 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/eid-wallet_iOS.entitlements +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/eid-wallet_iOS.entitlements @@ -1,5 +1,8 @@ - + + aps-environment + development + diff --git a/infrastructure/eid-wallet/src-tauri/src/lib.rs b/infrastructure/eid-wallet/src-tauri/src/lib.rs index dbeba59a9..51e3a07ba 100644 --- a/infrastructure/eid-wallet/src-tauri/src/lib.rs +++ b/infrastructure/eid-wallet/src-tauri/src/lib.rs @@ -82,7 +82,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_notifications::init()) .setup(move |_app| { #[cfg(mobile)] { diff --git a/infrastructure/eid-wallet/src/lib/services/NotificationService.ts b/infrastructure/eid-wallet/src/lib/services/NotificationService.ts index 24fb22c25..680693b66 100644 --- a/infrastructure/eid-wallet/src/lib/services/NotificationService.ts +++ b/infrastructure/eid-wallet/src/lib/services/NotificationService.ts @@ -4,7 +4,8 @@ import { isPermissionGranted, requestPermission, sendNotification, -} from "@tauri-apps/plugin-notification"; + registerForPushNotifications, +} from "@choochmeque/tauri-plugin-notifications-api"; export interface DeviceRegistration { eName: string; @@ -341,18 +342,28 @@ class NotificationService { } /** - * Get FCM token for push notifications (mobile only) + * Get push notification token (FCM on Android, APNs on iOS) */ private async getFCMToken(): Promise { try { - // This would need to be implemented with Firebase SDK - // For now, return undefined as we're focusing on local notifications - return undefined; + return await registerForPushNotifications(); } catch (error) { - console.error("Failed to get FCM token:", error); + console.error("Failed to get push notification token:", error); return undefined; } } + + /** + * Request permissions and get push notification token (FCM on Android, APNs on iOS). + * Returns undefined on desktop or if permission is denied. + */ + async getPushToken(): Promise { + const hasPermission = await this.requestPermissions(); + if (!hasPermission) return undefined; + const platform = await this.getPlatform(); + if (platform !== "android" && platform !== "ios") return undefined; + return this.getFCMToken(); + } /** * Get eName from vault (helper method) */ diff --git a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte index e2aa5b85b..4748f2b9d 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte @@ -6,6 +6,7 @@ import { } from "$env/static/public"; import { Hero } from "$lib/fragments"; import { GlobalState } from "$lib/global"; +import NotificationService from "$lib/services/NotificationService"; import { ButtonAction } from "$lib/ui"; import { capitalize } from "$lib/utils"; import * as falso from "@ngneat/falso"; @@ -16,6 +17,9 @@ import { provision } from "wallet-sdk"; let isPaneOpen = $state(false); let preVerified = $state(false); +let pushToken = $state(undefined); +let pushTokenError = $state(undefined); +let pushTokenLoading = $state(true); let loading = $state(false); let verificationId = $state(""); let demoName = $state(""); @@ -125,6 +129,16 @@ onMount(async () => { // Don't initialize key manager here - wait until user chooses their path + // Fetch push notification token for display (Android/iOS) + try { + pushToken = await NotificationService.getInstance().getPushToken(); + if (!pushToken) pushTokenError = "No token (desktop or permission denied)"; + } catch (e) { + pushTokenError = e instanceof Error ? e.message : "Failed to get push token"; + } finally { + pushTokenLoading = false; + } + handleContinue = async () => { // Require both verification code and name if ( @@ -209,6 +223,16 @@ onMount(async () => {
+
+

Push token (FCM/APNs):

+ {#if pushTokenLoading} + Loading... + {:else if pushToken} + {pushToken} + {:else} + {pushTokenError ?? "—"} + {/if} +
Date: Mon, 23 Feb 2026 12:11:28 +0530 Subject: [PATCH 2/8] fix: update lockfile --- pnpm-lock.yaml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8f55c60e..1cceda40a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: '@auvo/tauri-plugin-crypto-hw-api': specifier: ^0.1.0 version: 0.1.0 + '@choochmeque/tauri-plugin-notifications-api': + specifier: ^0.4.3 + version: 0.4.3 '@hugeicons/core-free-icons': specifier: ^1.0.13 version: 1.2.1 @@ -294,9 +297,6 @@ importers: '@tauri-apps/plugin-deep-link': specifier: ^2.4.5 version: 2.4.7 - '@tauri-apps/plugin-notification': - specifier: ^2.3.3 - version: 2.3.3 '@tauri-apps/plugin-opener': specifier: ^2.5.2 version: 2.5.3 @@ -4472,6 +4472,9 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@choochmeque/tauri-plugin-notifications-api@0.4.3': + resolution: {integrity: sha512-nq8dL7z9ZFC+G9cWmy/FTkjURIYDwvg7PklaIQQLwwOZmPEfBfLyvJsHNE+xTXRZYWdP79nkVchxjwxja35fUw==} + '@chromatic-com/storybook@3.2.7': resolution: {integrity: sha512-fCGhk4cd3VA8RNg55MZL5CScdHqljsQcL9g6Ss7YuobHpSo9yytEWNdgMd5QxAHSPBlLGFHjnSmliM3G/BeBqw==} engines: {node: '>=16.0.0', yarn: '>=1.22.18'} @@ -9189,9 +9192,6 @@ packages: '@tauri-apps/plugin-deep-link@2.4.7': resolution: {integrity: sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==} - '@tauri-apps/plugin-notification@2.3.3': - resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==} - '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} @@ -20562,6 +20562,10 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@choochmeque/tauri-plugin-notifications-api@0.4.3': + dependencies: + '@tauri-apps/api': 2.10.1 + '@chromatic-com/storybook@3.2.7(react@18.3.1)(storybook@8.6.15(bufferutil@4.1.0)(prettier@3.8.1))': dependencies: chromatic: 11.29.0 @@ -26733,10 +26737,6 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 - '@tauri-apps/plugin-notification@2.3.3': - dependencies: - '@tauri-apps/api': 2.10.1 - '@tauri-apps/plugin-opener@2.5.3': dependencies: '@tauri-apps/api': 2.10.1 From 59ab8d101ae07456b77e9af446e820d61ac42497 Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Mon, 23 Feb 2026 12:14:09 +0530 Subject: [PATCH 3/8] fix: format --- .../eid-wallet/src/routes/(auth)/onboarding/+page.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte index 4748f2b9d..700f8ad72 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte @@ -132,9 +132,11 @@ onMount(async () => { // Fetch push notification token for display (Android/iOS) try { pushToken = await NotificationService.getInstance().getPushToken(); - if (!pushToken) pushTokenError = "No token (desktop or permission denied)"; + if (!pushToken) + pushTokenError = "No token (desktop or permission denied)"; } catch (e) { - pushTokenError = e instanceof Error ? e.message : "Failed to get push token"; + pushTokenError = + e instanceof Error ? e.message : "Failed to get push token"; } finally { pushTokenLoading = false; } From d6ca59936c0adb7c641845129bd6ef11e95fcc9e Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Mon, 23 Feb 2026 12:20:43 +0530 Subject: [PATCH 4/8] fix: sort imports --- .../eid-wallet/src/lib/services/NotificationService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/eid-wallet/src/lib/services/NotificationService.ts b/infrastructure/eid-wallet/src/lib/services/NotificationService.ts index 680693b66..d0436394a 100644 --- a/infrastructure/eid-wallet/src/lib/services/NotificationService.ts +++ b/infrastructure/eid-wallet/src/lib/services/NotificationService.ts @@ -2,9 +2,9 @@ import { PUBLIC_PROVISIONER_URL } from "$env/static/public"; import { invoke } from "@tauri-apps/api/core"; import { isPermissionGranted, + registerForPushNotifications, requestPermission, sendNotification, - registerForPushNotifications, } from "@choochmeque/tauri-plugin-notifications-api"; export interface DeviceRegistration { From 7bf40b1a77f2fb5729295a1737cf7c274485009b Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Wed, 25 Feb 2026 15:07:46 +0530 Subject: [PATCH 5/8] fix: remove google-services.json --- .env.example | 9 ++ .../gen/android/app/google-services.json | 29 ----- pnpm-lock.yaml | 100 ++++++++++++++++++ 3 files changed, 109 insertions(+), 29 deletions(-) delete mode 100644 infrastructure/eid-wallet/src-tauri/gen/android/app/google-services.json diff --git a/.env.example b/.env.example index 3998101fa..06118f2f8 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,15 @@ CERBERUS_MAPPING_DB_PATH=/path/to/cerberus/mapping/db GOOGLE_APPLICATION_CREDENTIALS="/path/to/firebase-secrets.json" +# Notification Trigger (APNS/FCM toy platform) +NOTIFICATION_TRIGGER_PORT=3999 +# APNS (iOS) - from Apple Developer +APNS_KEY_PATH="/path/to/AuthKey_XXXXX.p8" +APNS_KEY_ID="your-key-id" +APNS_TEAM_ID="your-team-id" +APNS_BUNDLE_ID="com.example.app" +APNS_PRODUCTION=false + #PUBLIC_REGISTRY_URL="https://registry.w3ds.metastate.foundation" #PUBLIC_PROVISIONER_URL="https://provisioner.w3ds.metastate.foundation" diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/google-services.json b/infrastructure/eid-wallet/src-tauri/gen/android/app/google-services.json deleted file mode 100644 index f3f72e76f..000000000 --- a/infrastructure/eid-wallet/src-tauri/gen/android/app/google-services.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "project_info": { - "project_number": "346438559528", - "project_id": "eid-wallet-1b286", - "storage_bucket": "eid-wallet-1b286.firebasestorage.app" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:346438559528:android:133bf9df990583462ed21d", - "android_client_info": { - "package_name": "foundation.metastate.eid_wallet" - } - }, - "oauth_client": [], - "api_key": [ - { - "current_key": "AIzaSyAXofG56YlllvLV-yF4zroGjvmosoVXPIU" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [] - } - } - } - ], - "configuration_version": "1" -} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b198d1888..a571bb8ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -582,6 +582,40 @@ importers: specifier: ^1.6.1 version: 1.6.1(@types/node@20.19.26)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) + infrastructure/notification-trigger: + dependencies: + apn: + specifier: ^2.2.0 + version: 2.2.0 + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.22.1 + firebase-admin: + specifier: ^12.0.0 + version: 12.7.0(encoding@0.1.13) + devDependencies: + '@types/cors': + specifier: ^2.8.18 + version: 2.8.19 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^20.11.24 + version: 20.19.26 + tsx: + specifier: ^4.7.1 + version: 4.21.0 + typescript: + specifier: ^5.3.3 + version: 5.8.2 + infrastructure/signature-validator: dependencies: axios: @@ -10442,6 +10476,10 @@ packages: apexcharts@5.4.0: resolution: {integrity: sha512-qyEypKc1nixORUHdwO30izyyCqH9A4NsxQZ6Xrlq+ABEamOED1AoSg3eHaJMPRGT+wfE09wucLqwvk8oSr3tdA==} + apn@2.2.0: + resolution: {integrity: sha512-YIypYzPVJA9wzNBLKZ/mq2l1IZX/2FadPvwmSv4ZeR0VH7xdNITQ6Pucgh0Uw6ZZKC+XwheaJ57DFZAhJ0FvPg==} + engines: {node: '>= 4.6.0'} + app-root-path@3.1.0: resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==} engines: {node: '>= 6.0.0'} @@ -13561,6 +13599,11 @@ packages: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} + http2@https://github.com/node-apn/node-http2/archive/apn-2.1.4.tar.gz: + resolution: {tarball: https://github.com/node-apn/node-http2/archive/apn-2.1.4.tar.gz} + version: 3.3.6 + engines: {node: '>=0.12.0'} + https-browserify@1.0.0: resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==} @@ -14417,6 +14460,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + jsonwebtoken@8.5.1: + resolution: {integrity: sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==} + engines: {node: '>=4', npm: '>=1.4.28'} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -14432,6 +14479,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -14439,6 +14489,9 @@ packages: resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} engines: {node: '>=14'} + jws@3.2.3: + resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==} + jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -15390,6 +15443,9 @@ packages: encoding: optional: true + node-forge@0.7.6: + resolution: {integrity: sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==} + node-forge@1.3.3: resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} engines: {node: '>= 6.13.0'} @@ -17333,6 +17389,10 @@ packages: resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==} engines: {node: '>=12'} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -28307,6 +28367,16 @@ snapshots: dependencies: '@yr/monotone-cubic-spline': 1.0.3 + apn@2.2.0: + dependencies: + debug: 3.2.7 + http2: https://github.com/node-apn/node-http2/archive/apn-2.1.4.tar.gz + jsonwebtoken: 8.5.1 + node-forge: 0.7.6 + verror: 1.10.0 + transitivePeerDependencies: + - supports-color + app-root-path@3.1.0: {} append-field@1.0.0: {} @@ -32516,6 +32586,8 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + http2@https://github.com/node-apn/node-http2/archive/apn-2.1.4.tar.gz: {} + https-browserify@1.0.0: {} https-proxy-agent@5.0.1: @@ -33810,6 +33882,19 @@ snapshots: '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) jsep: 1.4.0 + jsonwebtoken@8.5.1: + dependencies: + jws: 3.2.3 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 5.7.2 + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -33839,6 +33924,12 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -33855,6 +33946,11 @@ snapshots: transitivePeerDependencies: - supports-color + jws@3.2.3: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + jws@4.0.1: dependencies: jwa: 2.0.1 @@ -35141,6 +35237,8 @@ snapshots: optionalDependencies: encoding: 0.1.13 + node-forge@0.7.6: {} + node-forge@1.3.3: {} node-gyp-build@4.8.4: @@ -37399,6 +37497,8 @@ snapshots: dependencies: semver: 7.7.4 + semver@5.7.2: {} + semver@6.3.1: {} semver@7.7.4: {} From 2450232f872bb429f3c00629382781bc5cd5b06c Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Wed, 25 Feb 2026 15:08:13 +0530 Subject: [PATCH 6/8] fix: ignore google-services.json --- .../eid-wallet/src-tauri/gen/android/app/.gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore b/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore index 4ecec9c1a..ae2ee920b 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore @@ -3,4 +3,6 @@ /src/main/assets/tauri.conf.json /tauri.build.gradle.kts /proguard-tauri.pro -/tauri.properties \ No newline at end of file +/tauri.properties + +google-services.json \ No newline at end of file From fc4d3fedc98803e99f16f0a76be2ae249548bde0 Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Wed, 25 Feb 2026 18:39:12 +0530 Subject: [PATCH 7/8] feat: CP notifications --- .env.example | 14 +- infrastructure/control-panel/.env.example | 3 + .../src/lib/services/notificationService.ts | 132 ++++++ .../control-panel/src/routes/+layout.svelte | 3 +- .../notifications/devices-count/+server.ts | 12 + .../api/notifications/health/+server.ts | 12 + .../notifications/send-bulk-all/+server.ts | 61 +++ .../api/notifications/send-bulk/+server.ts | 55 +++ .../notifications/send-by-ename/+server.ts | 63 +++ .../routes/api/notifications/send/+server.ts | 45 ++ .../src/routes/notifications/+page.svelte | 406 ++++++++++++++++++ .../src/routes/(app)/+layout.svelte | 11 + .../evault-core/src/config/database.ts | 3 +- .../src/controllers/NotificationController.ts | 73 +++- .../evault-core/src/entities/DeviceToken.ts | 33 ++ .../migrations/1771880000000-DeviceToken.ts | 32 ++ .../src/services/DeviceTokenService.ts | 94 ++++ .../src/services/NotificationService.ts | 5 + infrastructure/notification-trigger/README.md | 68 +++ .../notification-trigger/package.json | 26 ++ .../notification-trigger/public/index.html | 177 ++++++++ .../notification-trigger/src/index.ts | 107 +++++ .../notification-trigger/src/senders/apns.ts | 80 ++++ .../notification-trigger/src/senders/fcm.ts | 79 ++++ .../notification-trigger/src/senders/index.ts | 14 + .../notification-trigger/src/types.ts | 39 ++ .../notification-trigger/tsconfig.json | 16 + 27 files changed, 1640 insertions(+), 23 deletions(-) create mode 100644 infrastructure/control-panel/src/lib/services/notificationService.ts create mode 100644 infrastructure/control-panel/src/routes/api/notifications/devices-count/+server.ts create mode 100644 infrastructure/control-panel/src/routes/api/notifications/health/+server.ts create mode 100644 infrastructure/control-panel/src/routes/api/notifications/send-bulk-all/+server.ts create mode 100644 infrastructure/control-panel/src/routes/api/notifications/send-bulk/+server.ts create mode 100644 infrastructure/control-panel/src/routes/api/notifications/send-by-ename/+server.ts create mode 100644 infrastructure/control-panel/src/routes/api/notifications/send/+server.ts create mode 100644 infrastructure/control-panel/src/routes/notifications/+page.svelte create mode 100644 infrastructure/evault-core/src/entities/DeviceToken.ts create mode 100644 infrastructure/evault-core/src/migrations/1771880000000-DeviceToken.ts create mode 100644 infrastructure/evault-core/src/services/DeviceTokenService.ts create mode 100644 infrastructure/notification-trigger/README.md create mode 100644 infrastructure/notification-trigger/package.json create mode 100644 infrastructure/notification-trigger/public/index.html create mode 100644 infrastructure/notification-trigger/src/index.ts create mode 100644 infrastructure/notification-trigger/src/senders/apns.ts create mode 100644 infrastructure/notification-trigger/src/senders/fcm.ts create mode 100644 infrastructure/notification-trigger/src/senders/index.ts create mode 100644 infrastructure/notification-trigger/src/types.ts create mode 100644 infrastructure/notification-trigger/tsconfig.json diff --git a/.env.example b/.env.example index 06118f2f8..5d1f5c702 100644 --- a/.env.example +++ b/.env.example @@ -37,16 +37,20 @@ DREAMSYNC_MAPPING_DB_PATH="/path/to/dreamsync/mapping/db" GROUP_CHARTER_MAPPING_DB_PATH=/path/to/charter/mapping/db CERBERUS_MAPPING_DB_PATH=/path/to/cerberus/mapping/db -GOOGLE_APPLICATION_CREDENTIALS="/path/to/firebase-secrets.json" +GOOGLE_APPLICATION_CREDENTIALS="/Users/sosweetham/projs/metastate/prototype/secrets/eid-w-firebase-adminsdk.json" # Notification Trigger (APNS/FCM toy platform) -NOTIFICATION_TRIGGER_PORT=3999 +NOTIFICATION_TRIGGER_PORT=3998 +# Full URL for control panel proxy (optional; defaults to http://localhost:NOTIFICATION_TRIGGER_PORT) +NOTIFICATION_TRIGGER_URL=http://localhost:3998 # APNS (iOS) - from Apple Developer -APNS_KEY_PATH="/path/to/AuthKey_XXXXX.p8" -APNS_KEY_ID="your-key-id" -APNS_TEAM_ID="your-team-id" +APNS_KEY_PATH="/Users/sosweetham/projs/metastate/prototype/secrets/AuthKey_A3BBXD9YR3.p8" +APNS_KEY_ID="A3BBXD9YR3" +APNS_TEAM_ID="M49C8XS835" APNS_BUNDLE_ID="com.example.app" APNS_PRODUCTION=false +# Broadcast push (Live Activities) - base64 channel ID +APNS_BROADCAST_CHANNEL_ID=znbhuBJCEfEAAMIJbS9xUw== #PUBLIC_REGISTRY_URL="https://registry.w3ds.metastate.foundation" #PUBLIC_PROVISIONER_URL="https://provisioner.w3ds.metastate.foundation" diff --git a/infrastructure/control-panel/.env.example b/infrastructure/control-panel/.env.example index 2e5a24e16..6d28f4804 100644 --- a/infrastructure/control-panel/.env.example +++ b/infrastructure/control-panel/.env.example @@ -5,3 +5,6 @@ LOKI_PASSWORD=admin # Registry Configuration PUBLIC_REGISTRY_URL=https://registry.staging.metastate.foundation + +# Notification Trigger (for Notifications tab proxy) +NOTIFICATION_TRIGGER_URL=http://localhost:3998 diff --git a/infrastructure/control-panel/src/lib/services/notificationService.ts b/infrastructure/control-panel/src/lib/services/notificationService.ts new file mode 100644 index 000000000..cfad78ab3 --- /dev/null +++ b/infrastructure/control-panel/src/lib/services/notificationService.ts @@ -0,0 +1,132 @@ +import { env } from '$env/dynamic/private'; + +export interface NotificationPayload { + title: string; + body: string; + subtitle?: string; + data?: Record; + sound?: string; + badge?: number; + clickAction?: string; +} + +export interface SendNotificationRequest { + token: string; + platform?: 'ios' | 'android'; + payload: NotificationPayload; +} + +export interface SendResult { + success: boolean; + error?: string; +} + +function getBaseUrl(): string { + const url = env.NOTIFICATION_TRIGGER_URL; + if (url) return url; + const port = env.NOTIFICATION_TRIGGER_PORT || '3998'; + return `http://localhost:${port}`; +} + +export async function sendNotification( + request: SendNotificationRequest +): Promise { + const baseUrl = getBaseUrl(); + try { + const response = await fetch(`${baseUrl}/api/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal: AbortSignal.timeout(15000) + }); + const data = await response.json(); + if (data.success) return { success: true }; + return { success: false, error: data.error ?? 'Unknown error' }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : 'Request failed' + }; + } +} + +export async function getDevicesWithTokens(): Promise< + { token: string; platform: string; eName: string }[] +> { + const { env } = await import('$env/dynamic/private'); + const provisionerUrl = + env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001'; + try { + const response = await fetch(`${provisionerUrl}/api/devices/list`, { + signal: AbortSignal.timeout(10000) + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + return data.devices ?? []; + } catch (err) { + console.error('Failed to fetch devices:', err); + return []; + } +} + +export async function getDevicesByEName(eName: string): Promise< + { token: string; platform: string; eName: string }[] +> { + const { env } = await import('$env/dynamic/private'); + const provisionerUrl = + env.PUBLIC_PROVISIONER_URL || env.PROVISIONER_URL || 'http://localhost:3001'; + try { + const response = await fetch( + `${provisionerUrl}/api/devices/by-ename/${encodeURIComponent(eName)}`, + { signal: AbortSignal.timeout(10000) } + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + return data.devices ?? []; + } catch (err) { + console.error('Failed to fetch devices by eName:', err); + return []; + } +} + +export async function sendBulkNotifications( + tokens: string[], + payload: NotificationPayload, + platform?: 'ios' | 'android' +): Promise<{ sent: number; failed: number; errors: { token: string; error: string }[] }> { + const results = await Promise.all( + tokens.map(async (token) => { + const result = await sendNotification({ + token: token.trim(), + platform, + payload + }); + return { token: token.trim(), ...result }; + }) + ); + + const sent = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success); + return { + sent, + failed: failed.length, + errors: failed.map((r) => ({ token: r.token.slice(0, 20) + '...', error: r.error ?? 'Unknown' })) + }; +} + +export async function checkNotificationTriggerHealth(): Promise<{ + ok: boolean; + apns: boolean; + fcm: boolean; +}> { + const baseUrl = getBaseUrl(); + try { + const response = await fetch(`${baseUrl}/api/health`, { + signal: AbortSignal.timeout(5000) + }); + const data = await response.json(); + return { ok: data.ok ?? false, apns: data.apns ?? false, fcm: data.fcm ?? false }; + } catch { + return { ok: false, apns: false, fcm: false }; + } +} diff --git a/infrastructure/control-panel/src/routes/+layout.svelte b/infrastructure/control-panel/src/routes/+layout.svelte index 812f6fd26..7cc102df7 100644 --- a/infrastructure/control-panel/src/routes/+layout.svelte +++ b/infrastructure/control-panel/src/routes/+layout.svelte @@ -12,7 +12,8 @@ const navLinks = [ { label: 'Dashboard', href: '/' }, { label: 'Monitoring', href: '/monitoring' }, - { label: 'Actions', href: '/actions' } + { label: 'Actions', href: '/actions' }, + { label: 'Notifications', href: '/notifications' } ]; const isActive = (href: string) => (href === '/' ? pageUrl === '/' : pageUrl.startsWith(href)); diff --git a/infrastructure/control-panel/src/routes/api/notifications/devices-count/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/devices-count/+server.ts new file mode 100644 index 000000000..a50f85e3a --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/devices-count/+server.ts @@ -0,0 +1,12 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { getDevicesWithTokens } from '$lib/services/notificationService'; + +export const GET: RequestHandler = async () => { + try { + const devices = await getDevicesWithTokens(); + return json({ count: devices.length }); + } catch { + return json({ count: 0 }); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/notifications/health/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/health/+server.ts new file mode 100644 index 000000000..df479263e --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/health/+server.ts @@ -0,0 +1,12 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { checkNotificationTriggerHealth } from '$lib/services/notificationService'; + +export const GET: RequestHandler = async () => { + try { + const health = await checkNotificationTriggerHealth(); + return json(health); + } catch { + return json({ ok: false, apns: false, fcm: false }); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/notifications/send-bulk-all/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/send-bulk-all/+server.ts new file mode 100644 index 000000000..cb9439707 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/send-bulk-all/+server.ts @@ -0,0 +1,61 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { + getDevicesWithTokens, + sendBulkNotifications +} from '$lib/services/notificationService'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const { payload } = body; + + if (!payload?.title || !payload?.body) { + return json( + { success: false, error: 'Missing payload.title or payload.body' }, + { status: 400 } + ); + } + + const devices = await getDevicesWithTokens(); + const tokens = devices.map((d) => d.token); + + if (tokens.length === 0) { + return json( + { + success: false, + error: 'No registered devices with push tokens found' + }, + { status: 400 } + ); + } + + const result = await sendBulkNotifications( + tokens, + { + title: String(payload.title), + body: String(payload.body), + subtitle: payload.subtitle ? String(payload.subtitle) : undefined, + data: payload.data, + sound: payload.sound ? String(payload.sound) : undefined, + badge: payload.badge !== undefined ? Number(payload.badge) : undefined, + clickAction: payload.clickAction ? String(payload.clickAction) : undefined + } + // platform auto-detected per token + ); + + return json({ + success: true, + sent: result.sent, + failed: result.failed, + total: tokens.length, + errors: result.errors + }); + } catch (err) { + console.error('Bulk-all send error:', err); + return json( + { success: false, error: err instanceof Error ? err.message : 'Internal error' }, + { status: 500 } + ); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/notifications/send-bulk/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/send-bulk/+server.ts new file mode 100644 index 000000000..530b17b50 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/send-bulk/+server.ts @@ -0,0 +1,55 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { sendBulkNotifications } from '$lib/services/notificationService'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const { tokens, platform, payload } = body; + + if (!Array.isArray(tokens) || tokens.length === 0) { + return json({ success: false, error: 'tokens must be a non-empty array' }, { status: 400 }); + } + if (!payload?.title || !payload?.body) { + return json( + { success: false, error: 'Missing payload.title or payload.body' }, + { status: 400 } + ); + } + + const validTokens = tokens + .filter((t: unknown) => typeof t === 'string' && t.trim().length > 0) + .map((t: string) => t.trim()); + + if (validTokens.length === 0) { + return json({ success: false, error: 'No valid tokens' }, { status: 400 }); + } + + const result = await sendBulkNotifications( + validTokens, + { + title: String(payload.title), + body: String(payload.body), + subtitle: payload.subtitle ? String(payload.subtitle) : undefined, + data: payload.data, + sound: payload.sound ? String(payload.sound) : undefined, + badge: payload.badge !== undefined ? Number(payload.badge) : undefined, + clickAction: payload.clickAction ? String(payload.clickAction) : undefined + }, + platform && ['ios', 'android'].includes(platform) ? platform : undefined + ); + + return json({ + success: true, + sent: result.sent, + failed: result.failed, + errors: result.errors + }); + } catch (err) { + console.error('Bulk notification send error:', err); + return json( + { success: false, error: err instanceof Error ? err.message : 'Internal error' }, + { status: 500 } + ); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/notifications/send-by-ename/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/send-by-ename/+server.ts new file mode 100644 index 000000000..81e0d2d2f --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/send-by-ename/+server.ts @@ -0,0 +1,63 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { + getDevicesByEName, + sendBulkNotifications +} from '$lib/services/notificationService'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const { eName, payload } = body; + + if (!eName || typeof eName !== 'string' || !eName.trim()) { + return json( + { success: false, error: 'Missing or invalid eName' }, + { status: 400 } + ); + } + if (!payload?.title || !payload?.body) { + return json( + { success: false, error: 'Missing payload.title or payload.body' }, + { status: 400 } + ); + } + + const devices = await getDevicesByEName(eName.trim()); + const tokens = devices.map((d) => d.token); + + if (tokens.length === 0) { + return json( + { + success: false, + error: `No devices with push tokens found for eName: ${eName}` + }, + { status: 400 } + ); + } + + const result = await sendBulkNotifications(tokens, { + title: String(payload.title), + body: String(payload.body), + subtitle: payload.subtitle ? String(payload.subtitle) : undefined, + data: payload.data, + sound: payload.sound ? String(payload.sound) : undefined, + badge: payload.badge !== undefined ? Number(payload.badge) : undefined, + clickAction: payload.clickAction ? String(payload.clickAction) : undefined + }); + + return json({ + success: true, + sent: result.sent, + failed: result.failed, + total: tokens.length, + errors: result.errors + }); + } catch (err) { + console.error('Send by eName error:', err); + return json( + { success: false, error: err instanceof Error ? err.message : 'Internal error' }, + { status: 500 } + ); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/notifications/send/+server.ts b/infrastructure/control-panel/src/routes/api/notifications/send/+server.ts new file mode 100644 index 000000000..88dc6d655 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/notifications/send/+server.ts @@ -0,0 +1,45 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { sendNotification } from '$lib/services/notificationService'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const { token, platform, payload } = body; + + if (!token || typeof token !== 'string') { + return json({ success: false, error: 'Missing or invalid token' }, { status: 400 }); + } + if (!payload?.title || !payload?.body) { + return json( + { success: false, error: 'Missing payload.title or payload.body' }, + { status: 400 } + ); + } + + const result = await sendNotification({ + token: token.trim(), + platform, + payload: { + title: String(payload.title), + body: String(payload.body), + subtitle: payload.subtitle ? String(payload.subtitle) : undefined, + data: payload.data, + sound: payload.sound ? String(payload.sound) : undefined, + badge: payload.badge !== undefined ? Number(payload.badge) : undefined, + clickAction: payload.clickAction ? String(payload.clickAction) : undefined + } + }); + + if (result.success) { + return json({ success: true, message: 'Notification sent' }); + } + return json({ success: false, error: result.error }, { status: 500 }); + } catch (err) { + console.error('Notification send error:', err); + return json( + { success: false, error: err instanceof Error ? err.message : 'Internal error' }, + { status: 500 } + ); + } +}; diff --git a/infrastructure/control-panel/src/routes/notifications/+page.svelte b/infrastructure/control-panel/src/routes/notifications/+page.svelte new file mode 100644 index 000000000..2e3c74c90 --- /dev/null +++ b/infrastructure/control-panel/src/routes/notifications/+page.svelte @@ -0,0 +1,406 @@ + + +
+
+

Notifications

+

+ Send push notifications via APNS (iOS) and FCM (Android). Platform is auto-detected from + token format when not specified. +

+ {#if health} +
+ Trigger: {health.ok ? 'Connected' : 'Not connected'} + {#if health.ok} + APNS: {health.apns ? '✓' : '✗'} + FCM: {health.fcm ? '✓' : '✗'} + {/if} +
+ {/if} +
+ + +
+

Send single notification

+
+
+ + + {#if platformHint} +

Detected: {platformHint}

+ {/if} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Send + +
+
+ + +
+

Send by eName

+

+ Send to all devices registered for a specific eName (e.g. @user-id). +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Send to eName + +
+
+ + +
+

Send to all registered devices

+

+ One-button push to every device with a registered push token (from provisioner). +

+ {#if deviceCount !== null} +

+ {deviceCount} device{deviceCount === 1 ? '' : 's'} with push token{deviceCount === 1 ? '' : 's'} +

+ {/if} +
+
+ + +
+
+ + +
+
+ + +
+ + Send to all + +
+
+
+ +{#if toast} +
+

{toast.message}

+
+{/if} diff --git a/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte b/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte index a9ed5b2a5..2dd5355b7 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/+layout.svelte @@ -32,6 +32,17 @@ onMount(async () => { console.log("User authenticated, allowing access to app routes"); + // Register device for push notifications (eName + token to provisioner) + try { + const notificationService = globalState.notificationService; + const ename = vault && "ename" in vault ? String(vault.ename) : undefined; + if (ename) { + await notificationService.registerDevice(ename); + } + } catch (error) { + console.error("Failed to register device for notifications:", error); + } + // Check for notifications after successful authentication try { const notificationService = globalState.notificationService; diff --git a/infrastructure/evault-core/src/config/database.ts b/infrastructure/evault-core/src/config/database.ts index 0d64d08c6..4daf5c1a9 100644 --- a/infrastructure/evault-core/src/config/database.ts +++ b/infrastructure/evault-core/src/config/database.ts @@ -1,6 +1,7 @@ import { DataSource } from "typeorm" import { Verification } from "../entities/Verification" import { Notification } from "../entities/Notification" +import { DeviceToken } from "../entities/DeviceToken" import * as dotenv from "dotenv" import { join } from "path" @@ -11,7 +12,7 @@ export const AppDataSource = new DataSource({ type: "postgres", url: process.env.REGISTRY_DATABASE_URL || process.env.PROVISIONER_DATABASE_URL || "postgresql://postgres:postgres@localhost:5432/registry", logging: process.env.NODE_ENV !== "production", - entities: [Verification, Notification], + entities: [Verification, Notification, DeviceToken], migrations: [join(__dirname, "../migrations/*.{ts,js}")], migrationsTableName: "migrations", subscribers: [], diff --git a/infrastructure/evault-core/src/controllers/NotificationController.ts b/infrastructure/evault-core/src/controllers/NotificationController.ts index 4520c3ef4..6cde93715 100644 --- a/infrastructure/evault-core/src/controllers/NotificationController.ts +++ b/infrastructure/evault-core/src/controllers/NotificationController.ts @@ -1,33 +1,66 @@ import { Request, Response } from "express"; import { NotificationService } from "../services/NotificationService"; +import { DeviceTokenService } from "../services/DeviceTokenService"; import { AppDataSource } from "../config/database"; -import { Notification } from "../entities/Notification"; +import { DeviceToken } from "../entities/DeviceToken"; export class NotificationController { private notificationService: NotificationService; + private deviceTokenService: DeviceTokenService; constructor() { this.notificationService = new NotificationService( AppDataSource.getRepository("Verification"), AppDataSource.getRepository("Notification") ); + this.deviceTokenService = new DeviceTokenService( + AppDataSource.getRepository(DeviceToken) + ); } registerRoutes(app: any) { - // Register device endpoint app.post("/api/devices/register", this.registerDevice.bind(this)); - - // Unregister device endpoint app.post("/api/devices/unregister", this.unregisterDevice.bind(this)); - - // Send notification endpoint app.post("/api/notifications/send", this.sendNotification.bind(this)); - - // Check for notifications endpoint app.post("/api/notifications/check", this.checkNotifications.bind(this)); - - // Get device stats endpoint app.get("/api/devices/stats", this.getDeviceStats.bind(this)); + app.get("/api/devices/list", this.listDevicesWithTokens.bind(this)); + app.get("/api/devices/by-ename/:eName", this.getDevicesByEName.bind(this)); + } + + private async listDevicesWithTokens(req: Request, res: Response) { + try { + const devices = await this.deviceTokenService.getDevicesWithTokens(); + res.json({ success: true, devices }); + } catch (error) { + console.error("Error listing devices:", error); + res.status(500).json({ + success: false, + error: "Failed to list devices", + }); + } + } + + private async getDevicesByEName(req: Request, res: Response) { + try { + const eName = req.params.eName; + if (!eName) { + return res.status(400).json({ + success: false, + error: "Missing eName", + }); + } + const devices = await this.deviceTokenService.getDevicesByEName( + decodeURIComponent(eName) + ); + res.json({ success: true, devices }); + } catch (error) { + console.error("Error getting devices by eName:", error); + res.status(500).json({ + success: false, + error: "Failed to get devices", + }); + } } private async registerDevice(req: Request, res: Response) { @@ -41,16 +74,23 @@ export class NotificationController { }); } - const registration = { + if (fcmToken && typeof fcmToken === "string" && fcmToken.trim()) { + await this.deviceTokenService.register({ + eName, + deviceId, + platform, + token: fcmToken.trim(), + }); + } + + const verification = await this.notificationService.registerDevice({ eName, deviceId, platform, - fcmToken, - registrationTime: new Date() - }; + fcmToken: fcmToken.trim(), + registrationTime: new Date(), + }); - const verification = await this.notificationService.registerDevice(registration); - res.json({ success: true, message: "Device registered successfully", @@ -76,6 +116,7 @@ export class NotificationController { }); } + await this.deviceTokenService.unregister(eName, deviceId); const success = await this.notificationService.unregisterDevice(eName, deviceId); res.json({ diff --git a/infrastructure/evault-core/src/entities/DeviceToken.ts b/infrastructure/evault-core/src/entities/DeviceToken.ts new file mode 100644 index 000000000..feac2ed7b --- /dev/null +++ b/infrastructure/evault-core/src/entities/DeviceToken.ts @@ -0,0 +1,33 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; + +@Entity("device_token") +@Index(["eName", "deviceId"], { unique: true }) +export class DeviceToken { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "varchar" }) + eName!: string; + + @Column({ type: "varchar" }) + token!: string; + + @Column({ type: "varchar" }) + platform!: string; + + @Column({ type: "varchar" }) + deviceId!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/infrastructure/evault-core/src/migrations/1771880000000-DeviceToken.ts b/infrastructure/evault-core/src/migrations/1771880000000-DeviceToken.ts new file mode 100644 index 000000000..e0610c266 --- /dev/null +++ b/infrastructure/evault-core/src/migrations/1771880000000-DeviceToken.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1771880000000 implements MigrationInterface { + name = "Migration1771880000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "device_token" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "eName" character varying NOT NULL, + "token" character varying NOT NULL, + "platform" character varying NOT NULL, + "deviceId" character varying NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_device_token" PRIMARY KEY ("id") + ) + `); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_device_token_ename_deviceid" ON "device_token" ("eName", "deviceId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_device_token_ename" ON "device_token" ("eName")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "UQ_device_token_ename_deviceid"`); + await queryRunner.query(`DROP INDEX "IDX_device_token_ename"`); + await queryRunner.query(`DROP TABLE "device_token"`); + } +} diff --git a/infrastructure/evault-core/src/services/DeviceTokenService.ts b/infrastructure/evault-core/src/services/DeviceTokenService.ts new file mode 100644 index 000000000..a7fe5f3f6 --- /dev/null +++ b/infrastructure/evault-core/src/services/DeviceTokenService.ts @@ -0,0 +1,94 @@ +import { Repository } from "typeorm"; +import { DeviceToken } from "../entities/DeviceToken"; + +export interface DeviceTokenRegistration { + eName: string; + deviceId: string; + platform: string; + token: string; +} + +export class DeviceTokenService { + constructor(private deviceTokenRepository: Repository) {} + + async register(registration: DeviceTokenRegistration): Promise { + const { eName, deviceId, platform, token } = registration; + + // 1. Exact match: same eName + deviceId → update token/platform + const byEnameAndDevice = await this.deviceTokenRepository.findOne({ + where: { eName, deviceId }, + }); + if (byEnameAndDevice) { + byEnameAndDevice.token = token; + byEnameAndDevice.platform = platform; + byEnameAndDevice.updatedAt = new Date(); + return this.deviceTokenRepository.save(byEnameAndDevice); + } + + // 2. Same token (same physical device) but different eName or deviceId → update eName/deviceId + const byToken = await this.deviceTokenRepository.findOne({ + where: { token }, + }); + if (byToken) { + byToken.eName = eName; + byToken.deviceId = deviceId; + byToken.platform = platform; + byToken.updatedAt = new Date(); + return this.deviceTokenRepository.save(byToken); + } + + // 3. Both eName and token are new → create + const deviceToken = this.deviceTokenRepository.create({ + eName, + deviceId, + platform, + token, + }); + return this.deviceTokenRepository.save(deviceToken); + } + + async getDevicesWithTokens(): Promise< + { token: string; platform: string; eName: string }[] + > { + const tokens = await this.deviceTokenRepository.find({ + order: { updatedAt: "DESC" }, + }); + return tokens.map((t) => ({ + token: t.token, + platform: t.platform, + eName: t.eName, + })); + } + + async getDevicesByEName(eName: string): Promise< + { token: string; platform: string; eName: string }[] + > { + const normalized = eName.startsWith("@") ? eName : `@${eName}`; + const withoutAt = eName.replace(/^@/, ""); + const tokens = await this.deviceTokenRepository + .createQueryBuilder("dt") + .where("dt.eName = :e1 OR dt.eName = :e2", { + e1: normalized, + e2: withoutAt, + }) + .orderBy("dt.updatedAt", "DESC") + .getMany(); + return tokens.map((t) => ({ + token: t.token, + platform: t.platform, + eName: t.eName, + })); + } + + async getDeviceCount(): Promise { + return this.deviceTokenRepository.count(); + } + + async unregister(eName: string, deviceId: string): Promise { + const result = await this.deviceTokenRepository.delete({ + eName, + deviceId, + }); + return (result.affected ?? 0) > 0; + } +} diff --git a/infrastructure/evault-core/src/services/NotificationService.ts b/infrastructure/evault-core/src/services/NotificationService.ts index 2b0ede80f..1eaf325f6 100644 --- a/infrastructure/evault-core/src/services/NotificationService.ts +++ b/infrastructure/evault-core/src/services/NotificationService.ts @@ -124,6 +124,11 @@ export class NotificationService { }); } + async getDevicesWithPushTokens(): Promise { + const all = await this.getAllDevices(); + return all.filter((v) => v.fcmToken && v.fcmToken.trim().length > 0); + } + async getDeviceStats(): Promise<{ totalDevices: number; devicesByPlatform: Record }> { const verifications = await this.getAllDevices(); const devicesByPlatform: Record = {}; diff --git a/infrastructure/notification-trigger/README.md b/infrastructure/notification-trigger/README.md new file mode 100644 index 000000000..c014ab542 --- /dev/null +++ b/infrastructure/notification-trigger/README.md @@ -0,0 +1,68 @@ +# Notification Trigger + +A simple toy platform to send push notifications via **APNS** (iOS) and **FCM** (Android). Accepts a common payload structure and routes to the appropriate service based on platform. + +## Quick start + +```bash +pnpm install +pnpm dev +``` + +Open http://localhost:3998 to use the web UI. + +## API + +### POST /api/send + +Send a push notification. + +**Request body:** + +```json +{ + "token": "", + "platform": "ios" | "android" | null, + "payload": { + "title": "Hello", + "body": "Notification body", + "subtitle": "Optional subtitle", + "data": { "key": "value" }, + "sound": "default", + "badge": 1, + "clickAction": "category-id" + } +} +``` + +- **token**: APNS device token (iOS) or FCM registration token (Android) +- **platform**: Optional. If omitted, auto-detected from token format (64 hex chars → iOS/APNS, else → Android/FCM) +- **payload**: Common structure; `title` and `body` are required. `data` values must be strings for FCM compatibility. + +**Example (curl):** + +```bash +curl -X POST http://localhost:3998/api/send \ + -H "Content-Type: application/json" \ + -d '{ + "token": "YOUR_DEVICE_TOKEN", + "payload": { + "title": "Test", + "body": "Hello from notification trigger" + } + }' +``` + +## Environment + +| Variable | Description | +|----------|-------------| +| `NOTIFICATION_TRIGGER_PORT` | Server port (default: 3998) | +| `GOOGLE_APPLICATION_CREDENTIALS` | Path to Firebase service account JSON (for FCM) | +| `APNS_KEY_PATH` | Path to .p8 APNS auth key | +| `APNS_KEY_ID` | APNS key ID | +| `APNS_TEAM_ID` | Apple team ID | +| `APNS_BUNDLE_ID` | App bundle ID | +| `APNS_PRODUCTION` | `true` for production APNS | + +Load from project root `.env` (or set in shell). diff --git a/infrastructure/notification-trigger/package.json b/infrastructure/notification-trigger/package.json new file mode 100644 index 000000000..a0f483696 --- /dev/null +++ b/infrastructure/notification-trigger/package.json @@ -0,0 +1,26 @@ +{ + "name": "notification-trigger", + "version": "0.1.0", + "private": true, + "description": "Simple toy platform to trigger push notifications via APNS/FCM", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc && cp -r public dist/", + "start": "node dist/index.js" + }, + "dependencies": { + "apn": "^2.2.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "firebase-admin": "^12.0.0" + }, + "devDependencies": { + "@types/cors": "^2.8.18", + "@types/express": "^4.17.21", + "@types/node": "^20.11.24", + "tsx": "^4.7.1", + "typescript": "^5.3.3" + } +} diff --git a/infrastructure/notification-trigger/public/index.html b/infrastructure/notification-trigger/public/index.html new file mode 100644 index 000000000..208b62422 --- /dev/null +++ b/infrastructure/notification-trigger/public/index.html @@ -0,0 +1,177 @@ + + + + + + Notification Trigger + + + +

Notification Trigger

+
+ + +
+ + + + + + + + + + + + + + +
+ + + + + + diff --git a/infrastructure/notification-trigger/src/index.ts b/infrastructure/notification-trigger/src/index.ts new file mode 100644 index 000000000..d10d70adc --- /dev/null +++ b/infrastructure/notification-trigger/src/index.ts @@ -0,0 +1,107 @@ +import path from "path"; +import cors from "cors"; +import dotenv from "dotenv"; +import express, { type Request, type Response } from "express"; +import { initApns, shutdownApns } from "./senders/apns"; +import { initFcm } from "./senders/fcm"; +import { sendNotification } from "./senders"; +import type { NotificationPayload, Platform } from "./types"; +import { detectPlatformFromToken } from "./types"; + +// Load root .env (two levels up from src/) +dotenv.config({ + path: path.resolve(__dirname, "../../../.env"), +}); + +const app = express(); +const PORT = process.env.NOTIFICATION_TRIGGER_PORT || 3998; + +app.use(cors()); +app.use(express.json()); + +// Serve static UI +const publicDir = path.join(path.resolve(__dirname, ".."), "public"); +app.use(express.static(publicDir)); + +app.get("/api/health", (_req: Request, res: Response) => { + res.json({ + ok: true, + apns: !!process.env.APNS_KEY_PATH, + fcm: !!process.env.GOOGLE_APPLICATION_CREDENTIALS, + }); +}); + +app.post("/api/send", async (req: Request, res: Response) => { + try { + const { token, platform: platformParam, payload } = req.body; + + if (!token || typeof token !== "string") { + return res.status(400).json({ + success: false, + error: "Missing or invalid 'token' (APNS/FCM token)", + }); + } + + const platform: Platform = + platformParam && ["ios", "android"].includes(platformParam) + ? platformParam + : detectPlatformFromToken(token); + + if (!payload?.title || !payload?.body) { + return res.status(400).json({ + success: false, + error: "Missing or invalid 'payload' (requires title and body)", + }); + } + + const notificationPayload: NotificationPayload = { + title: String(payload.title), + body: String(payload.body), + ...(payload.subtitle && { subtitle: String(payload.subtitle) }), + ...(payload.data && { + data: Object.fromEntries( + Object.entries(payload.data).map(([k, v]) => [k, String(v)]) + ), + }), + ...(payload.sound && { sound: String(payload.sound) }), + ...(payload.badge !== undefined && { badge: Number(payload.badge) }), + ...(payload.clickAction && { clickAction: String(payload.clickAction) }), + }; + + const result = await sendNotification( + token.trim(), + platform, + notificationPayload + ); + + if (result.success) { + return res.json({ success: true, message: "Notification sent" }); + } + + return res.status(500).json({ + success: false, + error: result.error ?? "Failed to send notification", + }); + } catch (err) { + console.error("Send error:", err); + return res.status(500).json({ + success: false, + error: err instanceof Error ? err.message : "Internal server error", + }); + } +}); + +// Initialize providers +initApns(); +initFcm(); + +const server = app.listen(PORT, () => { + console.log(`Notification trigger running at http://localhost:${PORT}`); + console.log(` API: POST /api/send`); + console.log(` UI: http://localhost:${PORT}/`); +}); + +process.on("SIGTERM", () => { + shutdownApns(); + server.close(); +}); diff --git a/infrastructure/notification-trigger/src/senders/apns.ts b/infrastructure/notification-trigger/src/senders/apns.ts new file mode 100644 index 000000000..694a9d0f3 --- /dev/null +++ b/infrastructure/notification-trigger/src/senders/apns.ts @@ -0,0 +1,80 @@ +import apn from "apn"; +import type { NotificationPayload } from "../types"; + +let provider: apn.Provider | null = null; + +export function initApns(): boolean { + const keyPath = process.env.APNS_KEY_PATH; + const keyId = process.env.APNS_KEY_ID; + const teamId = process.env.APNS_TEAM_ID; + const bundleId = process.env.APNS_BUNDLE_ID; + + if (!keyPath || !keyId || !teamId || !bundleId) { + console.warn( + "[APNS] Missing config: APNS_KEY_PATH, APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID" + ); + return false; + } + + try { + provider = new apn.Provider({ + token: { + key: keyPath, + keyId, + teamId, + }, + production: process.env.APNS_PRODUCTION === "true", + }); + console.log("[APNS] Provider initialized"); + return true; + } catch (err) { + console.error("[APNS] Failed to initialize:", err); + return false; + } +} + +export async function sendApns( + token: string, + payload: NotificationPayload +): Promise<{ success: boolean; error?: string }> { + if (!provider) { + return { success: false, error: "APNS not configured" }; + } + + const note = new apn.Notification(); + note.alert = { + title: payload.title, + body: payload.body, + ...(payload.subtitle && { subtitle: payload.subtitle }), + }; + note.topic = process.env.APNS_BUNDLE_ID!; + note.sound = payload.sound ?? "default"; + if (payload.badge !== undefined) note.badge = payload.badge; + if (payload.clickAction) note.aps = { ...note.aps, category: payload.clickAction }; + if (payload.data && Object.keys(payload.data).length > 0) { + note.payload = payload.data; + } + + try { + const result = await provider.send(note, token); + if (result.failed.length > 0) { + const fail = result.failed[0]; + const err = + fail.response?.reason ?? fail.status ?? fail.error?.message ?? "Unknown"; + return { success: false, error: String(err) }; + } + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export function shutdownApns(): void { + if (provider) { + provider.shutdown(); + provider = null; + } +} diff --git a/infrastructure/notification-trigger/src/senders/fcm.ts b/infrastructure/notification-trigger/src/senders/fcm.ts new file mode 100644 index 000000000..4182a8a40 --- /dev/null +++ b/infrastructure/notification-trigger/src/senders/fcm.ts @@ -0,0 +1,79 @@ +import admin from "firebase-admin"; +import type { NotificationPayload } from "../types"; + +let initialized = false; + +export function initFcm(): boolean { + if (initialized) return true; + + if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { + console.warn("[FCM] Missing GOOGLE_APPLICATION_CREDENTIALS"); + return false; + } + + try { + admin.initializeApp({ + credential: admin.credential.applicationDefault(), + }); + initialized = true; + console.log("[FCM] Initialized"); + return true; + } catch (err) { + console.error("[FCM] Failed to initialize:", err); + return false; + } +} + +export async function sendFcm( + token: string, + payload: NotificationPayload +): Promise<{ success: boolean; error?: string }> { + if (!initialized) { + return { success: false, error: "FCM not configured" }; + } + + const data = + payload.data && + Object.fromEntries( + Object.entries(payload.data).map(([k, v]) => [k, String(v)]) + ); + + const message: admin.messaging.Message = { + token, + notification: { + title: payload.title, + body: payload.body, + }, + ...(data && Object.keys(data).length > 0 && { data }), + android: { + notification: { + title: payload.title, + body: payload.body, + sound: payload.sound ?? "default", + channelId: payload.clickAction ?? "default", + }, + }, + apns: { + payload: { + aps: { + alert: { + title: payload.title, + body: payload.body, + ...(payload.subtitle && { subtitle: payload.subtitle }), + }, + sound: payload.sound ?? "default", + ...(payload.badge !== undefined && { badge: payload.badge }), + ...(payload.clickAction && { category: payload.clickAction }), + }, + }, + }, + }; + + try { + const response = await admin.messaging().send(message); + return { success: true }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { success: false, error: msg }; + } +} diff --git a/infrastructure/notification-trigger/src/senders/index.ts b/infrastructure/notification-trigger/src/senders/index.ts new file mode 100644 index 000000000..53e29ae33 --- /dev/null +++ b/infrastructure/notification-trigger/src/senders/index.ts @@ -0,0 +1,14 @@ +import { sendApns } from "./apns"; +import { sendFcm } from "./fcm"; +import type { NotificationPayload, Platform } from "../types.js"; + +export async function sendNotification( + token: string, + platform: Platform, + payload: NotificationPayload +): Promise<{ success: boolean; error?: string }> { + if (platform === "ios") { + return sendApns(token, payload); + } + return sendFcm(token, payload); +} diff --git a/infrastructure/notification-trigger/src/types.ts b/infrastructure/notification-trigger/src/types.ts new file mode 100644 index 000000000..fe1f3a76b --- /dev/null +++ b/infrastructure/notification-trigger/src/types.ts @@ -0,0 +1,39 @@ +/** + * Common notification payload - works for both iOS (APNS) and Android (FCM). + * Maps cleanly to platform-specific formats. + */ +export interface NotificationPayload { + /** Notification title */ + title: string; + /** Notification body text */ + body: string; + /** Optional subtitle (iOS) / subtext (Android) */ + subtitle?: string; + /** Custom data payload - string values only for FCM compatibility */ + data?: Record; + /** Sound name - "default" or custom */ + sound?: string; + /** Badge count (iOS) */ + badge?: number; + /** Click action / category (iOS) / channel (Android) */ + clickAction?: string; +} + +export type Platform = "ios" | "android"; + +/** + * APNS tokens are 64 hex chars; FCM tokens are longer and more varied. + */ +export function detectPlatformFromToken(token: string): Platform { + const cleaned = token.replace(/\s/g, ""); + return /^[a-fA-F0-9]{64}$/.test(cleaned) ? "ios" : "android"; +} + +export interface SendNotificationRequest { + /** APNS token (iOS) or FCM token (Android) */ + token: string; + /** Target platform - optional, auto-detected from token format if omitted */ + platform?: Platform; + /** Common notification payload */ + payload: NotificationPayload; +} diff --git a/infrastructure/notification-trigger/tsconfig.json b/infrastructure/notification-trigger/tsconfig.json new file mode 100644 index 000000000..8d213d2c3 --- /dev/null +++ b/infrastructure/notification-trigger/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 305bdab8939c758fca0876df4a79217c9692064b Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Wed, 25 Feb 2026 18:49:31 +0530 Subject: [PATCH 8/8] fix: remove notification trigger test proj --- infrastructure/notification-trigger/README.md | 68 ------- .../notification-trigger/package.json | 26 --- .../notification-trigger/public/index.html | 177 ------------------ .../notification-trigger/src/index.ts | 107 ----------- .../notification-trigger/src/senders/apns.ts | 80 -------- .../notification-trigger/src/senders/fcm.ts | 79 -------- .../notification-trigger/src/senders/index.ts | 14 -- .../notification-trigger/src/types.ts | 39 ---- .../notification-trigger/tsconfig.json | 16 -- pnpm-lock.yaml | 68 +++---- pnpm-workspace.yaml | 1 + 11 files changed, 35 insertions(+), 640 deletions(-) delete mode 100644 infrastructure/notification-trigger/README.md delete mode 100644 infrastructure/notification-trigger/package.json delete mode 100644 infrastructure/notification-trigger/public/index.html delete mode 100644 infrastructure/notification-trigger/src/index.ts delete mode 100644 infrastructure/notification-trigger/src/senders/apns.ts delete mode 100644 infrastructure/notification-trigger/src/senders/fcm.ts delete mode 100644 infrastructure/notification-trigger/src/senders/index.ts delete mode 100644 infrastructure/notification-trigger/src/types.ts delete mode 100644 infrastructure/notification-trigger/tsconfig.json diff --git a/infrastructure/notification-trigger/README.md b/infrastructure/notification-trigger/README.md deleted file mode 100644 index c014ab542..000000000 --- a/infrastructure/notification-trigger/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Notification Trigger - -A simple toy platform to send push notifications via **APNS** (iOS) and **FCM** (Android). Accepts a common payload structure and routes to the appropriate service based on platform. - -## Quick start - -```bash -pnpm install -pnpm dev -``` - -Open http://localhost:3998 to use the web UI. - -## API - -### POST /api/send - -Send a push notification. - -**Request body:** - -```json -{ - "token": "", - "platform": "ios" | "android" | null, - "payload": { - "title": "Hello", - "body": "Notification body", - "subtitle": "Optional subtitle", - "data": { "key": "value" }, - "sound": "default", - "badge": 1, - "clickAction": "category-id" - } -} -``` - -- **token**: APNS device token (iOS) or FCM registration token (Android) -- **platform**: Optional. If omitted, auto-detected from token format (64 hex chars → iOS/APNS, else → Android/FCM) -- **payload**: Common structure; `title` and `body` are required. `data` values must be strings for FCM compatibility. - -**Example (curl):** - -```bash -curl -X POST http://localhost:3998/api/send \ - -H "Content-Type: application/json" \ - -d '{ - "token": "YOUR_DEVICE_TOKEN", - "payload": { - "title": "Test", - "body": "Hello from notification trigger" - } - }' -``` - -## Environment - -| Variable | Description | -|----------|-------------| -| `NOTIFICATION_TRIGGER_PORT` | Server port (default: 3998) | -| `GOOGLE_APPLICATION_CREDENTIALS` | Path to Firebase service account JSON (for FCM) | -| `APNS_KEY_PATH` | Path to .p8 APNS auth key | -| `APNS_KEY_ID` | APNS key ID | -| `APNS_TEAM_ID` | Apple team ID | -| `APNS_BUNDLE_ID` | App bundle ID | -| `APNS_PRODUCTION` | `true` for production APNS | - -Load from project root `.env` (or set in shell). diff --git a/infrastructure/notification-trigger/package.json b/infrastructure/notification-trigger/package.json deleted file mode 100644 index a0f483696..000000000 --- a/infrastructure/notification-trigger/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "notification-trigger", - "version": "0.1.0", - "private": true, - "description": "Simple toy platform to trigger push notifications via APNS/FCM", - "main": "dist/index.js", - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc && cp -r public dist/", - "start": "node dist/index.js" - }, - "dependencies": { - "apn": "^2.2.0", - "cors": "^2.8.5", - "dotenv": "^16.4.5", - "express": "^4.18.2", - "firebase-admin": "^12.0.0" - }, - "devDependencies": { - "@types/cors": "^2.8.18", - "@types/express": "^4.17.21", - "@types/node": "^20.11.24", - "tsx": "^4.7.1", - "typescript": "^5.3.3" - } -} diff --git a/infrastructure/notification-trigger/public/index.html b/infrastructure/notification-trigger/public/index.html deleted file mode 100644 index 208b62422..000000000 --- a/infrastructure/notification-trigger/public/index.html +++ /dev/null @@ -1,177 +0,0 @@ - - - - - - Notification Trigger - - - -

Notification Trigger

-
- - -
- - - - - - - - - - - - - - -
- - - - - - diff --git a/infrastructure/notification-trigger/src/index.ts b/infrastructure/notification-trigger/src/index.ts deleted file mode 100644 index d10d70adc..000000000 --- a/infrastructure/notification-trigger/src/index.ts +++ /dev/null @@ -1,107 +0,0 @@ -import path from "path"; -import cors from "cors"; -import dotenv from "dotenv"; -import express, { type Request, type Response } from "express"; -import { initApns, shutdownApns } from "./senders/apns"; -import { initFcm } from "./senders/fcm"; -import { sendNotification } from "./senders"; -import type { NotificationPayload, Platform } from "./types"; -import { detectPlatformFromToken } from "./types"; - -// Load root .env (two levels up from src/) -dotenv.config({ - path: path.resolve(__dirname, "../../../.env"), -}); - -const app = express(); -const PORT = process.env.NOTIFICATION_TRIGGER_PORT || 3998; - -app.use(cors()); -app.use(express.json()); - -// Serve static UI -const publicDir = path.join(path.resolve(__dirname, ".."), "public"); -app.use(express.static(publicDir)); - -app.get("/api/health", (_req: Request, res: Response) => { - res.json({ - ok: true, - apns: !!process.env.APNS_KEY_PATH, - fcm: !!process.env.GOOGLE_APPLICATION_CREDENTIALS, - }); -}); - -app.post("/api/send", async (req: Request, res: Response) => { - try { - const { token, platform: platformParam, payload } = req.body; - - if (!token || typeof token !== "string") { - return res.status(400).json({ - success: false, - error: "Missing or invalid 'token' (APNS/FCM token)", - }); - } - - const platform: Platform = - platformParam && ["ios", "android"].includes(platformParam) - ? platformParam - : detectPlatformFromToken(token); - - if (!payload?.title || !payload?.body) { - return res.status(400).json({ - success: false, - error: "Missing or invalid 'payload' (requires title and body)", - }); - } - - const notificationPayload: NotificationPayload = { - title: String(payload.title), - body: String(payload.body), - ...(payload.subtitle && { subtitle: String(payload.subtitle) }), - ...(payload.data && { - data: Object.fromEntries( - Object.entries(payload.data).map(([k, v]) => [k, String(v)]) - ), - }), - ...(payload.sound && { sound: String(payload.sound) }), - ...(payload.badge !== undefined && { badge: Number(payload.badge) }), - ...(payload.clickAction && { clickAction: String(payload.clickAction) }), - }; - - const result = await sendNotification( - token.trim(), - platform, - notificationPayload - ); - - if (result.success) { - return res.json({ success: true, message: "Notification sent" }); - } - - return res.status(500).json({ - success: false, - error: result.error ?? "Failed to send notification", - }); - } catch (err) { - console.error("Send error:", err); - return res.status(500).json({ - success: false, - error: err instanceof Error ? err.message : "Internal server error", - }); - } -}); - -// Initialize providers -initApns(); -initFcm(); - -const server = app.listen(PORT, () => { - console.log(`Notification trigger running at http://localhost:${PORT}`); - console.log(` API: POST /api/send`); - console.log(` UI: http://localhost:${PORT}/`); -}); - -process.on("SIGTERM", () => { - shutdownApns(); - server.close(); -}); diff --git a/infrastructure/notification-trigger/src/senders/apns.ts b/infrastructure/notification-trigger/src/senders/apns.ts deleted file mode 100644 index 694a9d0f3..000000000 --- a/infrastructure/notification-trigger/src/senders/apns.ts +++ /dev/null @@ -1,80 +0,0 @@ -import apn from "apn"; -import type { NotificationPayload } from "../types"; - -let provider: apn.Provider | null = null; - -export function initApns(): boolean { - const keyPath = process.env.APNS_KEY_PATH; - const keyId = process.env.APNS_KEY_ID; - const teamId = process.env.APNS_TEAM_ID; - const bundleId = process.env.APNS_BUNDLE_ID; - - if (!keyPath || !keyId || !teamId || !bundleId) { - console.warn( - "[APNS] Missing config: APNS_KEY_PATH, APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID" - ); - return false; - } - - try { - provider = new apn.Provider({ - token: { - key: keyPath, - keyId, - teamId, - }, - production: process.env.APNS_PRODUCTION === "true", - }); - console.log("[APNS] Provider initialized"); - return true; - } catch (err) { - console.error("[APNS] Failed to initialize:", err); - return false; - } -} - -export async function sendApns( - token: string, - payload: NotificationPayload -): Promise<{ success: boolean; error?: string }> { - if (!provider) { - return { success: false, error: "APNS not configured" }; - } - - const note = new apn.Notification(); - note.alert = { - title: payload.title, - body: payload.body, - ...(payload.subtitle && { subtitle: payload.subtitle }), - }; - note.topic = process.env.APNS_BUNDLE_ID!; - note.sound = payload.sound ?? "default"; - if (payload.badge !== undefined) note.badge = payload.badge; - if (payload.clickAction) note.aps = { ...note.aps, category: payload.clickAction }; - if (payload.data && Object.keys(payload.data).length > 0) { - note.payload = payload.data; - } - - try { - const result = await provider.send(note, token); - if (result.failed.length > 0) { - const fail = result.failed[0]; - const err = - fail.response?.reason ?? fail.status ?? fail.error?.message ?? "Unknown"; - return { success: false, error: String(err) }; - } - return { success: true }; - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : String(err), - }; - } -} - -export function shutdownApns(): void { - if (provider) { - provider.shutdown(); - provider = null; - } -} diff --git a/infrastructure/notification-trigger/src/senders/fcm.ts b/infrastructure/notification-trigger/src/senders/fcm.ts deleted file mode 100644 index 4182a8a40..000000000 --- a/infrastructure/notification-trigger/src/senders/fcm.ts +++ /dev/null @@ -1,79 +0,0 @@ -import admin from "firebase-admin"; -import type { NotificationPayload } from "../types"; - -let initialized = false; - -export function initFcm(): boolean { - if (initialized) return true; - - if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { - console.warn("[FCM] Missing GOOGLE_APPLICATION_CREDENTIALS"); - return false; - } - - try { - admin.initializeApp({ - credential: admin.credential.applicationDefault(), - }); - initialized = true; - console.log("[FCM] Initialized"); - return true; - } catch (err) { - console.error("[FCM] Failed to initialize:", err); - return false; - } -} - -export async function sendFcm( - token: string, - payload: NotificationPayload -): Promise<{ success: boolean; error?: string }> { - if (!initialized) { - return { success: false, error: "FCM not configured" }; - } - - const data = - payload.data && - Object.fromEntries( - Object.entries(payload.data).map(([k, v]) => [k, String(v)]) - ); - - const message: admin.messaging.Message = { - token, - notification: { - title: payload.title, - body: payload.body, - }, - ...(data && Object.keys(data).length > 0 && { data }), - android: { - notification: { - title: payload.title, - body: payload.body, - sound: payload.sound ?? "default", - channelId: payload.clickAction ?? "default", - }, - }, - apns: { - payload: { - aps: { - alert: { - title: payload.title, - body: payload.body, - ...(payload.subtitle && { subtitle: payload.subtitle }), - }, - sound: payload.sound ?? "default", - ...(payload.badge !== undefined && { badge: payload.badge }), - ...(payload.clickAction && { category: payload.clickAction }), - }, - }, - }, - }; - - try { - const response = await admin.messaging().send(message); - return { success: true }; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - return { success: false, error: msg }; - } -} diff --git a/infrastructure/notification-trigger/src/senders/index.ts b/infrastructure/notification-trigger/src/senders/index.ts deleted file mode 100644 index 53e29ae33..000000000 --- a/infrastructure/notification-trigger/src/senders/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { sendApns } from "./apns"; -import { sendFcm } from "./fcm"; -import type { NotificationPayload, Platform } from "../types.js"; - -export async function sendNotification( - token: string, - platform: Platform, - payload: NotificationPayload -): Promise<{ success: boolean; error?: string }> { - if (platform === "ios") { - return sendApns(token, payload); - } - return sendFcm(token, payload); -} diff --git a/infrastructure/notification-trigger/src/types.ts b/infrastructure/notification-trigger/src/types.ts deleted file mode 100644 index fe1f3a76b..000000000 --- a/infrastructure/notification-trigger/src/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Common notification payload - works for both iOS (APNS) and Android (FCM). - * Maps cleanly to platform-specific formats. - */ -export interface NotificationPayload { - /** Notification title */ - title: string; - /** Notification body text */ - body: string; - /** Optional subtitle (iOS) / subtext (Android) */ - subtitle?: string; - /** Custom data payload - string values only for FCM compatibility */ - data?: Record; - /** Sound name - "default" or custom */ - sound?: string; - /** Badge count (iOS) */ - badge?: number; - /** Click action / category (iOS) / channel (Android) */ - clickAction?: string; -} - -export type Platform = "ios" | "android"; - -/** - * APNS tokens are 64 hex chars; FCM tokens are longer and more varied. - */ -export function detectPlatformFromToken(token: string): Platform { - const cleaned = token.replace(/\s/g, ""); - return /^[a-fA-F0-9]{64}$/.test(cleaned) ? "ios" : "android"; -} - -export interface SendNotificationRequest { - /** APNS token (iOS) or FCM token (Android) */ - token: string; - /** Target platform - optional, auto-detected from token format if omitted */ - platform?: Platform; - /** Common notification payload */ - payload: NotificationPayload; -} diff --git a/infrastructure/notification-trigger/tsconfig.json b/infrastructure/notification-trigger/tsconfig.json deleted file mode 100644 index 8d213d2c3..000000000 --- a/infrastructure/notification-trigger/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "commonjs", - "moduleResolution": "node", - "esModuleInterop": true, - "strict": true, - "skipLibCheck": true, - "outDir": "dist", - "rootDir": "src", - "sourceMap": true, - "declaration": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a571bb8ad..99d1d07b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -582,40 +582,6 @@ importers: specifier: ^1.6.1 version: 1.6.1(@types/node@20.19.26)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0) - infrastructure/notification-trigger: - dependencies: - apn: - specifier: ^2.2.0 - version: 2.2.0 - cors: - specifier: ^2.8.5 - version: 2.8.6 - dotenv: - specifier: ^16.4.5 - version: 16.6.1 - express: - specifier: ^4.18.2 - version: 4.22.1 - firebase-admin: - specifier: ^12.0.0 - version: 12.7.0(encoding@0.1.13) - devDependencies: - '@types/cors': - specifier: ^2.8.18 - version: 2.8.19 - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 - '@types/node': - specifier: ^20.11.24 - version: 20.19.26 - tsx: - specifier: ^4.7.1 - version: 4.21.0 - typescript: - specifier: ^5.3.3 - version: 5.8.2 - infrastructure/signature-validator: dependencies: axios: @@ -733,6 +699,40 @@ importers: specifier: ^5.0.4 version: 5.8.2 + notification-trigger: + dependencies: + apn: + specifier: ^2.2.0 + version: 2.2.0 + cors: + specifier: ^2.8.5 + version: 2.8.6 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.22.1 + firebase-admin: + specifier: ^12.0.0 + version: 12.7.0(encoding@0.1.13) + devDependencies: + '@types/cors': + specifier: ^2.8.18 + version: 2.8.19 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^20.11.24 + version: 20.19.26 + tsx: + specifier: ^4.7.1 + version: 4.21.0 + typescript: + specifier: ^5.3.3 + version: 5.8.2 + packages/eslint-config: devDependencies: '@eslint/js': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0c7b2afb4..4f615292b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - platforms/*/* - infrastructure/* - services/* + - notification-trigger - tests/ - docs/ onlyBuiltDependencies: