Merge branch 'notify-api_endpoint-query_merge' into notify-api_endpoint-query
This commit is contained in:
@@ -1,9 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
#
|
||||||
# Husky Commit Message Hook
|
# Husky Commit Message Hook
|
||||||
# Validates commit message format using commitlint
|
# Validates commit message format using commitlint
|
||||||
#
|
#
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
# Run commitlint but don't fail the commit (|| true)
|
# Run commitlint but don't fail the commit (|| true)
|
||||||
# This provides helpful feedback without blocking commits
|
# This provides helpful feedback without blocking commits
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
#
|
||||||
# Husky Pre-commit Hook
|
# Husky Pre-commit Hook
|
||||||
# Runs lint-fix and Build Architecture Guard on staged files
|
# Runs lint-fix and Build Architecture Guard on staged files
|
||||||
#
|
#
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
echo "🔍 Running pre-commit hooks..."
|
echo "🔍 Running pre-commit hooks..."
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
#
|
||||||
# Husky Pre-push Hook
|
# Husky Pre-push Hook
|
||||||
# Runs Build Architecture Guard to check commits being pushed
|
# Runs Build Architecture Guard to check commits being pushed
|
||||||
#
|
#
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
echo "🔍 Running Build Architecture Guard (pre-push)..."
|
echo "🔍 Running Build Architecture Guard (pre-push)..."
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
|
## [?] - 2026
|
||||||
|
### Added
|
||||||
|
- Full flow for setting up SMS notifications: phone registration and code
|
||||||
|
verification, delegated alertSearch JWT batches with a send hour, and
|
||||||
|
revocation to stop the texts
|
||||||
|
- Push-channel client for the notify-api's alert authorization
|
||||||
|
(`authorizePushAlertSearch`, `revokePushAlertSearch`), built on the same batch
|
||||||
|
minter as the SMS channel
|
||||||
|
|
||||||
|
|
||||||
## [1.3.8] - 2026
|
## [1.3.8] - 2026
|
||||||
### Added
|
### Added
|
||||||
- Device wake-up for notifications
|
- Device wake-up for notifications
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ Assumes Xcode and Xcode Command Line Tools are installed.
|
|||||||
|
|
||||||
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).
|
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run test:web
|
||||||
|
|
||||||
|
# ... or do every check:
|
||||||
|
npm run test:all
|
||||||
|
```
|
||||||
|
|
||||||
## 🛡️ Build Architecture Guard
|
## 🛡️ Build Architecture Guard
|
||||||
|
|
||||||
This project uses **Husky Git hooks** to protect the build system
|
This project uses **Husky Git hooks** to protect the build system
|
||||||
|
|||||||
@@ -578,7 +578,7 @@ curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
|
|||||||
3. Confirm **Backend Status → URL** matches the saved ngrok host.
|
3. Confirm **Backend Status → URL** matches the saved ngrok host.
|
||||||
4. Enable **Test Mode** if using dev backend behavior.
|
4. Enable **Test Mode** if using dev backend behavior.
|
||||||
|
|
||||||
**Expected outcome:** **Active** URL in the panel equals your ngrok `https://…` host. Subsequent app requests use that base (not `DEFAULT_NOTIFY_API_SERVER`) for `/notifications/register` and `/notifications/refresh`.
|
**Expected outcome:** **Active** URL in the panel equals your ngrok `https://…` host. Subsequent app requests use that base (not the default `DEFAULT_NOTIFY_API_SERVER`) for `/notifications/register` and `/notifications/refresh`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ Before ngrok end-to-end testing, confirm:
|
|||||||
|
|
||||||
## 6. Configure the Notification Debug Panel backend override
|
## 6. Configure the Notification Debug Panel backend override
|
||||||
|
|
||||||
|
|
||||||
The app normally calls `DEFAULT_NOTIFY_API_SERVER` (from `VITE_DEFAULT_NOTIFY_API_SERVER`, falling back to `AppString.PROD_NOTIFY_API_SERVER`). That is independent of `APP_SERVER`. For local wakeup testing, override the notification API base URL in the Debug Panel without rebuilding.
|
The app normally calls `DEFAULT_NOTIFY_API_SERVER` (from `VITE_DEFAULT_NOTIFY_API_SERVER`, falling back to `AppString.PROD_NOTIFY_API_SERVER`). That is independent of `APP_SERVER`. For local wakeup testing, override the notification API base URL in the Debug Panel without rebuilding.
|
||||||
|
|
||||||
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
|
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# SMS Registration
|
||||||
|
|
||||||
|
All text providers now require 10DLC registration, which is a horrendous process. (I can refer you to others who have also found the process to be a nightmare. I just tried to look up docs on the official pages and found broken links... cool.)
|
||||||
|
|
||||||
|
The functionality here mirrors the server-push FCM functionality. We're taking this approach as well because A) iOS client-side notifications are unreliable, and B) some users prefer to get text messages.
|
||||||
|
|
||||||
|
## Details on iOS client-side problems
|
||||||
|
|
||||||
|
iOS in particular makes it impossible to guarantee that the user will get notifications,
|
||||||
|
even if we separate the data-fetch from the user-notify as designed in the daily-notification-plugin
|
||||||
|
|
||||||
|
You can see more details here: https://chatgpt.com/share/69e601ea-6434-8398-8d28-f1a3118f86ad
|
||||||
|
... which explains:
|
||||||
|
|
||||||
|
```
|
||||||
|
That implies one of these patterns:
|
||||||
|
|
||||||
|
- Polling (setInterval / timers / background fetch)
|
||||||
|
- Service worker / PWA background sync
|
||||||
|
- App wake-up logic (foreground or semi-background)
|
||||||
|
|
||||||
|
All three are fragile or outright blocked on iOS.
|
||||||
|
|
||||||
|
Unlike Android, iOS has these restrictions:
|
||||||
|
|
||||||
|
- No persistent timers when app is backgrounded
|
||||||
|
- No reliable background fetch at exact times
|
||||||
|
- No service worker push for non-installed PWAs (and even then, limited)
|
||||||
|
- No “wake up at X time and run JS”
|
||||||
|
```
|
||||||
Generated
+113
-1
@@ -153,7 +153,8 @@
|
|||||||
"ts-jest": "^29.4.0",
|
"ts-jest": "^29.4.0",
|
||||||
"tsx": "^4.20.4",
|
"tsx": "^4.20.4",
|
||||||
"typescript": "~5.2.2",
|
"typescript": "~5.2.2",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0",
|
||||||
|
"vue-tsc": "^2.1.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adraffy/ens-normalize": {
|
"node_modules/@adraffy/ens-normalize": {
|
||||||
@@ -11605,6 +11606,35 @@
|
|||||||
"vue": "^3.2.25"
|
"vue": "^3.2.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@volar/language-core": {
|
||||||
|
"version": "2.4.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
|
||||||
|
"integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@volar/source-map": "2.4.28"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@volar/source-map": {
|
||||||
|
"version": "2.4.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
|
||||||
|
"integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@volar/typescript": {
|
||||||
|
"version": "2.4.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
|
||||||
|
"integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@volar/language-core": "2.4.28",
|
||||||
|
"path-browserify": "^1.0.1",
|
||||||
|
"vscode-uri": "^3.0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vue-leaflet/vue-leaflet": {
|
"node_modules/@vue-leaflet/vue-leaflet": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -11663,6 +11693,17 @@
|
|||||||
"@vue/shared": "3.5.13"
|
"@vue/shared": "3.5.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vue/compiler-vue2": {
|
||||||
|
"version": "2.7.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
|
||||||
|
"integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"de-indent": "^1.0.2",
|
||||||
|
"he": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vue/devtools-api": {
|
"node_modules/@vue/devtools-api": {
|
||||||
"version": "6.6.4",
|
"version": "6.6.4",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -11890,6 +11931,31 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vue/language-core": {
|
||||||
|
"version": "2.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.1.10.tgz",
|
||||||
|
"integrity": "sha512-DAI289d0K3AB5TUG3xDp9OuQ71CnrujQwJrQnfuZDwo6eGNf0UoRlPuaVNO+Zrn65PC3j0oB2i7mNmVPggeGeQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@volar/language-core": "~2.4.8",
|
||||||
|
"@vue/compiler-dom": "^3.5.0",
|
||||||
|
"@vue/compiler-vue2": "^2.7.16",
|
||||||
|
"@vue/shared": "^3.5.0",
|
||||||
|
"alien-signals": "^0.2.0",
|
||||||
|
"minimatch": "^9.0.3",
|
||||||
|
"muggle-string": "^0.4.1",
|
||||||
|
"path-browserify": "^1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.13",
|
"version": "3.5.13",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -12155,6 +12221,13 @@
|
|||||||
"ajv": "^6.9.1"
|
"ajv": "^6.9.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/alien-signals": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-cZIRkbERILsBOXTQmMrxc9hgpxglstn69zm+F1ARf4aPAzdAFYd6sBq87ErO0Fj3DV94tglcyHG5kQz9nDC/8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/anser": {
|
"node_modules/anser": {
|
||||||
"version": "1.4.10",
|
"version": "1.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz",
|
||||||
@@ -15004,6 +15077,13 @@
|
|||||||
"version": "1.11.13",
|
"version": "1.11.13",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/de-indent": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.3.4",
|
"version": "4.3.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -23436,6 +23516,13 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/muggle-string": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/multibase": {
|
"node_modules/multibase": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.6",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -30292,6 +30379,13 @@
|
|||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/vscode-uri": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.13",
|
"version": "3.5.13",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -30433,6 +30527,24 @@
|
|||||||
"vue": "^3.2.0"
|
"vue": "^3.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-tsc": {
|
||||||
|
"version": "2.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.1.10.tgz",
|
||||||
|
"integrity": "sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@volar/typescript": "~2.4.8",
|
||||||
|
"@vue/language-core": "2.1.10",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vue-tsc": "bin/vue-tsc.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/walker": {
|
"node_modules/walker": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
|||||||
+3
-1
@@ -10,6 +10,7 @@
|
|||||||
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
||||||
"type-safety-check": "./scripts/type-safety-check.sh",
|
"type-safety-check": "./scripts/type-safety-check.sh",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
|
"type-check:vue": "vue-tsc --noEmit",
|
||||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
|
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
|
||||||
"test:prerequisites": "node scripts/check-prerequisites.js",
|
"test:prerequisites": "node scripts/check-prerequisites.js",
|
||||||
"check:dependencies": "./scripts/check-dependencies.sh",
|
"check:dependencies": "./scripts/check-dependencies.sh",
|
||||||
@@ -282,6 +283,7 @@
|
|||||||
"ts-jest": "^29.4.0",
|
"ts-jest": "^29.4.0",
|
||||||
"tsx": "^4.20.4",
|
"tsx": "^4.20.4",
|
||||||
"typescript": "~5.2.2",
|
"typescript": "~5.2.2",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0",
|
||||||
|
"vue-tsc": "^2.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<p :class="textClasses">{{ text }}</p>
|
<p :class="textClasses">{{ text }}</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-if="option1Text"
|
||||||
:class="option1ButtonClasses"
|
:class="option1ButtonClasses"
|
||||||
@click="handleOption1(close)"
|
@click="handleOption1(close)"
|
||||||
>
|
>
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-if="option2Text"
|
||||||
:class="option2ButtonClasses"
|
:class="option2ButtonClasses"
|
||||||
@click="handleOption2(close)"
|
@click="handleOption2(close)"
|
||||||
>
|
>
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-if="option3Text"
|
||||||
:class="option3ButtonClasses"
|
:class="option3ButtonClasses"
|
||||||
@click="handleOption3(close)"
|
@click="handleOption3(close)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -159,6 +159,13 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
pushType = "";
|
pushType = "";
|
||||||
/** When true, dialog only returns time/message to parent; parent does cancel+schedule (avoids double schedule on edit). */
|
/** When true, dialog only returns time/message to parent; parent does cancel+schedule (avoids double schedule on edit). */
|
||||||
skipScheduleForOpen = false;
|
skipScheduleForOpen = false;
|
||||||
|
/**
|
||||||
|
* When true, the dialog is a time picker and nothing else: no permission
|
||||||
|
* request, no web-push subscription, no settings write. Used by delivery
|
||||||
|
* channels that carry their own schedule -- SMS sends its hour to the
|
||||||
|
* notify-api rather than arming anything on this device.
|
||||||
|
*/
|
||||||
|
timeOnlyForOpen = false;
|
||||||
/** When set (e.g. 10), passed to plugin for dev/test fast rollover. */
|
/** When set (e.g. 10), passed to plugin for dev/test fast rollover. */
|
||||||
rolloverIntervalMinutesForSchedule: number | undefined = undefined;
|
rolloverIntervalMinutesForSchedule: number | undefined = undefined;
|
||||||
serviceWorkerReady = false;
|
serviceWorkerReady = false;
|
||||||
@@ -174,14 +181,27 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
async open(
|
async open(
|
||||||
pushType: string,
|
pushType: string,
|
||||||
callback?: (success: boolean, time: string, message?: string) => void,
|
callback?: (success: boolean, time: string, message?: string) => void,
|
||||||
options?: { skipSchedule?: boolean; rolloverIntervalMinutes?: number },
|
options?: {
|
||||||
|
skipSchedule?: boolean;
|
||||||
|
rolloverIntervalMinutes?: number;
|
||||||
|
timeOnly?: boolean;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
this.callback = callback || this.callback;
|
this.callback = callback || this.callback;
|
||||||
this.isVisible = true;
|
this.isVisible = true;
|
||||||
this.pushType = pushType;
|
this.pushType = pushType;
|
||||||
this.skipScheduleForOpen = options?.skipSchedule ?? false;
|
this.skipScheduleForOpen = options?.skipSchedule ?? false;
|
||||||
|
this.timeOnlyForOpen = options?.timeOnly ?? false;
|
||||||
this.rolloverIntervalMinutesForSchedule = options?.rolloverIntervalMinutes;
|
this.rolloverIntervalMinutesForSchedule = options?.rolloverIntervalMinutes;
|
||||||
|
|
||||||
|
// Time-only callers never subscribe to anything, so the web-push
|
||||||
|
// handshake would only be a way to fail before showing a clock.
|
||||||
|
if (this.timeOnlyForOpen) {
|
||||||
|
this.serviceWorkerReady = true;
|
||||||
|
this.messageInput = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Native platforms: Skip web push initialization
|
// Native platforms: Skip web push initialization
|
||||||
if (this.isNativePlatform) {
|
if (this.isNativePlatform) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -589,8 +609,8 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
* For native platforms, always returns true (no VAPID needed)
|
* For native platforms, always returns true (no VAPID needed)
|
||||||
*/
|
*/
|
||||||
get isSystemReady(): boolean {
|
get isSystemReady(): boolean {
|
||||||
if (this.isNativePlatform) {
|
if (this.isNativePlatform || this.timeOnlyForOpen) {
|
||||||
return true; // Native doesn't need VAPID/service worker
|
return true; // Neither needs VAPID/service worker
|
||||||
}
|
}
|
||||||
return this.serviceWorkerReady && !!this.vapidKey;
|
return this.serviceWorkerReady && !!this.vapidKey;
|
||||||
}
|
}
|
||||||
@@ -601,8 +621,8 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
* For native platforms, always returns true (no VAPID needed)
|
* For native platforms, always returns true (no VAPID needed)
|
||||||
*/
|
*/
|
||||||
get canShowNotificationForm(): boolean {
|
get canShowNotificationForm(): boolean {
|
||||||
if (this.isNativePlatform) {
|
if (this.isNativePlatform || this.timeOnlyForOpen) {
|
||||||
return true; // Native doesn't need VAPID/service worker
|
return true; // Neither needs VAPID/service worker
|
||||||
}
|
}
|
||||||
return this.serviceWorkerReady && !!this.vapidKey;
|
return this.serviceWorkerReady && !!this.vapidKey;
|
||||||
}
|
}
|
||||||
@@ -672,6 +692,12 @@ export default class PushNotificationPermission extends Vue {
|
|||||||
* Close only after async flow completes so success/error $notify runs while component is mounted (fixes Android).
|
* Close only after async flow completes so success/error $notify runs while component is mounted (fixes Android).
|
||||||
*/
|
*/
|
||||||
async handleTurnOnNotifications() {
|
async handleTurnOnNotifications() {
|
||||||
|
if (this.timeOnlyForOpen) {
|
||||||
|
// Nothing to arm on this device; the caller owns whatever the time means.
|
||||||
|
this.callback(true, this.notificationTimeText, this.messageInput);
|
||||||
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.isNativePlatform) {
|
if (this.isNativePlatform) {
|
||||||
await this.turnOnNativeNotifications();
|
await this.turnOnNativeNotifications();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<!-- Step 1: enter phone number -->
|
||||||
|
<div v-if="step === 'phone'">
|
||||||
|
<h1 class="text-xl font-bold text-center mb-2">Verify Your Phone</h1>
|
||||||
|
<p class="text-sm text-slate-500 mb-4">
|
||||||
|
Enter the mobile number where you want to receive New Activity text
|
||||||
|
messages. We'll send a one-time code to confirm it's yours. Standard
|
||||||
|
message and data rates may apply.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="sms-phone" class="text-slate-500 text-sm font-bold">
|
||||||
|
Mobile number
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="sms-phone"
|
||||||
|
v-model="phoneNumber"
|
||||||
|
type="tel"
|
||||||
|
inputmode="tel"
|
||||||
|
autocomplete="tel"
|
||||||
|
placeholder="+1 555 123 4567"
|
||||||
|
class="block w-full rounded border border-slate-400 mt-1 mb-1 px-3 py-2"
|
||||||
|
:disabled="sending"
|
||||||
|
@keyup.enter="sendCode()"
|
||||||
|
/>
|
||||||
|
<p v-if="errorMessage" class="text-sm text-red-600 mb-2">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="primaryButtonClasses"
|
||||||
|
:disabled="sending"
|
||||||
|
@click="sendCode()"
|
||||||
|
>
|
||||||
|
<font-awesome v-if="sending" icon="spinner" spin class="mr-1" />
|
||||||
|
{{ sending ? "Sending…" : "Send Code" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="cancelButtonClasses"
|
||||||
|
:disabled="sending"
|
||||||
|
@click="onClickCancel()"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: enter the code -->
|
||||||
|
<div v-else-if="step === 'code'">
|
||||||
|
<h1 class="text-xl font-bold text-center mb-2">Enter the Code</h1>
|
||||||
|
<p class="text-sm text-slate-500 mb-4">
|
||||||
|
We sent a 6-digit code to
|
||||||
|
<b>{{ sentTo }}</b
|
||||||
|
>. Enter it below to finish verifying your number.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label for="sms-code" class="text-slate-500 text-sm font-bold">
|
||||||
|
Verification code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="sms-code"
|
||||||
|
v-model="code"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
maxlength="6"
|
||||||
|
placeholder="123456"
|
||||||
|
class="block w-full tracking-[0.4em] text-center text-lg rounded border border-slate-400 mt-1 mb-1 px-3 py-2"
|
||||||
|
:disabled="verifying"
|
||||||
|
@keyup.enter="verifyCode()"
|
||||||
|
/>
|
||||||
|
<p v-if="errorMessage" class="text-sm text-red-600 mb-2">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="text-center mb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sm text-blue-500"
|
||||||
|
:disabled="sending || verifying"
|
||||||
|
@click="resendCode()"
|
||||||
|
>
|
||||||
|
{{ sending ? "Resending…" : "Resend code" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="primaryButtonClasses"
|
||||||
|
:disabled="verifying"
|
||||||
|
@click="verifyCode()"
|
||||||
|
>
|
||||||
|
<font-awesome v-if="verifying" icon="spinner" spin class="mr-1" />
|
||||||
|
{{ verifying ? "Verifying…" : "Verify" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="cancelButtonClasses"
|
||||||
|
:disabled="verifying"
|
||||||
|
@click="onClickCancel()"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Vue, Component } from "vue-facing-decorator";
|
||||||
|
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import { logger } from "@/utils/logger";
|
||||||
|
import {
|
||||||
|
normalizePhoneNumber,
|
||||||
|
registerSmsPhone,
|
||||||
|
SmsApiError,
|
||||||
|
smsErrorMessage,
|
||||||
|
verifySmsPhone,
|
||||||
|
} from "@/services/notifications/smsNotificationApi";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SmsVerificationDialog Component
|
||||||
|
*
|
||||||
|
* A two-step modal that walks the user through verifying a mobile number so
|
||||||
|
* they can opt in to SMS "New Activity" notifications:
|
||||||
|
* 1. Enter phone number -> `POST /notify-sms/phone` texts a one-time code
|
||||||
|
* 2. Enter the code -> `PUT /notify-sms/phone` confirms possession
|
||||||
|
*
|
||||||
|
* On successful verification the callback fires with (true, phoneNumber), the
|
||||||
|
* number in the E.164 form the service stores, so the caller can then let the
|
||||||
|
* user turn the SMS notification on. Cancelling fires the callback with
|
||||||
|
* (false).
|
||||||
|
*
|
||||||
|
* A number this identity has already verified comes back from the POST with
|
||||||
|
* `verified: true` and no text sent, which finishes the dialog in one step.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
|
})
|
||||||
|
export default class SmsVerificationDialog extends Vue {
|
||||||
|
visible = false;
|
||||||
|
step: "phone" | "code" = "phone";
|
||||||
|
phoneNumber = "";
|
||||||
|
sentTo = "";
|
||||||
|
code = "";
|
||||||
|
sending = false;
|
||||||
|
verifying = false;
|
||||||
|
errorMessage = "";
|
||||||
|
|
||||||
|
/** Identity the registration is scoped to; every route is per-DID. */
|
||||||
|
activeDid = "";
|
||||||
|
|
||||||
|
callback: (success: boolean, phoneNumber?: string) => void = () => {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the dialog at the phone-entry step.
|
||||||
|
* @param activeDid - identity the number is registered under
|
||||||
|
* @param aCallback - fired with (true, phoneNumber) on success, (false) on cancel
|
||||||
|
* @param prefillPhone - optional number to pre-populate (e.g. re-verifying)
|
||||||
|
*/
|
||||||
|
open(
|
||||||
|
activeDid: string,
|
||||||
|
aCallback?: (success: boolean, phoneNumber?: string) => void,
|
||||||
|
prefillPhone = "",
|
||||||
|
) {
|
||||||
|
this.activeDid = activeDid;
|
||||||
|
this.callback = aCallback || this.callback;
|
||||||
|
this.step = "phone";
|
||||||
|
this.phoneNumber = prefillPhone;
|
||||||
|
this.sentTo = "";
|
||||||
|
this.code = "";
|
||||||
|
this.sending = false;
|
||||||
|
this.verifying = false;
|
||||||
|
this.errorMessage = "";
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the phone number and ask the service to text a code.
|
||||||
|
*
|
||||||
|
* The masked number the service echoes back is what the user sees; the
|
||||||
|
* normalized one is what later calls send, since the mask is not a number.
|
||||||
|
*/
|
||||||
|
async sendCode(): Promise<void> {
|
||||||
|
this.errorMessage = "";
|
||||||
|
const normalized = normalizePhoneNumber(this.phoneNumber);
|
||||||
|
if (!normalized || normalized.replace(/\D/g, "").length < 7) {
|
||||||
|
this.errorMessage = "Please enter a valid mobile number.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sending = true;
|
||||||
|
try {
|
||||||
|
const result = await registerSmsPhone(this.activeDid, normalized);
|
||||||
|
this.phoneNumber = normalized;
|
||||||
|
|
||||||
|
if (result.verified) {
|
||||||
|
// Already verified under this identity: nothing was texted and there
|
||||||
|
// is no code to enter.
|
||||||
|
this.visible = false;
|
||||||
|
this.callback(true, normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sentTo = result.phoneNumber || normalized;
|
||||||
|
// Only a dev server with SMS_DEV_ECHO_CODE returns this; it saves a
|
||||||
|
// developer with no carrier coverage from a dead-end flow.
|
||||||
|
this.code = result.devCode || "";
|
||||||
|
this.step = "code";
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[SmsVerificationDialog] sendCode failed:", error);
|
||||||
|
this.errorMessage = smsErrorMessage(
|
||||||
|
error,
|
||||||
|
"Could not send the code. Please try again.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the service to send a fresh code to the same number. Sends are
|
||||||
|
* throttled to three per number per hour, so this can be refused.
|
||||||
|
*/
|
||||||
|
async resendCode(): Promise<void> {
|
||||||
|
this.errorMessage = "";
|
||||||
|
this.sending = true;
|
||||||
|
try {
|
||||||
|
const result = await registerSmsPhone(this.activeDid, this.phoneNumber);
|
||||||
|
this.sentTo = result.phoneNumber || this.sentTo;
|
||||||
|
// A number already verified comes back with no code, dev or otherwise.
|
||||||
|
this.code = result.verified ? "" : result.devCode || "";
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[SmsVerificationDialog] resendCode failed:", error);
|
||||||
|
this.errorMessage = smsErrorMessage(
|
||||||
|
error,
|
||||||
|
"Could not resend the code. Please try again.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the entered code with the service.
|
||||||
|
*
|
||||||
|
* A wrong code says how many tries are left; running out clears the code
|
||||||
|
* server-side, so the only way forward is a fresh send from step one.
|
||||||
|
*/
|
||||||
|
async verifyCode(): Promise<void> {
|
||||||
|
this.errorMessage = "";
|
||||||
|
if (!/^\d{6}$/.test(this.code.trim())) {
|
||||||
|
this.errorMessage = "Enter the 6-digit code we texted you.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.verifying = true;
|
||||||
|
try {
|
||||||
|
await verifySmsPhone(this.activeDid, this.phoneNumber, this.code);
|
||||||
|
this.visible = false;
|
||||||
|
this.callback(true, this.phoneNumber);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[SmsVerificationDialog] verifyCode failed:", error);
|
||||||
|
this.errorMessage = smsErrorMessage(
|
||||||
|
error,
|
||||||
|
"Could not verify the code. Please try again.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
error instanceof SmsApiError &&
|
||||||
|
error.code === "SMS_CODE_ATTEMPTS_EXHAUSTED"
|
||||||
|
) {
|
||||||
|
// The stored code is gone; recovery is another send, not another guess.
|
||||||
|
this.step = "phone";
|
||||||
|
this.code = "";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.verifying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickCancel(): void {
|
||||||
|
this.visible = false;
|
||||||
|
this.callback(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
get primaryButtonClasses(): string {
|
||||||
|
return "block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md disabled:opacity-50";
|
||||||
|
}
|
||||||
|
|
||||||
|
get cancelButtonClasses(): string {
|
||||||
|
return "block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md disabled:opacity-50";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -535,16 +535,10 @@ function formatRealWakeupStatusMessage(
|
|||||||
>,
|
>,
|
||||||
): string {
|
): string {
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const body =
|
|
||||||
typeof result.responseBody === "object" && result.responseBody !== null
|
|
||||||
? (result.responseBody as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const parts = ["Real WAKEUP_PING sent via backend."];
|
const parts = ["Real WAKEUP_PING sent via backend."];
|
||||||
if (typeof body?.message === "string" && body.message.trim()) {
|
const suffix = result.responseBody?.fcmTokenSuffix?.trim();
|
||||||
parts.push(body.message.trim());
|
if (suffix) {
|
||||||
}
|
parts.push(`token …${suffix}`);
|
||||||
if (typeof body?.tokenSuffix === "string" && body.tokenSuffix.trim()) {
|
|
||||||
parts.push(`token …${body.tokenSuffix.trim()}`);
|
|
||||||
}
|
}
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export const ACCOUNT_VIEW_CONSTANTS = {
|
|||||||
NO_PROFILE_LOCATION: "No profile location is saved.",
|
NO_PROFILE_LOCATION: "No profile location is saved.",
|
||||||
RELOAD_VAPID:
|
RELOAD_VAPID:
|
||||||
"Now reload the app to get a new VAPID to use with this push server.",
|
"Now reload the app to get a new VAPID to use with this push server.",
|
||||||
|
NOTIFY_SERVER_INFO:
|
||||||
|
"The notify server URL can be modified on the Notification Debug screen.",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Warning messages
|
// Warning messages
|
||||||
@@ -57,11 +59,20 @@ export const ACCOUNT_VIEW_CONSTANTS = {
|
|||||||
// Notification messages
|
// Notification messages
|
||||||
NOTIFICATIONS: {
|
NOTIFICATIONS: {
|
||||||
NEW_ACTIVITY_INFO: `
|
NEW_ACTIVITY_INFO: `
|
||||||
This will only notify you when there is new relevant activity for you personally.
|
This will notify you with an app message when there is new relevant activity for you personally.
|
||||||
Note that it runs on your device and many factors may affect delivery,
|
|
||||||
so if you want a reliable but simple daily notification then choose a 'Reminder'.
|
|
||||||
Do you want more details?
|
Do you want more details?
|
||||||
`,
|
`,
|
||||||
|
NEW_ACTIVITY_SMS_INFO: `
|
||||||
|
This is opt-in: by turning it on you agree to receive text messages when there is new relevant activity for you personally.
|
||||||
|
Standard message and data rates may apply, and you can turn it off at any time.
|
||||||
|
Do you want more details?
|
||||||
|
`,
|
||||||
|
SMS_OPT_IN_CHOICE: `
|
||||||
|
This is opt-in: by entering your phone number you agree to receive text messages when there is new relevant activity for you personally.
|
||||||
|
Standard message and data rates may apply, and you can turn it off at any time.
|
||||||
|
`,
|
||||||
|
SMS_FORGET_PHONE_CONFIRM:
|
||||||
|
"This will remove your phone number and turn off all text messages. You will have to verify a number again to re-enable them. Are you sure?",
|
||||||
REMINDER_INFO: `
|
REMINDER_INFO: `
|
||||||
This will notify you at a specific time each day.
|
This will notify you at a specific time each day.
|
||||||
Note that it does not give you personalized notifications,
|
Note that it does not give you personalized notifications,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export enum AppString {
|
|||||||
|
|
||||||
PROD_NOTIFY_API_SERVER = "https://notify-api.timesafari.app",
|
PROD_NOTIFY_API_SERVER = "https://notify-api.timesafari.app",
|
||||||
TEST_NOTIFY_API_SERVER = "https://test-notify-api.timesafari.app",
|
TEST_NOTIFY_API_SERVER = "https://test-notify-api.timesafari.app",
|
||||||
|
LOCAL_NOTIFY_API_SERVER = "http://127.0.0.1:3003",
|
||||||
|
|
||||||
NO_CONTACT_NAME = "(no name)",
|
NO_CONTACT_NAME = "(no name)",
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,7 @@ export const DEFAULT_PARTNER_API_SERVER =
|
|||||||
export const DEFAULT_PUSH_SERVER =
|
export const DEFAULT_PUSH_SERVER =
|
||||||
import.meta.env.VITE_DEFAULT_PUSH_SERVER || AppString.PROD_PUSH_SERVER;
|
import.meta.env.VITE_DEFAULT_PUSH_SERVER || AppString.PROD_PUSH_SERVER;
|
||||||
|
|
||||||
|
/** Base URL of the notify-api (FCM wakeup registration and SMS notifications). */
|
||||||
export const DEFAULT_NOTIFY_API_SERVER =
|
export const DEFAULT_NOTIFY_API_SERVER =
|
||||||
import.meta.env.VITE_DEFAULT_NOTIFY_API_SERVER ||
|
import.meta.env.VITE_DEFAULT_NOTIFY_API_SERVER ||
|
||||||
AppString.PROD_NOTIFY_API_SERVER;
|
AppString.PROD_NOTIFY_API_SERVER;
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
/**
|
|
||||||
* Delegated authorization JWTs for notification-wakeup-service via notify-api.
|
|
||||||
* Distinct from the native background prefetch pool in backgroundJwt.ts.
|
|
||||||
*/
|
|
||||||
export const DELEGATED_NOTIFICATION_JWT_COUNT = 100;
|
|
||||||
@@ -276,6 +276,17 @@ const MIGRATIONS = [
|
|||||||
ALTER TABLE settings ADD COLUMN reminderFastRolloverForTesting BOOLEAN DEFAULT FALSE;
|
ALTER TABLE settings ADD COLUMN reminderFastRolloverForTesting BOOLEAN DEFAULT FALSE;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "010_add_sms_notification_settings",
|
||||||
|
sql: `
|
||||||
|
-- Verified mobile number this identity registered with the notify-api
|
||||||
|
ALTER TABLE settings ADD COLUMN notifyingNewActivitySmsPhone TEXT;
|
||||||
|
-- Local time of day the notify-api was told to text, blank when off
|
||||||
|
ALTER TABLE settings ADD COLUMN notifyingNewActivitySmsTime TEXT;
|
||||||
|
-- Master switch for the SMS channel; a paused channel keeps its number
|
||||||
|
ALTER TABLE settings ADD COLUMN smsNotificationsEnabled BOOLEAN DEFAULT FALSE;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,11 +50,15 @@ export type Settings = {
|
|||||||
lastViewedClaimId?: string;
|
lastViewedClaimId?: string;
|
||||||
|
|
||||||
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
|
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
|
||||||
|
notifyingNewActivitySmsPhone?: string; // verified mobile number registered with the notify-api for text alerts
|
||||||
|
notifyingNewActivitySmsTime?: string; // set to their chosen time if they have turned on new-activity texts
|
||||||
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
|
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
|
||||||
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
|
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
|
||||||
/** Dev/test only: use 10-minute rollover interval for daily reminder (plugin rolloverIntervalMinutes) */
|
/** Dev/test only: use 10-minute rollover interval for daily reminder (plugin rolloverIntervalMinutes) */
|
||||||
reminderFastRolloverForTesting?: boolean;
|
reminderFastRolloverForTesting?: boolean;
|
||||||
|
|
||||||
|
smsNotificationsEnabled?: boolean; // master switch for the text-message channel
|
||||||
|
|
||||||
partnerApiServer?: string; // partner server API URL
|
partnerApiServer?: string; // partner server API URL
|
||||||
|
|
||||||
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
|
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export interface AccountSettings {
|
|||||||
bbox: BoundingBox;
|
bbox: BoundingBox;
|
||||||
}>;
|
}>;
|
||||||
notifyingNewActivityTime?: string;
|
notifyingNewActivityTime?: string;
|
||||||
|
notifyingNewActivitySmsPhone?: string;
|
||||||
|
notifyingNewActivitySmsTime?: string;
|
||||||
|
smsNotificationsEnabled?: boolean;
|
||||||
notifyingReminderMessage?: string;
|
notifyingReminderMessage?: string;
|
||||||
notifyingReminderTime?: string;
|
notifyingReminderTime?: string;
|
||||||
starredPlanHandleIds?: string[];
|
starredPlanHandleIds?: string[];
|
||||||
@@ -80,6 +83,12 @@ export interface ProfileState {
|
|||||||
export interface NotificationState {
|
export interface NotificationState {
|
||||||
notifyingNewActivity: boolean;
|
notifyingNewActivity: boolean;
|
||||||
notifyingNewActivityTime: string;
|
notifyingNewActivityTime: string;
|
||||||
|
/** Master switch for the text-message channel. */
|
||||||
|
smsEnabled: boolean;
|
||||||
|
/** Verified number, kept while the channel is paused so re-enabling is free. */
|
||||||
|
notifyingNewActivitySmsPhone: string;
|
||||||
|
notifyingNewActivitySms: boolean;
|
||||||
|
notifyingNewActivitySmsTime: string;
|
||||||
notifyingReminder: boolean;
|
notifyingReminder: boolean;
|
||||||
notifyingReminderMessage: string;
|
notifyingReminderMessage: string;
|
||||||
notifyingReminderTime: string;
|
notifyingReminderTime: string;
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* Batch of per-UTC-day delegated JWTs for notify-api / wakeup-service.
|
|
||||||
*
|
|
||||||
* Not authentication JWTs, not alertSearch cursor ULIDs, and not the native
|
|
||||||
* background prefetch pool (`mintBackgroundJwtTokenPool`).
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface DelegatedNotificationJwtWindow {
|
|
||||||
/** 1-based; 1 is the UTC calendar day of `now`. */
|
|
||||||
sequence: number;
|
|
||||||
/** Calendar date of this slot, YYYY-MM-DD in UTC. */
|
|
||||||
utcDay: string;
|
|
||||||
/** Unix seconds at 00:00:00Z of this UTC day. */
|
|
||||||
nbf: number;
|
|
||||||
/** Unix seconds at 00:00:00Z of the following UTC day. */
|
|
||||||
exp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DelegatedNotificationJwtSlot
|
|
||||||
extends DelegatedNotificationJwtWindow {
|
|
||||||
jwt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DelegatedNotificationJwtBatch {
|
|
||||||
did: string;
|
|
||||||
timeZone: string;
|
|
||||||
mintedAtEpoch: number;
|
|
||||||
tokens: DelegatedNotificationJwtSlot[];
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
export * from "./alertSearch";
|
export * from "./alertSearch";
|
||||||
export * from "./claims";
|
export * from "./claims";
|
||||||
export * from "./delegatedNotificationJwt";
|
|
||||||
export * from "./claims-result";
|
export * from "./claims-result";
|
||||||
export * from "./common";
|
export * from "./common";
|
||||||
export * from "./deepLinks";
|
export * from "./deepLinks";
|
||||||
export * from "./limits";
|
export * from "./limits";
|
||||||
|
export * from "./notifyApi";
|
||||||
export * from "./records";
|
export * from "./records";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
/**
|
||||||
|
* Wire payloads for the notify-api (notification-wakeup-service): request
|
||||||
|
* bodies, query parameters, the JWT payloads the app signs for it, and the
|
||||||
|
* bodies it answers with.
|
||||||
|
*
|
||||||
|
* Each type names the route it belongs to and holds the fields that route reads
|
||||||
|
* or writes; a field the app sends and the service ignores is marked "Not read
|
||||||
|
* by the service." The service's code is the source of truth, in
|
||||||
|
* notification-wakeup-service: `src/routes/notifications.ts`,
|
||||||
|
* `src/routes/notifySms.ts`, `src/routes/debug.ts`, `src/middleware/`, and
|
||||||
|
* `src/services/alertAuthorization.ts`.
|
||||||
|
*
|
||||||
|
* Two kinds of JWT travel to the service:
|
||||||
|
* - The Bearer token on each request authenticates the caller, and the service
|
||||||
|
* takes the DID from it; no body carries the DID. On `/notify-sms` that token
|
||||||
|
* also carries an {@link SmsNotificationActionClaim}.
|
||||||
|
* - The delegated JWTs inside an {@link AlertAuthorizationRequestBody} are
|
||||||
|
* stored credentials the service later presents to Endorser and Partner.
|
||||||
|
* They authenticate nothing about the upload that carries them.
|
||||||
|
*
|
||||||
|
* Refusals come in these shapes, by where they are raised:
|
||||||
|
* - {@link NotifyApiUncodedFailure}: a message and no code, from the auth
|
||||||
|
* stages in front of every authenticated route, and from a push
|
||||||
|
* alert-authorization route that fails to store or delete.
|
||||||
|
* - {@link NotificationDeviceRouteFailure}: a sentence under `error`, from
|
||||||
|
* register and refresh.
|
||||||
|
* - {@link DebugSendWakeupResponse} with `success: false`.
|
||||||
|
* - {@link AlertAuthorizationBatchFailure} and {@link NotifySmsFailure}: coded.
|
||||||
|
*
|
||||||
|
* A proxy in front of the service can answer with none of these, or with no
|
||||||
|
* JSON at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refusal with a message and no code. The auth stages send it with 401 for a
|
||||||
|
* missing, invalid, or Endorser-rejected Bearer token and 503 when Endorser
|
||||||
|
* cannot be reached; their messages end with a timestamp for finding the server
|
||||||
|
* log. The push alert-authorization routes send it with 500 when storing or
|
||||||
|
* deleting fails.
|
||||||
|
*/
|
||||||
|
export interface NotifyApiUncodedFailure {
|
||||||
|
success: false;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Device routes: /notifications/register, /notifications/refresh, /debug
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local-test switch on the device routes. `true` with no `Authorization`
|
||||||
|
* header makes the service skip JWT and Endorser checks and file the request
|
||||||
|
* under a synthetic test user. A request carrying a Bearer token is
|
||||||
|
* authenticated normally whatever this says, and no other route reads it.
|
||||||
|
*/
|
||||||
|
export interface NotifyApiTestModeFlag {
|
||||||
|
testMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of `POST /notifications/register`: store this device's FCM token under
|
||||||
|
* the caller's DID, keyed by `deviceId`. Success is a bare 200 with a
|
||||||
|
* plain-text body; a failure to store is a bare 500.
|
||||||
|
*/
|
||||||
|
export interface NotificationRegisterRequest extends NotifyApiTestModeFlag {
|
||||||
|
/** Stable per-install id; trimmed by the service and required non-empty. */
|
||||||
|
deviceId: string;
|
||||||
|
/** Required non-empty. */
|
||||||
|
fcmToken: string;
|
||||||
|
/** `Capacitor.getPlatform()`: `"ios"`, `"android"`, or `"web"`. Required non-empty. */
|
||||||
|
platform: string;
|
||||||
|
/** The DID comes from the Bearer token; a body with a `userId` key is rejected. */
|
||||||
|
userId?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of `POST /notifications/refresh`. The service finds the caller's device
|
||||||
|
* by `deviceId`, or by `fcmToken` when no `deviceId` is sent, and answers 400
|
||||||
|
* when neither is present and non-empty. When both are sent they must name the
|
||||||
|
* same device, or the answer is 404.
|
||||||
|
*/
|
||||||
|
export type NotificationRefreshRequest = NotifyApiTestModeFlag & {
|
||||||
|
/** Not read by the service. */
|
||||||
|
platform?: string;
|
||||||
|
} & (
|
||||||
|
| { deviceId: string; fcmToken?: string }
|
||||||
|
| { deviceId?: string; fcmToken: string }
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Success body of `POST /notifications/refresh`. */
|
||||||
|
export interface NotificationRefreshResponse {
|
||||||
|
shouldNotify: boolean;
|
||||||
|
/** Instants for the device to schedule, as Unix milliseconds. */
|
||||||
|
nextNotifications: Array<{ timestamp: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refusal from `POST /notifications/register` (400) or
|
||||||
|
* `POST /notifications/refresh` (400, or 404 when no device of the caller's
|
||||||
|
* matches). `error` is a sentence such as `"Device not found"`, not a code.
|
||||||
|
*/
|
||||||
|
export interface NotificationDeviceRouteFailure {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of `POST /debug/send-wakeup`, which pushes a WAKEUP_PING to one of the
|
||||||
|
* caller's devices. The `/debug` routes exist only on a service started with
|
||||||
|
* `DEBUG_ENDPOINT` on.
|
||||||
|
*/
|
||||||
|
export interface DebugSendWakeupRequest extends NotifyApiTestModeFlag {
|
||||||
|
/** A token registered under the caller's DID; required non-empty. */
|
||||||
|
fcmToken: string;
|
||||||
|
/** Not read by the service. */
|
||||||
|
deviceId?: string;
|
||||||
|
/** Not read by the service. */
|
||||||
|
platform?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every answer `POST /debug/send-wakeup` writes itself. A 200 still carries
|
||||||
|
* `success: false` when the push was skipped or failed, so `success`, not the
|
||||||
|
* status, says whether a WAKEUP_PING went out.
|
||||||
|
*/
|
||||||
|
export interface DebugSendWakeupResponse {
|
||||||
|
success: boolean;
|
||||||
|
/**
|
||||||
|
* Why no push went out: `"fcmToken is required"` (400), `"Device not found"`
|
||||||
|
* (404), or with a 200, `"Device was notified within the eligibility
|
||||||
|
* threshold"` or `"FCM send failed"`.
|
||||||
|
*/
|
||||||
|
failureReason?: string;
|
||||||
|
/** Last six characters of the token; absent on the 400. */
|
||||||
|
fcmTokenSuffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Alert authorization: /notifications/alert-authorization and
|
||||||
|
// /notify-sms/alert-authorization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload the app signs for one {@link DelegatedAlertJwt}; the signer adds
|
||||||
|
* `iss` (the DID) and `iat`. The service requires the signed `nbf` and `exp` to
|
||||||
|
* equal the values listed beside the JWT.
|
||||||
|
*/
|
||||||
|
export interface DelegatedAlertJwtPayload {
|
||||||
|
/** Unix seconds, at or before the midnight UTC that opens the entry's `day`. */
|
||||||
|
nbf: number;
|
||||||
|
/** Unix seconds, at or after the midnight UTC that closes the entry's `day`. */
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One day's delegated credential inside an {@link AlertAuthorizationRequestBody}. */
|
||||||
|
export interface DelegatedAlertJwt {
|
||||||
|
/** Integer; the batch's values are consecutive, starting anywhere. */
|
||||||
|
sequence: number;
|
||||||
|
/** UTC calendar day, `YYYY-MM-DD`, distinct across the batch. */
|
||||||
|
day: string;
|
||||||
|
/** Unix seconds; equal to the signed `nbf`. */
|
||||||
|
nbf: number;
|
||||||
|
/** Unix seconds; equal to the signed `exp`. */
|
||||||
|
exp: number;
|
||||||
|
/** Signed by the caller's DID, which must be a `did:ethr`. */
|
||||||
|
jwt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of `PUT /notifications/alert-authorization` (push) and
|
||||||
|
* `POST /notify-sms/alert-authorization` (SMS), which validate it with one
|
||||||
|
* rule. A stored batch replaces the unused inventory the DID holds in that
|
||||||
|
* channel; JWTs already spent stay behind.
|
||||||
|
*/
|
||||||
|
export interface AlertAuthorizationRequestBody {
|
||||||
|
/** Required non-empty; echoed in the response. */
|
||||||
|
batchId: string;
|
||||||
|
/** UTC hour, integer 0-23. */
|
||||||
|
notifyHourUtc: number;
|
||||||
|
/** UTC minute, integer 0-59. */
|
||||||
|
notifyMinuteUtc: number;
|
||||||
|
/**
|
||||||
|
* IANA zone name such as `"America/Denver"`. Validated and stored; no
|
||||||
|
* scheduling decision reads it.
|
||||||
|
*/
|
||||||
|
timezone?: string;
|
||||||
|
/** Exactly 100 entries. */
|
||||||
|
jwts: DelegatedAlertJwt[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success body of storing a batch, on either alert-authorization route. */
|
||||||
|
export interface AlertAuthorizationResponse {
|
||||||
|
success: true;
|
||||||
|
batchId: string;
|
||||||
|
/** The stored UTC hour; null only for a batch stored without one. */
|
||||||
|
notifyHourUtc: number | null;
|
||||||
|
/** The stored UTC minute; null only for a batch stored without one. */
|
||||||
|
notifyMinuteUtc: number | null;
|
||||||
|
timezone: string | null;
|
||||||
|
/** JWTs stored from this batch. */
|
||||||
|
storedCount: number;
|
||||||
|
/** Unused JWTs the DID holds in this channel once the batch is stored. */
|
||||||
|
unusedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success body of `DELETE` on either alert-authorization route. */
|
||||||
|
export interface AlertAuthorizationRevokeResponse {
|
||||||
|
success: true;
|
||||||
|
/** Batches removed, spent ones included. */
|
||||||
|
deletedBatches: number;
|
||||||
|
/** JWTs removed, spent ones included. */
|
||||||
|
deletedJwts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coded refusal of a batch (400) on either alert-authorization route: it failed
|
||||||
|
* validation, or the caller's identity cannot hold one. Nothing was stored.
|
||||||
|
*/
|
||||||
|
export interface AlertAuthorizationBatchFailure {
|
||||||
|
success: false;
|
||||||
|
error:
|
||||||
|
| "ALERT_AUTHORIZATION_INVALID_BATCH"
|
||||||
|
| "DELEGATED_JWT_UNSUPPORTED_IDENTITY";
|
||||||
|
message: string;
|
||||||
|
/** Specific problems, at most 20 plus a count of the rest. */
|
||||||
|
details: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refusal from `PUT` or `DELETE /notifications/alert-authorization`. */
|
||||||
|
export type AlertAuthorizationFailure =
|
||||||
|
| AlertAuthorizationBatchFailure
|
||||||
|
| NotifyApiUncodedFailure;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SMS channel: /notify-sms
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a `/notify-sms` action claim authorizes. The service checks `action`
|
||||||
|
* against the route and, for handset actions, checks that `phoneNumber`
|
||||||
|
* normalizes to the same E.164 number the request names.
|
||||||
|
*/
|
||||||
|
export type SmsActionTarget =
|
||||||
|
// Bound to one handset; the number must match the request's.
|
||||||
|
| {
|
||||||
|
action: "register-phone" | "verify-phone" | "delete-phone";
|
||||||
|
phoneNumber: string;
|
||||||
|
}
|
||||||
|
// Bound to a number only when the request sends `?phoneNumber=`.
|
||||||
|
| { action: "list-phones"; phoneNumber?: string }
|
||||||
|
// Acts on the DID's whole alert authorization; the service reads no number.
|
||||||
|
| {
|
||||||
|
action: "authorize-alert-search" | "revoke-alert-search";
|
||||||
|
phoneNumber?: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Actions the service's `requireSmsActionJwt` stage recognizes, one per route. */
|
||||||
|
export type SmsAction = SmsActionTarget["action"];
|
||||||
|
|
||||||
|
/** The `claim` in the Bearer JWT on every `/notify-sms` request. */
|
||||||
|
export type SmsNotificationActionClaim = {
|
||||||
|
/** Claim namespace; distinct from `https://giftopia.me`, the link in the texts. */
|
||||||
|
"@context": "https://giftopia.tech";
|
||||||
|
"@type": "SmsNotificationAction";
|
||||||
|
} & SmsActionTarget;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload the app signs as the Bearer JWT on every `/notify-sms` request; the
|
||||||
|
* signer adds `iss`, `iat`, and `exp`. The service refuses a token whose `iat`
|
||||||
|
* is further than `SMS_ACTION_JWT_MAX_AGE_SEC` from its clock or whose `exp`
|
||||||
|
* has passed, and accepts each token once.
|
||||||
|
*/
|
||||||
|
export interface SmsActionJwtPayload {
|
||||||
|
claim: SmsNotificationActionClaim;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query of `GET /notify-sms/phone`. With `phoneNumber`, the response adds the
|
||||||
|
* DIDs verified on that number, which the service reveals only to a DID that
|
||||||
|
* has verified it too.
|
||||||
|
*/
|
||||||
|
export interface SmsPhoneListQuery {
|
||||||
|
phoneNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of the caller's registrations, as `GET /notify-sms/phone` lists it. */
|
||||||
|
export interface SmsPhoneRegistration {
|
||||||
|
/** The full E.164 number; these are the caller's own. */
|
||||||
|
phoneNumber: string;
|
||||||
|
verified: boolean;
|
||||||
|
/** ISO 8601; null until verified. */
|
||||||
|
verifiedAt: string | null;
|
||||||
|
/** ISO 8601. */
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success body of `GET /notify-sms/phone`, oldest registration first. An
|
||||||
|
* identity with no registrations gets an empty list.
|
||||||
|
*/
|
||||||
|
export interface SmsPhoneListResponse {
|
||||||
|
success: true;
|
||||||
|
phones: SmsPhoneRegistration[];
|
||||||
|
/** The queried number, normalized; present exactly when `?phoneNumber=` was sent. */
|
||||||
|
phoneNumber?: string;
|
||||||
|
/**
|
||||||
|
* Every DID verified on the queried number, the caller's included; present
|
||||||
|
* exactly when `?phoneNumber=` was sent.
|
||||||
|
*/
|
||||||
|
dids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body of `POST /notify-sms/phone`: record the number, unverified, and text it a code. */
|
||||||
|
export interface SmsPhoneRegisterRequest {
|
||||||
|
/** E.164; the service normalizes, taking ten bare digits as a US number. */
|
||||||
|
phoneNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success body of `POST /notify-sms/phone`. `phoneNumber` is masked, such as
|
||||||
|
* `+1555*****23`, so it cannot stand in for the number that was sent.
|
||||||
|
*/
|
||||||
|
export type SmsPhoneRegisterResponse =
|
||||||
|
// The caller had already verified this number; no text was sent.
|
||||||
|
| { success: true; phoneNumber: string; verified: true }
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
phoneNumber: string;
|
||||||
|
verified: false;
|
||||||
|
/** ISO 8601; when the texted code stops working. */
|
||||||
|
expiresAt: string;
|
||||||
|
/**
|
||||||
|
* The plaintext code, only from a service run with both
|
||||||
|
* `NODE_ENV=test-local` and `SMS_DEV_ECHO_CODE`.
|
||||||
|
*/
|
||||||
|
devCode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Body of `PUT /notify-sms/phone`: match the texted code. */
|
||||||
|
export interface SmsPhoneVerifyRequest {
|
||||||
|
phoneNumber: string;
|
||||||
|
/** The six digits as texted. */
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success body of `PUT /notify-sms/phone`, also for a number the caller had
|
||||||
|
* already verified. `phoneNumber` is masked.
|
||||||
|
*/
|
||||||
|
export interface SmsPhoneVerifyResponse {
|
||||||
|
success: true;
|
||||||
|
phoneNumber: string;
|
||||||
|
verified: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body, and also query, of `DELETE /notify-sms/phone`. The service reads the
|
||||||
|
* body first and falls back to the query, because proxies may drop DELETE
|
||||||
|
* bodies.
|
||||||
|
*/
|
||||||
|
export interface SmsPhoneDeleteRequest {
|
||||||
|
phoneNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success body of `DELETE /notify-sms/phone`; `deleted` is false when the number was not registered. */
|
||||||
|
export interface SmsPhoneDeleteResponse {
|
||||||
|
success: true;
|
||||||
|
deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Codes of `/notify-sms` refusals that carry nothing beyond the message. */
|
||||||
|
export type NotifySmsPlainErrorCode =
|
||||||
|
| "SMS_DISABLED" // 503
|
||||||
|
| "SMS_NOT_CONFIGURED" // 500
|
||||||
|
| "SMS_PHONE_INVALID" // 400
|
||||||
|
| "SMS_PHONE_BLOCKED" // 403
|
||||||
|
| "SMS_PHONE_NOT_VERIFIED_BY_CALLER" // 403
|
||||||
|
| "SMS_RECIPIENT_NOT_ALLOWED" // 403
|
||||||
|
| "SMS_CODE_RATE_LIMITED" // 429
|
||||||
|
| "SMS_CODE_SEND_FAILED" // 502
|
||||||
|
| "SMS_CODE_EXPIRED" // 400
|
||||||
|
| "SMS_CODE_ATTEMPTS_EXHAUSTED" // 429
|
||||||
|
| "SMS_NO_VERIFIED_PHONE" // 409
|
||||||
|
| "SMS_ALERT_AUTHORIZATION_FAILED" // 500
|
||||||
|
| "SMS_ALERT_AUTHORIZATION_DELETE_FAILED" // 500
|
||||||
|
| "SMS_ACTION_JWT_NOT_AUTHENTICATED" // 500
|
||||||
|
| "SMS_ACTION_JWT_MISSING_CLAIM" // 403
|
||||||
|
| "SMS_ACTION_JWT_WRONG_ACTION" // 403
|
||||||
|
| "SMS_ACTION_JWT_PHONE_MISMATCH" // 403
|
||||||
|
| "SMS_ACTION_JWT_STALE" // 401
|
||||||
|
| "SMS_ACTION_JWT_EXPIRED" // 401
|
||||||
|
| "SMS_ACTION_JWT_REPLAYED"; // 401
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A coded refusal from any `/notify-sms` route. The auth stages in front of
|
||||||
|
* these routes refuse with a {@link NotifyApiUncodedFailure} instead.
|
||||||
|
*/
|
||||||
|
export type NotifySmsFailure =
|
||||||
|
| { success: false; error: NotifySmsPlainErrorCode; message: string }
|
||||||
|
| {
|
||||||
|
success: false;
|
||||||
|
error: "SMS_CODE_MISMATCH"; // 400
|
||||||
|
message: string;
|
||||||
|
/** Wrong codes left before this code is cleared. */
|
||||||
|
attemptsRemaining: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: false;
|
||||||
|
error: "SMS_PHONE_DID_LIMIT"; // 409
|
||||||
|
message: string;
|
||||||
|
/** Most identities one number may carry. */
|
||||||
|
limit: number;
|
||||||
|
/** Identities other than the caller already verified on the number. */
|
||||||
|
verifiedCount: number;
|
||||||
|
/**
|
||||||
|
* Every DID verified on the number. Sent only by `PUT`, whose caller has
|
||||||
|
* just proved possession of the handset.
|
||||||
|
*/
|
||||||
|
dids?: string[];
|
||||||
|
}
|
||||||
|
| AlertAuthorizationBatchFailure;
|
||||||
@@ -8,8 +8,10 @@
|
|||||||
* - Authentication JWT: short-lived access token (`iss`/`iat`/`exp`) sent as
|
* - Authentication JWT: short-lived access token (`iss`/`iat`/`exp`) sent as
|
||||||
* `Authorization: Bearer` for interactive API calls (`accessToken` /
|
* `Authorization: Bearer` for interactive API calls (`accessToken` /
|
||||||
* `getHeaders`). Identifies the requester DID.
|
* `getHeaders`). Identifies the requester DID.
|
||||||
* - Delegated notification JWT: 100 per-UTC-day tokens from
|
* - Delegated notification JWT: a batch of 100 tokens, each valid for one whole
|
||||||
* `mintDelegatedNotificationJwtBatch` for notify-api / wakeup-service.
|
* UTC day, from `mintAlertAuthorizationBatch`
|
||||||
|
* (`@/services/notifications/alertAuthorizationBatch`), uploaded to the
|
||||||
|
* notify-api's push or SMS `alert-authorization` route.
|
||||||
* - Native background pool: `mintBackgroundJwtTokenPool` for daily-notification
|
* - Native background pool: `mintBackgroundJwtTokenPool` for daily-notification
|
||||||
* plugin prefetch. Unrelated to alertSearch and to the delegated batch.
|
* plugin prefetch. Unrelated to alertSearch and to the delegated batch.
|
||||||
* - alertSearch cursor ULID: server-issued record/JWT primary id (26-char
|
* - alertSearch cursor ULID: server-issued record/JWT primary id (26-char
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mint 100 delegated notification JWTs (one UTC calendar day each) for notify-api.
|
|
||||||
*
|
|
||||||
* JWT kinds (do not mix):
|
|
||||||
* - Authentication JWT: short-lived `accessToken` / `getHeaders` Bearer for the
|
|
||||||
* setup request itself (not generated here).
|
|
||||||
* - Delegated notification JWT: this module. Signed like other Endorser JWTs
|
|
||||||
* (`createEndorserJwtForDid`). `nbf`/`exp` are that UTC day's bounds.
|
|
||||||
* Sequence is array order: index 0 / sequence 1 = the UTC calendar day of `now`.
|
|
||||||
* - Native background pool: `mintBackgroundJwtTokenPool` — unchanged, unused here.
|
|
||||||
* - alertSearch cursor ULID: server record id, not a signed token.
|
|
||||||
*
|
|
||||||
* Timezone: device IANA zone via Luxon `DateTime.local().zoneName` (same source
|
|
||||||
* as project create/edit). Stored on the batch for the wakeup-service optional
|
|
||||||
* `timezone` field; it does not change the UTC-day JWT windows. Pass `timeZone`
|
|
||||||
* to override.
|
|
||||||
*
|
|
||||||
* Passkey (JWANT) identities cannot carry per-day nbf/exp; minting throws.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DateTime } from "luxon";
|
|
||||||
|
|
||||||
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
|
|
||||||
import type {
|
|
||||||
DelegatedNotificationJwtBatch,
|
|
||||||
DelegatedNotificationJwtSlot,
|
|
||||||
DelegatedNotificationJwtWindow,
|
|
||||||
} from "@/interfaces/delegatedNotificationJwt";
|
|
||||||
import { isFromPasskey } from "@/libs/crypto/vc";
|
|
||||||
import { createEndorserJwtForDid } from "@/libs/endorserServer";
|
|
||||||
import { retrieveAccountMetadata } from "@/libs/util";
|
|
||||||
|
|
||||||
export function resolveUserTimeZone(timeZone?: string): string {
|
|
||||||
const zone = timeZone ?? DateTime.local().zoneName ?? undefined;
|
|
||||||
if (!zone) {
|
|
||||||
throw new Error("Could not determine the user's timezone.");
|
|
||||||
}
|
|
||||||
const probe = DateTime.now().setZone(zone);
|
|
||||||
if (!probe.isValid) {
|
|
||||||
throw new Error(
|
|
||||||
"Invalid timezone for delegated notification JWTs: " + zone,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return zone;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UTC-day [nbf, exp) windows for sequence 1..count.
|
|
||||||
* `timeZone` is accepted for call-site compatibility and is not used for bounds.
|
|
||||||
*/
|
|
||||||
export function buildDelegatedNotificationJwtWindows(
|
|
||||||
count: number = DELEGATED_NOTIFICATION_JWT_COUNT,
|
|
||||||
timeZone?: string,
|
|
||||||
now: Date = new Date(),
|
|
||||||
): DelegatedNotificationJwtWindow[] {
|
|
||||||
if (timeZone !== undefined) {
|
|
||||||
resolveUserTimeZone(timeZone);
|
|
||||||
}
|
|
||||||
const todayStart = DateTime.fromJSDate(now, { zone: "utc" }).startOf("day");
|
|
||||||
const windows: DelegatedNotificationJwtWindow[] = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const dayStart = todayStart.plus({ days: i });
|
|
||||||
const nextStart = dayStart.plus({ days: 1 });
|
|
||||||
windows.push({
|
|
||||||
sequence: i + 1,
|
|
||||||
utcDay: dayStart.toFormat("yyyy-LL-dd"),
|
|
||||||
nbf: Math.floor(dayStart.toSeconds()),
|
|
||||||
exp: Math.floor(nextStart.toSeconds()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return windows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function delegatedNotificationJwtStrings(
|
|
||||||
batch: DelegatedNotificationJwtBatch,
|
|
||||||
): string[] {
|
|
||||||
return batch.tokens.map((slot) => slot.jwt);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mintDelegatedNotificationJwtBatch(
|
|
||||||
did: string,
|
|
||||||
options?: { timeZone?: string; now?: Date },
|
|
||||||
): Promise<DelegatedNotificationJwtBatch> {
|
|
||||||
if (!did) {
|
|
||||||
throw new Error("A DID is required to mint delegated notification JWTs.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await retrieveAccountMetadata(did);
|
|
||||||
if (isFromPasskey(account)) {
|
|
||||||
throw new Error(
|
|
||||||
"Delegated notification JWTs with per-day nbf/exp require a local signing key. Passkey JWANT tokens cannot carry those claims.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeZone = resolveUserTimeZone(options?.timeZone);
|
|
||||||
const now = options?.now ?? new Date();
|
|
||||||
const mintedAtEpoch = Math.floor(now.getTime() / 1000);
|
|
||||||
const windows = buildDelegatedNotificationJwtWindows(
|
|
||||||
DELEGATED_NOTIFICATION_JWT_COUNT,
|
|
||||||
timeZone,
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
|
|
||||||
const tokens: DelegatedNotificationJwtSlot[] = [];
|
|
||||||
for (const window of windows) {
|
|
||||||
const jwt = await createEndorserJwtForDid(did, {
|
|
||||||
iss: did,
|
|
||||||
iat: mintedAtEpoch,
|
|
||||||
nbf: window.nbf,
|
|
||||||
exp: window.exp,
|
|
||||||
jti: `${did}#delegated-notify#${window.utcDay}`,
|
|
||||||
});
|
|
||||||
tokens.push({ ...window, jwt });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { did, timeZone, mintedAtEpoch, tokens };
|
|
||||||
}
|
|
||||||
@@ -438,6 +438,18 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
// sessionStorage may be unavailable
|
// sessionStorage may be unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep diagnostic pages reachable when identity creation fails (e.g. a
|
||||||
|
// failed migration leaves every DB call throwing), so the user can open
|
||||||
|
// Profile, then Advanced Settings, then the Test Page or Logs, instead of
|
||||||
|
// being bounced back to /start forever.
|
||||||
|
const diagnosticRoutes = ["/account", "/test", "/logs"];
|
||||||
|
if (diagnosticRoutes.includes(to.path)) {
|
||||||
|
logger.info(
|
||||||
|
`[Router] 🩺 Allowing diagnostic route ${to.path} despite identity creation failure`,
|
||||||
|
);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect to start page if identity creation fails
|
// Redirect to start page if identity creation fails
|
||||||
// This allows users to manually create an identity or troubleshoot
|
// This allows users to manually create an identity or troubleshoot
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
|
|
||||||
import { Capacitor } from "@capacitor/core";
|
import { Capacitor } from "@capacitor/core";
|
||||||
import type { PushNotificationSchema } from "@capacitor/push-notifications";
|
import type { PushNotificationSchema } from "@capacitor/push-notifications";
|
||||||
|
import type {
|
||||||
|
NotificationRefreshRequest,
|
||||||
|
NotificationRefreshResponse,
|
||||||
|
} from "@/interfaces/notifyApi";
|
||||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||||
import { getOrCreateDeviceId } from "./deviceId";
|
import { getOrCreateDeviceId } from "./deviceId";
|
||||||
import { REMINDER_ID_DAILY_REMINDER } from "./reminderIds";
|
import { REMINDER_ID_DAILY_REMINDER } from "./reminderIds";
|
||||||
@@ -29,8 +33,9 @@ import {
|
|||||||
} from "./notificationLog";
|
} from "./notificationLog";
|
||||||
import {
|
import {
|
||||||
getNotificationApiHeaders,
|
getNotificationApiHeaders,
|
||||||
httpAuthErrorMessage,
|
|
||||||
logSkippingRefreshDueToMissingAuth,
|
logSkippingRefreshDueToMissingAuth,
|
||||||
|
notificationApiFailureMessage,
|
||||||
|
readNotificationApiBody,
|
||||||
} from "./notificationApiAuth";
|
} from "./notificationApiAuth";
|
||||||
import { logNotification } from "./NotificationDebugEvents";
|
import { logNotification } from "./NotificationDebugEvents";
|
||||||
|
|
||||||
@@ -607,27 +612,35 @@ export async function refreshNotificationsWithDiagnostics(options?: {
|
|||||||
deviceId = await getOrCreateDeviceId();
|
deviceId = await getOrCreateDeviceId();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"[NativeNotificationService] Could not obtain deviceId; refresh proceeding without deviceId",
|
"[NativeNotificationService] Could not obtain deviceId; skipping refresh",
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!deviceId) {
|
||||||
|
// The service finds the device by deviceId or fcmToken and answers 400
|
||||||
|
// without either, so there is no request worth sending.
|
||||||
|
const errorMessage = "no deviceId (cannot identify this device)";
|
||||||
|
logRefreshFailure(startedAt, errorMessage, undefined, source);
|
||||||
|
return { ok: false, scheduledCount: 0, errorMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: NotificationRefreshRequest = {
|
||||||
|
deviceId,
|
||||||
|
platform: Capacitor.getPlatform(),
|
||||||
|
testMode: getTestMode(),
|
||||||
|
};
|
||||||
const baseUrl = getNotificationApiBaseUrl();
|
const baseUrl = getNotificationApiBaseUrl();
|
||||||
const res = await fetch(`${baseUrl}/notifications/refresh`, {
|
const res = await fetch(`${baseUrl}/notifications/refresh`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: auth.headers,
|
headers: auth.headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
deviceId,
|
|
||||||
platform: Capacitor.getPlatform(),
|
|
||||||
testMode: getTestMode(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorMessage =
|
const errorMessage = notificationApiFailureMessage(
|
||||||
res.status === 401 || res.status === 403
|
res.status,
|
||||||
? httpAuthErrorMessage(res.status)
|
await readNotificationApiBody(res),
|
||||||
: res.statusText || `HTTP ${res.status}`;
|
);
|
||||||
logger.warn("[NativeNotificationService] refreshNotifications failed", {
|
logger.warn("[NativeNotificationService] refreshNotifications failed", {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
@@ -642,12 +655,11 @@ export async function refreshNotificationsWithDiagnostics(options?: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: unknown = await res.json();
|
const payload = (await res.json()) as NotificationRefreshResponse;
|
||||||
const payload = data as NotificationRefreshPayload;
|
|
||||||
const scheduledCount = Array.isArray(payload?.nextNotifications)
|
const scheduledCount = Array.isArray(payload?.nextNotifications)
|
||||||
? payload.nextNotifications.length
|
? payload.nextNotifications.length
|
||||||
: 0;
|
: 0;
|
||||||
await applyNotificationRefreshPayload(data);
|
await applyNotificationRefreshPayload(payload);
|
||||||
logRefreshSuccess(startedAt, scheduledCount, source);
|
logRefreshSuccess(startedAt, scheduledCount, source);
|
||||||
return { ok: true, scheduledCount };
|
return { ok: true, scheduledCount };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function writeStorage(key: string, value: string | null): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Backend URL override, or null when using the default Notification API. */
|
/** Backend URL override, or null when using the default notify-api server. */
|
||||||
export function getBackendBaseUrl(): string | null {
|
export function getBackendBaseUrl(): string | null {
|
||||||
const raw = readStorage(STORAGE_KEY_BACKEND_URL);
|
const raw = readStorage(STORAGE_KEY_BACKEND_URL);
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
@@ -104,8 +104,8 @@ export function setBypassAuth(enabled: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base URL for `/notifications/*` API calls.
|
* Base URL for notify-api calls (`/notifications/*`, `/notify-sms/*`).
|
||||||
* Uses debug override when set; otherwise DEFAULT_NOTIFY_API_SERVER.
|
* Uses debug override when set; otherwise `DEFAULT_NOTIFY_API_SERVER`.
|
||||||
*/
|
*/
|
||||||
export function getNotificationApiBaseUrl(): string {
|
export function getNotificationApiBaseUrl(): string {
|
||||||
const override = getBackendBaseUrl();
|
const override = getBackendBaseUrl();
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
|
|
||||||
import { Capacitor } from "@capacitor/core";
|
import { Capacitor } from "@capacitor/core";
|
||||||
import type { PushNotificationSchema } from "@capacitor/push-notifications";
|
import type { PushNotificationSchema } from "@capacitor/push-notifications";
|
||||||
|
import type {
|
||||||
|
DebugSendWakeupRequest,
|
||||||
|
DebugSendWakeupResponse,
|
||||||
|
} from "@/interfaces/notifyApi";
|
||||||
import { logger } from "@/utils/logger";
|
import { logger } from "@/utils/logger";
|
||||||
import { getOrCreateDeviceId } from "./deviceId";
|
import { getOrCreateDeviceId } from "./deviceId";
|
||||||
import {
|
import {
|
||||||
@@ -31,7 +35,8 @@ import {
|
|||||||
} from "./firebaseMessagingClient";
|
} from "./firebaseMessagingClient";
|
||||||
import {
|
import {
|
||||||
getNotificationApiHeaders,
|
getNotificationApiHeaders,
|
||||||
httpAuthErrorMessage,
|
notificationApiFailureMessage,
|
||||||
|
readNotificationApiBody,
|
||||||
} from "./notificationApiAuth";
|
} from "./notificationApiAuth";
|
||||||
import {
|
import {
|
||||||
applyNotificationRefreshPayload,
|
applyNotificationRefreshPayload,
|
||||||
@@ -62,49 +67,30 @@ export type PendingNotificationsResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SendRealWakeupPingResult =
|
export type SendRealWakeupPingResult =
|
||||||
| { ok: true; responseBody?: unknown }
|
| { ok: true; responseBody?: DebugSendWakeupResponse }
|
||||||
| {
|
| {
|
||||||
ok: false;
|
ok: false;
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
responseBody?: unknown;
|
responseBody?: DebugSendWakeupResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
function wakeupPingResponseDetail(body: unknown): Record<string, unknown> {
|
/** The fields of a send-wakeup answer worth a debug log line. */
|
||||||
if (typeof body !== "object" || body === null) {
|
function wakeupPingResponseDetail(
|
||||||
|
body: DebugSendWakeupResponse | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!body) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const record = body as Record<string, unknown>;
|
return {
|
||||||
const detail: Record<string, unknown> = {};
|
success: body.success,
|
||||||
for (const key of [
|
...(body.failureReason !== undefined
|
||||||
"success",
|
? { failureReason: body.failureReason }
|
||||||
"message",
|
: {}),
|
||||||
"reason",
|
...(body.fcmTokenSuffix !== undefined
|
||||||
"error",
|
? { fcmTokenSuffix: body.fcmTokenSuffix }
|
||||||
"tokenSuffix",
|
: {}),
|
||||||
"deviceId",
|
};
|
||||||
] as const) {
|
|
||||||
if (record[key] !== undefined) {
|
|
||||||
detail[key] = record[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return detail;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wakeupPingFailureMessage(status: number, body: unknown): string {
|
|
||||||
if (typeof body === "object" && body !== null) {
|
|
||||||
const record = body as Record<string, unknown>;
|
|
||||||
for (const key of ["message", "reason", "error"] as const) {
|
|
||||||
const value = record[key];
|
|
||||||
if (typeof value === "string" && value.trim()) {
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (status === 401 || status === 403) {
|
|
||||||
return httpAuthErrorMessage(status);
|
|
||||||
}
|
|
||||||
return `HTTP ${status}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUnimplementedError(e: unknown): boolean {
|
function isUnimplementedError(e: unknown): boolean {
|
||||||
@@ -225,26 +211,30 @@ export const NotificationDebugService = {
|
|||||||
|
|
||||||
const deviceId = await getOrCreateDeviceId();
|
const deviceId = await getOrCreateDeviceId();
|
||||||
const baseUrl = getNotificationApiBaseUrl();
|
const baseUrl = getNotificationApiBaseUrl();
|
||||||
const res = await fetch(`${baseUrl}/debug/send-wakeup`, {
|
const body: DebugSendWakeupRequest = {
|
||||||
method: "POST",
|
|
||||||
headers: auth.headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
deviceId,
|
deviceId,
|
||||||
fcmToken,
|
fcmToken,
|
||||||
platform: Capacitor.getPlatform(),
|
platform: Capacitor.getPlatform(),
|
||||||
testMode: getTestMode(),
|
testMode: getTestMode(),
|
||||||
}),
|
};
|
||||||
|
const res = await fetch(`${baseUrl}/debug/send-wakeup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: auth.headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
let responseBody: unknown;
|
const parsed = await readNotificationApiBody(res);
|
||||||
try {
|
const responseBody =
|
||||||
responseBody = await res.json();
|
typeof parsed === "object" && parsed !== null
|
||||||
} catch {
|
? (parsed as DebugSendWakeupResponse)
|
||||||
responseBody = undefined;
|
: undefined;
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
// A 200 still reports a skipped or failed push as `success: false`.
|
||||||
const errorMessage = wakeupPingFailureMessage(res.status, responseBody);
|
if (!res.ok || responseBody?.success === false) {
|
||||||
|
const errorMessage = notificationApiFailureMessage(
|
||||||
|
res.status,
|
||||||
|
responseBody,
|
||||||
|
);
|
||||||
logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, {
|
logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
token: truncateFcmTokenForLog(fcmToken),
|
token: truncateFcmTokenForLog(fcmToken),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Capacitor } from "@capacitor/core";
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
import type { NotificationRegisterRequest } from "@/interfaces/notifyApi";
|
||||||
import { logger } from "@/utils/logger";
|
import { logger } from "@/utils/logger";
|
||||||
import { getOrCreateDeviceId } from "./deviceId";
|
import { getOrCreateDeviceId } from "./deviceId";
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +23,9 @@ import {
|
|||||||
} from "./NotificationDebugConfig";
|
} from "./NotificationDebugConfig";
|
||||||
import {
|
import {
|
||||||
getNotificationApiHeaders,
|
getNotificationApiHeaders,
|
||||||
httpAuthErrorMessage,
|
|
||||||
logNotificationAuthFailure,
|
logNotificationAuthFailure,
|
||||||
|
notificationApiFailureMessage,
|
||||||
|
readNotificationApiBody,
|
||||||
} from "./notificationApiAuth";
|
} from "./notificationApiAuth";
|
||||||
import {
|
import {
|
||||||
logTokenRegistrationFailure,
|
logTokenRegistrationFailure,
|
||||||
@@ -47,27 +49,29 @@ export async function registerToken(fcmToken: string): Promise<void> {
|
|||||||
throw new Error(`registerToken auth unavailable: ${auth.message}`);
|
throw new Error(`registerToken auth unavailable: ${auth.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/notifications/register`, {
|
const body: NotificationRegisterRequest = {
|
||||||
method: "POST",
|
|
||||||
headers: auth.headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
deviceId,
|
deviceId,
|
||||||
fcmToken,
|
fcmToken,
|
||||||
platform: Capacitor.getPlatform(),
|
platform: Capacitor.getPlatform(),
|
||||||
testMode: getTestMode(),
|
testMode: getTestMode(),
|
||||||
}),
|
};
|
||||||
|
const res = await fetch(`${baseUrl}/notifications/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: auth.headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
// Success is a bare 200 with a plain-text body; only a refusal is read.
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const authDetail =
|
const detail = notificationApiFailureMessage(
|
||||||
res.status === 401 || res.status === 403
|
res.status,
|
||||||
? httpAuthErrorMessage(res.status)
|
await readNotificationApiBody(res),
|
||||||
: `HTTP ${res.status}`;
|
);
|
||||||
logger.warn("[NotificationService] registerToken failed", {
|
logger.warn("[NotificationService] registerToken failed", {
|
||||||
status: res.status,
|
status: res.status,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
authDetail,
|
detail,
|
||||||
});
|
});
|
||||||
throw new Error(`registerToken failed: ${authDetail}`);
|
throw new Error(`registerToken failed: ${detail}`);
|
||||||
}
|
}
|
||||||
logTokenRegistrationSuccess(fcmToken);
|
logTokenRegistrationSuccess(fcmToken);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Delegated alertSearch JWT batches for the notify-api.
|
||||||
|
*
|
||||||
|
* The notify-api runs a user's daily alertSearch on their behalf, so it needs a
|
||||||
|
* credential it can present to Endorser and Partner without the app being
|
||||||
|
* awake. The app mints a batch of {@link ALERT_AUTHORIZATION_BATCH_DAYS}
|
||||||
|
* single-day JWTs up front and uploads them; the service spends one per UTC day
|
||||||
|
* and stops when the inventory runs out.
|
||||||
|
*
|
||||||
|
* Both delivery channels take the same batch: push through
|
||||||
|
* `PUT /notifications/alert-authorization` (see `pushAlertAuthorizationApi.ts`)
|
||||||
|
* and SMS through `POST /notify-sms/alert-authorization` (see
|
||||||
|
* `smsNotificationApi.ts`). The service validates both with one rule and keeps
|
||||||
|
* a separate inventory per channel, so this module is the only minter for
|
||||||
|
* either and each upload carries a batch of its own. The native background
|
||||||
|
* prefetch pool (`mintBackgroundJwtTokenPool`) is a different credential. The
|
||||||
|
* wire shapes are in `@/interfaces/notifyApi`.
|
||||||
|
*
|
||||||
|
* Each JWT must cover the whole of the UTC day it names -- `nbf` at or before
|
||||||
|
* that day's opening midnight and `exp` at or after its closing midnight --
|
||||||
|
* because the daily run may fire at any moment inside the day, catch-up runs
|
||||||
|
* included. The frame is UTC rather than the device's zone: a window from local
|
||||||
|
* midnight to local midnight misses part of the UTC day it is filed under in
|
||||||
|
* every zone but UTC, and the service rejects the whole batch.
|
||||||
|
*
|
||||||
|
* Passkey (`did:peer`) identities cannot mint these: signing goes through a
|
||||||
|
* WebAuthn prompt with a fixed one-minute lifetime, so a day-long window is not
|
||||||
|
* expressible. The service rejects such batches with
|
||||||
|
* `DELEGATED_JWT_UNSUPPORTED_IDENTITY`; {@link mintAlertAuthorizationBatch}
|
||||||
|
* throws {@link UnsupportedIdentityError} before spending a round trip.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { KeyMetaWithPrivate } from "@/interfaces/common";
|
||||||
|
import type {
|
||||||
|
AlertAuthorizationRequestBody,
|
||||||
|
DelegatedAlertJwt,
|
||||||
|
DelegatedAlertJwtPayload,
|
||||||
|
} from "@/interfaces/notifyApi";
|
||||||
|
import { retrieveFullyDecryptedAccount } from "@/libs/util";
|
||||||
|
import { createEndorserJwtForKey } from "@/libs/crypto/vc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWTs per batch. The service expects consecutive sequence numbers covering
|
||||||
|
* distinct days, so this is also the number of days an upload lasts.
|
||||||
|
*/
|
||||||
|
export const ALERT_AUTHORIZATION_BATCH_DAYS = 100;
|
||||||
|
|
||||||
|
const SECONDS_PER_DAY = 24 * 60 * 60;
|
||||||
|
const MS_PER_DAY = SECONDS_PER_DAY * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Padding on each end of a day's validity window, for clock skew between this
|
||||||
|
* device, the notify-api, and Endorser. The service asks for `nbf` at or before
|
||||||
|
* the opening midnight and `exp` at or after the closing one, so widening is
|
||||||
|
* allowed and narrowing is not.
|
||||||
|
*/
|
||||||
|
const WINDOW_SLACK_SECONDS = 5 * 60;
|
||||||
|
|
||||||
|
export interface AlertAuthorizationBatch {
|
||||||
|
batchId: string;
|
||||||
|
jwts: DelegatedAlertJwt[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown for identities whose keys cannot sign a day-long delegated JWT. */
|
||||||
|
export class UnsupportedIdentityError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "UnsupportedIdentityError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateBatchId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTC calendar day (`YYYY-MM-DD`) for an epoch-milliseconds instant. */
|
||||||
|
function utcDayString(epochMs: number): string {
|
||||||
|
return new Date(epochMs).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a full batch of delegated alertSearch JWTs starting with the UTC day
|
||||||
|
* containing `startingAt`.
|
||||||
|
*
|
||||||
|
* The account is decrypted once and reused for all
|
||||||
|
* {@link ALERT_AUTHORIZATION_BATCH_DAYS} signatures; decrypting per JWT costs
|
||||||
|
* seconds on a phone.
|
||||||
|
*
|
||||||
|
* @param did identity the batch is issued by and for
|
||||||
|
* @param startingAt instant whose UTC day is sequence 0 (defaults to now)
|
||||||
|
* @throws UnsupportedIdentityError for passkey identities
|
||||||
|
*/
|
||||||
|
export async function mintAlertAuthorizationBatch(
|
||||||
|
did: string,
|
||||||
|
startingAt: Date = new Date(),
|
||||||
|
): Promise<AlertAuthorizationBatch> {
|
||||||
|
const account = await retrieveFullyDecryptedAccount(did);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error(`No account found for ${did}`);
|
||||||
|
}
|
||||||
|
if (!account.identity && account.passkeyCredIdHex) {
|
||||||
|
throw new UnsupportedIdentityError(
|
||||||
|
"Passkey identities cannot authorize background notifications. " +
|
||||||
|
"Switch to a seed-phrase identity to turn this on.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!account.identity) {
|
||||||
|
throw new Error(`No signing key found for ${did}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstDayStartMs = Date.UTC(
|
||||||
|
startingAt.getUTCFullYear(),
|
||||||
|
startingAt.getUTCMonth(),
|
||||||
|
startingAt.getUTCDate(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const jwts: DelegatedAlertJwt[] = [];
|
||||||
|
for (
|
||||||
|
let sequence = 0;
|
||||||
|
sequence < ALERT_AUTHORIZATION_BATCH_DAYS;
|
||||||
|
sequence++
|
||||||
|
) {
|
||||||
|
const dayStartMs = firstDayStartMs + sequence * MS_PER_DAY;
|
||||||
|
const dayStartSec = Math.floor(dayStartMs / 1000);
|
||||||
|
const nbf = dayStartSec - WINDOW_SLACK_SECONDS;
|
||||||
|
const exp = dayStartSec + SECONDS_PER_DAY + WINDOW_SLACK_SECONDS;
|
||||||
|
// `iat` and `iss` are filled in by the signer; nbf/exp are the day window.
|
||||||
|
const payload: DelegatedAlertJwtPayload = { nbf, exp };
|
||||||
|
const jwt = await createEndorserJwtForKey(
|
||||||
|
account as KeyMetaWithPrivate,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
jwts.push({
|
||||||
|
sequence,
|
||||||
|
day: utcDayString(dayStartMs),
|
||||||
|
nbf,
|
||||||
|
exp,
|
||||||
|
jwt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { batchId: generateBatchId(), jwts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a fresh batch and wrap it in the upload body for either channel.
|
||||||
|
*
|
||||||
|
* Mint once per upload: each channel spends its own inventory, and one batch
|
||||||
|
* shared by both would put the same JWT in front of Endorser twice a day.
|
||||||
|
*
|
||||||
|
* @param notifyHourUtc UTC hour, 0-23
|
||||||
|
* @param notifyMinuteUtc UTC minute, 0-59
|
||||||
|
* @throws UnsupportedIdentityError for passkey identities
|
||||||
|
*/
|
||||||
|
export async function buildAlertAuthorizationBody(
|
||||||
|
did: string,
|
||||||
|
notifyHourUtc: number,
|
||||||
|
notifyMinuteUtc: number,
|
||||||
|
): Promise<AlertAuthorizationRequestBody> {
|
||||||
|
const batch = await mintAlertAuthorizationBatch(did);
|
||||||
|
// Recorded for whatever later re-derives the hour across a daylight-saving
|
||||||
|
// change; no scheduling decision reads it.
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
return {
|
||||||
|
batchId: batch.batchId,
|
||||||
|
notifyHourUtc,
|
||||||
|
notifyMinuteUtc,
|
||||||
|
...(timezone ? { timezone } : {}),
|
||||||
|
jwts: batch.jwts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a 12-hour clock reading such as `"6:30 PM"` -- the format the time
|
||||||
|
* picker hands back -- into the UTC hour and minute the notify-api schedules
|
||||||
|
* on. The reading is interpreted in the device's current local time, so the
|
||||||
|
* result follows the device's present UTC offset and does not track later
|
||||||
|
* daylight-saving changes.
|
||||||
|
*
|
||||||
|
* @returns the UTC pair, or null when the text is not a time
|
||||||
|
*/
|
||||||
|
export function localTimeTextToUtc(
|
||||||
|
timeText: string,
|
||||||
|
): { notifyHourUtc: number; notifyMinuteUtc: number } | null {
|
||||||
|
const match = (timeText || "").match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rawHour = parseInt(match[1], 10);
|
||||||
|
const minute = parseInt(match[2], 10);
|
||||||
|
if (rawHour < 1 || rawHour > 12 || minute > 59) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const isAm = match[3].toUpperCase() === "AM";
|
||||||
|
let hour24 = rawHour % 12; // 12 AM -> 0, 12 PM -> 12 after the PM shift
|
||||||
|
if (!isAm) {
|
||||||
|
hour24 += 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve through a real local date so the offset used is the device's own,
|
||||||
|
// including half-hour and 45-minute zones that an hour-based shift mangles.
|
||||||
|
const local = new Date();
|
||||||
|
local.setHours(hour24, minute, 0, 0);
|
||||||
|
return {
|
||||||
|
notifyHourUtc: local.getUTCHours(),
|
||||||
|
notifyMinuteUtc: local.getUTCMinutes(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -60,6 +60,32 @@ export {
|
|||||||
timeToCron,
|
timeToCron,
|
||||||
timeToCronFiveMinutesBefore,
|
timeToCronFiveMinutesBefore,
|
||||||
} from "./dualScheduleConfig";
|
} from "./dualScheduleConfig";
|
||||||
|
export {
|
||||||
|
ALERT_AUTHORIZATION_BATCH_DAYS,
|
||||||
|
buildAlertAuthorizationBody,
|
||||||
|
localTimeTextToUtc,
|
||||||
|
mintAlertAuthorizationBatch,
|
||||||
|
UnsupportedIdentityError,
|
||||||
|
} from "./alertAuthorizationBatch";
|
||||||
|
export type { AlertAuthorizationBatch } from "./alertAuthorizationBatch";
|
||||||
|
|
||||||
|
export {
|
||||||
|
authorizePushAlertSearch,
|
||||||
|
PushAlertAuthorizationError,
|
||||||
|
revokePushAlertSearch,
|
||||||
|
} from "./pushAlertAuthorizationApi";
|
||||||
|
|
||||||
|
export {
|
||||||
|
authorizeSmsAlertSearch,
|
||||||
|
deleteSmsPhone,
|
||||||
|
listSmsPhones,
|
||||||
|
normalizePhoneNumber,
|
||||||
|
registerSmsPhone,
|
||||||
|
revokeSmsAlertSearch,
|
||||||
|
SmsApiError,
|
||||||
|
smsErrorMessage,
|
||||||
|
verifySmsPhone,
|
||||||
|
} from "./smsNotificationApi";
|
||||||
export type { DualScheduleConfigInput } from "./dualScheduleConfig";
|
export type { DualScheduleConfigInput } from "./dualScheduleConfig";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -168,3 +168,39 @@ export function httpAuthErrorMessage(status: number): string {
|
|||||||
}
|
}
|
||||||
return `HTTP ${status}`;
|
return `HTTP ${status}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A response body parsed as JSON, or undefined when it is empty or not JSON. */
|
||||||
|
export async function readNotificationApiBody(res: Response): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await res.json();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The best sentence for a failed device or debug route call: the service's own
|
||||||
|
* reason when the body carries one, otherwise a description of the status.
|
||||||
|
*
|
||||||
|
* Reads the uncoded refusal shapes in `@/interfaces/notifyApi`, in order:
|
||||||
|
* `error` (`NotificationDeviceRouteFailure`), `failureReason`
|
||||||
|
* (`DebugSendWakeupResponse`), and `message` (`NotifyApiUncodedFailure`).
|
||||||
|
*/
|
||||||
|
export function notificationApiFailureMessage(
|
||||||
|
status: number,
|
||||||
|
body: unknown,
|
||||||
|
): string {
|
||||||
|
if (typeof body === "object" && body !== null) {
|
||||||
|
const { error, failureReason, message } = body as {
|
||||||
|
error?: unknown;
|
||||||
|
failureReason?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
};
|
||||||
|
for (const value of [error, failureReason, message]) {
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return httpAuthErrorMessage(status);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Client for the notify-api's push-channel alertSearch authorization,
|
||||||
|
* `/notifications/alert-authorization`.
|
||||||
|
*
|
||||||
|
* The push counterpart of `authorizeSmsAlertSearch` / `revokeSmsAlertSearch`:
|
||||||
|
* the same batch body, stored in the push channel's own inventory, so the daily
|
||||||
|
* digest arrives as an FCM message on the DID's registered devices.
|
||||||
|
*
|
||||||
|
* Requests carry an ordinary access token for the DID. There is no per-action
|
||||||
|
* claim as on the SMS routes, and the service refuses the `testMode` bypass on
|
||||||
|
* these routes, so the notification debug auth bypass does not apply.
|
||||||
|
*
|
||||||
|
* The wire shapes are in `@/interfaces/notifyApi`; the route contract is in
|
||||||
|
* `notification-wakeup-service/README.md`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AlertAuthorizationRequestBody,
|
||||||
|
AlertAuthorizationResponse,
|
||||||
|
AlertAuthorizationRevokeResponse,
|
||||||
|
} from "@/interfaces/notifyApi";
|
||||||
|
import { getHeaders } from "@/libs/endorserServer";
|
||||||
|
import { logger } from "@/utils/logger";
|
||||||
|
import { buildAlertAuthorizationBody } from "./alertAuthorizationBatch";
|
||||||
|
import { getNotificationApiBaseUrl } from "./NotificationDebugConfig";
|
||||||
|
|
||||||
|
const ALERT_AUTHORIZATION_PATH = "/notifications/alert-authorization";
|
||||||
|
|
||||||
|
/** A push alert-authorization call that did not succeed. */
|
||||||
|
export class PushAlertAuthorizationError extends Error {
|
||||||
|
/**
|
||||||
|
* The service's code, such as `ALERT_AUTHORIZATION_INVALID_BATCH`;
|
||||||
|
* `NO_ACTIVE_IDENTITY` or `NO_ACCESS_TOKEN` when no request was sent; "" when
|
||||||
|
* the refusal carried no code.
|
||||||
|
*/
|
||||||
|
readonly code: string;
|
||||||
|
/** HTTP status, or 0 when no request was sent. */
|
||||||
|
readonly status: number;
|
||||||
|
/** Specific problems with a refused batch; empty for any other refusal. */
|
||||||
|
readonly details: string[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
code: string,
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
details: string[],
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PushAlertAuthorizationError";
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushAlertAuthorizationRequest<T>(
|
||||||
|
did: string,
|
||||||
|
method: "PUT" | "DELETE",
|
||||||
|
body?: AlertAuthorizationRequestBody,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!did) {
|
||||||
|
throw new PushAlertAuthorizationError(
|
||||||
|
"NO_ACTIVE_IDENTITY",
|
||||||
|
0,
|
||||||
|
"No active identity to authorize the request",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate as `did` itself rather than whichever identity is active: the
|
||||||
|
// service rejects a batch whose JWTs were issued by anyone but the caller.
|
||||||
|
const headers = await getHeaders(did);
|
||||||
|
if (!headers.Authorization) {
|
||||||
|
throw new PushAlertAuthorizationError(
|
||||||
|
"NO_ACCESS_TOKEN",
|
||||||
|
0,
|
||||||
|
"Could not create an access token for the request",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${getNotificationApiBaseUrl()}${ALERT_AUTHORIZATION_PATH}`,
|
||||||
|
{
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let parsed: unknown = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// An `AlertAuthorizationFailure`, or from a proxy, something else entirely.
|
||||||
|
const refusal = (parsed && typeof parsed === "object" ? parsed : {}) as {
|
||||||
|
error?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
|
const code = typeof refusal.error === "string" ? refusal.error : "";
|
||||||
|
const message =
|
||||||
|
typeof refusal.message === "string" && refusal.message
|
||||||
|
? refusal.message
|
||||||
|
: `HTTP ${response.status}`;
|
||||||
|
const details = Array.isArray(refusal.details)
|
||||||
|
? refusal.details.map(String)
|
||||||
|
: [];
|
||||||
|
logger.warn("[pushAlertAuthorizationApi] request failed", {
|
||||||
|
method,
|
||||||
|
status: response.status,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
throw new PushAlertAuthorizationError(
|
||||||
|
code,
|
||||||
|
response.status,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (parsed ?? {}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the push channel a fresh inventory of delegated JWTs and the UTC time of
|
||||||
|
* day to notify. Replaces any unused batch already stored, so it is also how
|
||||||
|
* the notify time is changed.
|
||||||
|
*
|
||||||
|
* @param notifyHourUtc UTC hour, 0-23
|
||||||
|
* @param notifyMinuteUtc UTC minute, 0-59
|
||||||
|
* @throws UnsupportedIdentityError for passkey identities, before any request
|
||||||
|
*/
|
||||||
|
export async function authorizePushAlertSearch(
|
||||||
|
did: string,
|
||||||
|
notifyHourUtc: number,
|
||||||
|
notifyMinuteUtc: number,
|
||||||
|
): Promise<AlertAuthorizationResponse> {
|
||||||
|
const body = await buildAlertAuthorizationBody(
|
||||||
|
did,
|
||||||
|
notifyHourUtc,
|
||||||
|
notifyMinuteUtc,
|
||||||
|
);
|
||||||
|
return pushAlertAuthorizationRequest<AlertAuthorizationResponse>(
|
||||||
|
did,
|
||||||
|
"PUT",
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the push digest off: every push batch and JWT for this DID is removed.
|
||||||
|
* Device registrations and the SMS channel are untouched.
|
||||||
|
*/
|
||||||
|
export async function revokePushAlertSearch(
|
||||||
|
did: string,
|
||||||
|
): Promise<AlertAuthorizationRevokeResponse> {
|
||||||
|
return pushAlertAuthorizationRequest<AlertAuthorizationRevokeResponse>(
|
||||||
|
did,
|
||||||
|
"DELETE",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
/**
|
||||||
|
* Client for the notify-api's `/notify-sms` surface.
|
||||||
|
*
|
||||||
|
* The SMS channel texts the daily alertSearch digest. Setting it up is two
|
||||||
|
* independent steps: prove possession of a handset (POST then PUT `/phone`),
|
||||||
|
* and authorize the service to run the daily search (POST
|
||||||
|
* `/alert-authorization`). Stopping is the mirror image, and the two stops are
|
||||||
|
* different promises: DELETE `/alert-authorization` silences the texts and
|
||||||
|
* keeps the number verified, while DELETE `/phone` forgets the number and
|
||||||
|
* costs a fresh code next time.
|
||||||
|
*
|
||||||
|
* Every call carries a Bearer JWT that both authenticates the caller and names
|
||||||
|
* the single action it may perform, on the single number it applies to. The
|
||||||
|
* service records the token's hash before running the handler, so a token buys
|
||||||
|
* exactly one call -- each function here mints its own and none may be reused.
|
||||||
|
*
|
||||||
|
* The wire shapes are in `@/interfaces/notifyApi`; the service's own account of
|
||||||
|
* these routes is `notification-wakeup-service/README.md`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AlertAuthorizationRequestBody,
|
||||||
|
AlertAuthorizationResponse,
|
||||||
|
AlertAuthorizationRevokeResponse,
|
||||||
|
NotifySmsFailure,
|
||||||
|
SmsActionJwtPayload,
|
||||||
|
SmsActionTarget,
|
||||||
|
SmsNotificationActionClaim,
|
||||||
|
SmsPhoneDeleteRequest,
|
||||||
|
SmsPhoneDeleteResponse,
|
||||||
|
SmsPhoneListQuery,
|
||||||
|
SmsPhoneListResponse,
|
||||||
|
SmsPhoneRegisterRequest,
|
||||||
|
SmsPhoneRegisterResponse,
|
||||||
|
SmsPhoneVerifyRequest,
|
||||||
|
SmsPhoneVerifyResponse,
|
||||||
|
} from "@/interfaces/notifyApi";
|
||||||
|
import { createEndorserJwtForDid } from "@/libs/endorserServer";
|
||||||
|
import { logger } from "@/utils/logger";
|
||||||
|
import { buildAlertAuthorizationBody } from "./alertAuthorizationBatch";
|
||||||
|
import { getNotificationApiBaseUrl } from "./NotificationDebugConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim namespace, shared with the FCM setup claim; `@type` is what separates
|
||||||
|
* the two. Not to be confused with `https://giftopia.me`, the app link that
|
||||||
|
* appears in the texts themselves.
|
||||||
|
*/
|
||||||
|
const SMS_CLAIM_CONTEXT = "https://giftopia.tech";
|
||||||
|
const SMS_CLAIM_TYPE = "SmsNotificationAction";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifetime of an action JWT. Long enough to survive a slow round trip and
|
||||||
|
* ordinary clock skew, short enough to stay inside the service's
|
||||||
|
* `SMS_ACTION_JWT_MAX_AGE_SEC` staleness window.
|
||||||
|
*/
|
||||||
|
const ACTION_JWT_EXPIRY_SECONDS = 300;
|
||||||
|
|
||||||
|
/** A `/notify-sms` call that did not succeed. */
|
||||||
|
export class SmsApiError extends Error {
|
||||||
|
/**
|
||||||
|
* The service's code, such as `SMS_CODE_MISMATCH`; `NO_ACTIVE_IDENTITY` when
|
||||||
|
* no request was sent; "" when the refusal carried no code, as from the auth
|
||||||
|
* stages or from an answer that was not the service's JSON.
|
||||||
|
*/
|
||||||
|
readonly code: string;
|
||||||
|
/** HTTP status, or 0 when no request was sent. */
|
||||||
|
readonly status: number;
|
||||||
|
/**
|
||||||
|
* The coded refusal as sent, for the fields specific codes carry. A code the
|
||||||
|
* service adds later arrives here too, outside the union.
|
||||||
|
*/
|
||||||
|
readonly response?: NotifySmsFailure;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
code: string,
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
response?: NotifySmsFailure,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SmsApiError";
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
this.response = response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a typed number toward E.164 so the same string goes into the claim
|
||||||
|
* and the body, and so a number stored from one call still matches on the next.
|
||||||
|
* Ten digits are assumed US, matching the service's own rule; a leading `+` is
|
||||||
|
* taken at its word.
|
||||||
|
*
|
||||||
|
* @returns the normalized number, or "" when it cannot be one
|
||||||
|
*/
|
||||||
|
export function normalizePhoneNumber(raw: string): string {
|
||||||
|
const trimmed = (raw || "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const digits = trimmed.replace(/\D/g, "");
|
||||||
|
if (!digits) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("+")) {
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
if (digits.length === 10) {
|
||||||
|
return `+1${digits}`;
|
||||||
|
}
|
||||||
|
if (digits.length === 11 && digits.startsWith("1")) {
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
// Everything else keeps its digits and gains the `+` the service expects;
|
||||||
|
// whether the country code is real is Twilio's judgment, not ours.
|
||||||
|
return `+${digits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint the one-shot Bearer token for a single `/notify-sms` call.
|
||||||
|
*
|
||||||
|
* {@link SmsActionTarget} pairs each action with the number it binds to: the
|
||||||
|
* per-handset actions require one and the inventory-wide ones take none. An
|
||||||
|
* empty number is left out of the claim.
|
||||||
|
*/
|
||||||
|
async function mintSmsActionJwt(
|
||||||
|
did: string,
|
||||||
|
target: SmsActionTarget,
|
||||||
|
): Promise<string> {
|
||||||
|
// Copied field by field so request options passed along as `target` stay out
|
||||||
|
// of the claim; the parameter type has already checked the pairing.
|
||||||
|
const claim = {
|
||||||
|
"@context": SMS_CLAIM_CONTEXT,
|
||||||
|
"@type": SMS_CLAIM_TYPE,
|
||||||
|
action: target.action,
|
||||||
|
...(target.phoneNumber ? { phoneNumber: target.phoneNumber } : {}),
|
||||||
|
} as SmsNotificationActionClaim;
|
||||||
|
const payload: SmsActionJwtPayload = { claim };
|
||||||
|
return createEndorserJwtForDid(did, payload, ACTION_JWT_EXPIRY_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a refusal: a coded `/notify-sms` refusal, or a message without a code
|
||||||
|
* from the auth stages. Anything else keeps only its status.
|
||||||
|
*/
|
||||||
|
function parseRefusal(
|
||||||
|
body: unknown,
|
||||||
|
status: number,
|
||||||
|
): { code: string; message: string; response?: NotifySmsFailure } {
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return { code: "", message: `HTTP ${status}` };
|
||||||
|
}
|
||||||
|
const { error, message } = body as { error?: unknown; message?: unknown };
|
||||||
|
const text =
|
||||||
|
typeof message === "string" && message ? message : `HTTP ${status}`;
|
||||||
|
if (typeof error === "string" && error) {
|
||||||
|
return { code: error, message: text, response: body as NotifySmsFailure };
|
||||||
|
}
|
||||||
|
return { code: "", message: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One `/notify-sms` call: what its action claim authorizes, plus the request.
|
||||||
|
* `phoneNumber` here goes into the claim only; a route that reads the number
|
||||||
|
* from the query or body still needs it there.
|
||||||
|
*/
|
||||||
|
type SmsRequestOptions = SmsActionTarget & {
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||||
|
path: string;
|
||||||
|
query?: SmsPhoneListQuery | SmsPhoneDeleteRequest;
|
||||||
|
body?:
|
||||||
|
| SmsPhoneRegisterRequest
|
||||||
|
| SmsPhoneVerifyRequest
|
||||||
|
| SmsPhoneDeleteRequest
|
||||||
|
| AlertAuthorizationRequestBody;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function smsRequest<T>(
|
||||||
|
did: string,
|
||||||
|
options: SmsRequestOptions,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!did) {
|
||||||
|
throw new SmsApiError(
|
||||||
|
"NO_ACTIVE_IDENTITY",
|
||||||
|
0,
|
||||||
|
"No active identity to authorize the request",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwt = await mintSmsActionJwt(did, options);
|
||||||
|
const url = new URL(`${getNotificationApiBaseUrl()}${options.path}`);
|
||||||
|
for (const [key, value] of Object.entries(options.query ?? {})) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: options.method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
},
|
||||||
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let parsed: unknown = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const refusal = parseRefusal(parsed, response.status);
|
||||||
|
logger.warn("[smsNotificationApi] request failed", {
|
||||||
|
action: options.action,
|
||||||
|
status: response.status,
|
||||||
|
code: refusal.code,
|
||||||
|
});
|
||||||
|
throw new SmsApiError(
|
||||||
|
refusal.code,
|
||||||
|
response.status,
|
||||||
|
refusal.message,
|
||||||
|
refusal.response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (parsed ?? {}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List this DID's registrations, with full numbers. Pass a number to also learn
|
||||||
|
* which DIDs have verified it -- allowed only to a caller who has verified it
|
||||||
|
* too.
|
||||||
|
*/
|
||||||
|
export async function listSmsPhones(
|
||||||
|
did: string,
|
||||||
|
phoneNumber?: string,
|
||||||
|
): Promise<SmsPhoneListResponse> {
|
||||||
|
const normalized = phoneNumber ? normalizePhoneNumber(phoneNumber) : "";
|
||||||
|
return smsRequest<SmsPhoneListResponse>(did, {
|
||||||
|
method: "GET",
|
||||||
|
path: "/notify-sms/phone",
|
||||||
|
action: "list-phones",
|
||||||
|
// The claim binds to a number only when the query parameter is present.
|
||||||
|
phoneNumber: normalized || undefined,
|
||||||
|
query: normalized ? { phoneNumber: normalized } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the number for this DID and have the service text a 6-digit code.
|
||||||
|
*
|
||||||
|
* A number this DID has already verified comes back with `verified: true` and
|
||||||
|
* no text is sent, so a caller can treat that as an immediate success.
|
||||||
|
*
|
||||||
|
* The `phoneNumber` in the response is masked. Keep
|
||||||
|
* `normalizePhoneNumber(phoneNumber)` instead: only the full number matches
|
||||||
|
* what {@link listSmsPhones} returns and what the later calls must send.
|
||||||
|
*/
|
||||||
|
export async function registerSmsPhone(
|
||||||
|
did: string,
|
||||||
|
phoneNumber: string,
|
||||||
|
): Promise<SmsPhoneRegisterResponse> {
|
||||||
|
const normalized = normalizePhoneNumber(phoneNumber);
|
||||||
|
return smsRequest<SmsPhoneRegisterResponse>(did, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/notify-sms/phone",
|
||||||
|
action: "register-phone",
|
||||||
|
phoneNumber: normalized,
|
||||||
|
body: { phoneNumber: normalized },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match the texted code and mark the registration verified. The response's
|
||||||
|
* `phoneNumber` is masked, as with {@link registerSmsPhone}.
|
||||||
|
*/
|
||||||
|
export async function verifySmsPhone(
|
||||||
|
did: string,
|
||||||
|
phoneNumber: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<SmsPhoneVerifyResponse> {
|
||||||
|
const normalized = normalizePhoneNumber(phoneNumber);
|
||||||
|
return smsRequest<SmsPhoneVerifyResponse>(did, {
|
||||||
|
method: "PUT",
|
||||||
|
path: "/notify-sms/phone",
|
||||||
|
action: "verify-phone",
|
||||||
|
phoneNumber: normalized,
|
||||||
|
body: { phoneNumber: normalized, code: code.trim() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget the number entirely. Removing one that is not registered is a success
|
||||||
|
* with `deleted: false`, not an error, so this is safe to call blind.
|
||||||
|
*
|
||||||
|
* The number goes in both the body and the query string because proxies drop
|
||||||
|
* bodies on DELETE and the service reads either.
|
||||||
|
*/
|
||||||
|
export async function deleteSmsPhone(
|
||||||
|
did: string,
|
||||||
|
phoneNumber: string,
|
||||||
|
): Promise<SmsPhoneDeleteResponse> {
|
||||||
|
const normalized = normalizePhoneNumber(phoneNumber);
|
||||||
|
return smsRequest<SmsPhoneDeleteResponse>(did, {
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/notify-sms/phone",
|
||||||
|
action: "delete-phone",
|
||||||
|
phoneNumber: normalized,
|
||||||
|
query: { phoneNumber: normalized },
|
||||||
|
body: { phoneNumber: normalized },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the service a fresh inventory of delegated JWTs and the UTC time of day
|
||||||
|
* to text. This is what turns the daily digest on; it replaces any batch
|
||||||
|
* already stored, so it is also how the send time is changed.
|
||||||
|
*
|
||||||
|
* Requires a verified phone for the DID -- without one the service answers
|
||||||
|
* `SMS_NO_VERIFIED_PHONE`.
|
||||||
|
*
|
||||||
|
* @param notifyHourUtc UTC hour, 0-23
|
||||||
|
* @param notifyMinuteUtc UTC minute, 0-59
|
||||||
|
*/
|
||||||
|
export async function authorizeSmsAlertSearch(
|
||||||
|
did: string,
|
||||||
|
notifyHourUtc: number,
|
||||||
|
notifyMinuteUtc: number,
|
||||||
|
): Promise<AlertAuthorizationResponse> {
|
||||||
|
const body = await buildAlertAuthorizationBody(
|
||||||
|
did,
|
||||||
|
notifyHourUtc,
|
||||||
|
notifyMinuteUtc,
|
||||||
|
);
|
||||||
|
return smsRequest<AlertAuthorizationResponse>(did, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/notify-sms/alert-authorization",
|
||||||
|
action: "authorize-alert-search",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the texts off: every SMS batch and JWT for this DID is removed and the
|
||||||
|
* scheduler stops listing the identity. Registered numbers survive, so turning
|
||||||
|
* the digest back on does not cost another verification code.
|
||||||
|
*/
|
||||||
|
export async function revokeSmsAlertSearch(
|
||||||
|
did: string,
|
||||||
|
): Promise<AlertAuthorizationRevokeResponse> {
|
||||||
|
return smsRequest<AlertAuthorizationRevokeResponse>(did, {
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/notify-sms/alert-authorization",
|
||||||
|
action: "revoke-alert-search",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a failure into something worth showing a user.
|
||||||
|
*
|
||||||
|
* Codes the service documents get their own wording; an unrecognized code
|
||||||
|
* falls back to the message the service sent, since a new code is more likely
|
||||||
|
* to be informative than a generic apology. A refusal with no code gets
|
||||||
|
* wording by status, because the auth stages' messages point at server logs.
|
||||||
|
*/
|
||||||
|
export function smsErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (!(error instanceof SmsApiError)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const response = error.response;
|
||||||
|
switch (error.code) {
|
||||||
|
case "SMS_DISABLED":
|
||||||
|
return "Text notifications are not available on this server yet.";
|
||||||
|
case "SMS_NOT_CONFIGURED":
|
||||||
|
return "This server cannot send texts right now. Please try again later.";
|
||||||
|
case "SMS_PHONE_BLOCKED":
|
||||||
|
return (
|
||||||
|
"This number has opted out of texts from us. Text START to the " +
|
||||||
|
"number that messaged you, then try again."
|
||||||
|
);
|
||||||
|
case "SMS_RECIPIENT_NOT_ALLOWED":
|
||||||
|
return "This server is not allowed to text your identity.";
|
||||||
|
case "SMS_CODE_MISMATCH": {
|
||||||
|
const remaining =
|
||||||
|
response?.error === "SMS_CODE_MISMATCH"
|
||||||
|
? response.attemptsRemaining
|
||||||
|
: undefined;
|
||||||
|
return typeof remaining === "number"
|
||||||
|
? `That code is not right. ${remaining} ${
|
||||||
|
remaining === 1 ? "try" : "tries"
|
||||||
|
} left.`
|
||||||
|
: "That code is not right.";
|
||||||
|
}
|
||||||
|
case "SMS_CODE_ATTEMPTS_EXHAUSTED":
|
||||||
|
return "Too many wrong codes. Ask for a new one to start over.";
|
||||||
|
case "SMS_CODE_EXPIRED":
|
||||||
|
return "That code has expired. Ask for a new one.";
|
||||||
|
case "SMS_CODE_RATE_LIMITED":
|
||||||
|
return "Too many codes sent to that number. Please wait a while.";
|
||||||
|
case "SMS_CODE_SEND_FAILED":
|
||||||
|
return "The code could not be texted to that number. Please try again.";
|
||||||
|
case "SMS_PHONE_INVALID":
|
||||||
|
return "That does not look like a mobile number we can text.";
|
||||||
|
case "SMS_PHONE_DID_LIMIT": {
|
||||||
|
const limit =
|
||||||
|
response?.error === "SMS_PHONE_DID_LIMIT" ? response.limit : undefined;
|
||||||
|
return typeof limit === "number"
|
||||||
|
? `This number already has ${limit} identities on it, which is the limit.`
|
||||||
|
: "This number already has as many identities on it as we allow.";
|
||||||
|
}
|
||||||
|
case "SMS_PHONE_NOT_VERIFIED_BY_CALLER":
|
||||||
|
return "Verify this number first to see who else is using it.";
|
||||||
|
case "SMS_NO_VERIFIED_PHONE":
|
||||||
|
return "Verify a phone number before turning on text notifications.";
|
||||||
|
case "SMS_ACTION_JWT_STALE":
|
||||||
|
case "SMS_ACTION_JWT_EXPIRED":
|
||||||
|
return "That request took too long to reach the server. Please try again.";
|
||||||
|
case "SMS_ACTION_JWT_REPLAYED":
|
||||||
|
return "That request was already used. Please try again.";
|
||||||
|
case "SMS_ACTION_JWT_MISSING_CLAIM":
|
||||||
|
case "SMS_ACTION_JWT_WRONG_ACTION":
|
||||||
|
case "SMS_ACTION_JWT_PHONE_MISMATCH":
|
||||||
|
case "ALERT_AUTHORIZATION_INVALID_BATCH":
|
||||||
|
return "This app and the notification server disagree about that request.";
|
||||||
|
case "SMS_ALERT_AUTHORIZATION_FAILED":
|
||||||
|
case "SMS_ALERT_AUTHORIZATION_DELETE_FAILED":
|
||||||
|
case "SMS_ACTION_JWT_NOT_AUTHENTICATED":
|
||||||
|
return "The notification server had a problem. Please try again later.";
|
||||||
|
case "DELEGATED_JWT_UNSUPPORTED_IDENTITY":
|
||||||
|
return (
|
||||||
|
"Passkey identities cannot authorize background notifications. " +
|
||||||
|
"Switch to a seed-phrase identity to turn this on."
|
||||||
|
);
|
||||||
|
case "NO_ACTIVE_IDENTITY":
|
||||||
|
return "Choose an identity before setting up text notifications.";
|
||||||
|
case "":
|
||||||
|
if (error.status === 401) {
|
||||||
|
return "The notification server could not confirm your identity. Please try again.";
|
||||||
|
}
|
||||||
|
if (error.status === 503) {
|
||||||
|
return "The notification server cannot confirm identities right now. Please try again later.";
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
default:
|
||||||
|
return error.message || fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -590,16 +590,22 @@ export class CapacitorPlatformService
|
|||||||
await this.db!.run(sql, params);
|
await this.db!.run(sql, params);
|
||||||
} else {
|
} else {
|
||||||
// For multi-statement SQL (like migrations), use executeSet method
|
// For multi-statement SQL (like migrations), use executeSet method
|
||||||
// This handles multiple statements properly
|
// This handles multiple statements properly.
|
||||||
if (
|
// Strip "--" line comments before splitting: a semicolon inside a
|
||||||
sql.includes(";") &&
|
// comment would otherwise split mid-comment, yielding a comment-only
|
||||||
sql.split(";").filter((s) => s.trim()).length > 1
|
// statement (SQLITE_MISUSE, "error code 21: not an error") and a
|
||||||
) {
|
// statement that starts with stray comment text.
|
||||||
|
// (No migration uses "--" or ";" inside a string literal.)
|
||||||
|
const statements = sql
|
||||||
|
.replace(/--[^\n]*/g, "")
|
||||||
|
.split(";")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s);
|
||||||
|
if (statements.length > 1) {
|
||||||
// Multi-statement SQL - use executeSet for proper handling
|
// Multi-statement SQL - use executeSet for proper handling
|
||||||
const statements = sql.split(";").filter((s) => s.trim());
|
|
||||||
await this.db!.executeSet(
|
await this.db!.executeSet(
|
||||||
statements.map((stmt) => ({
|
statements.map((stmt) => ({
|
||||||
statement: stmt.trim(),
|
statement: stmt,
|
||||||
values: [], // Empty values array for non-parameterized statements
|
values: [], // Empty values array for non-parameterized statements
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { DelegatedAlertJwt } from "@/interfaces/notifyApi";
|
||||||
|
import {
|
||||||
|
ALERT_AUTHORIZATION_BATCH_DAYS,
|
||||||
|
buildAlertAuthorizationBody,
|
||||||
|
mintAlertAuthorizationBatch,
|
||||||
|
UnsupportedIdentityError,
|
||||||
|
} from "@/services/notifications/alertAuthorizationBatch";
|
||||||
|
import { retrieveFullyDecryptedAccount } from "@/libs/util";
|
||||||
|
import { createEndorserJwtForKey } from "@/libs/crypto/vc";
|
||||||
|
|
||||||
|
jest.mock("@/libs/util", () => ({ retrieveFullyDecryptedAccount: jest.fn() }));
|
||||||
|
jest.mock("@/libs/crypto/vc", () => ({ createEndorserJwtForKey: jest.fn() }));
|
||||||
|
|
||||||
|
const mockedRetrieve = retrieveFullyDecryptedAccount as unknown as jest.Mock;
|
||||||
|
const mockedSign = createEndorserJwtForKey as unknown as jest.Mock;
|
||||||
|
|
||||||
|
const DID = "did:ethr:0x0000000000000000000000000000000000000001";
|
||||||
|
const SECONDS_PER_DAY = 86400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notify-api's acceptance rule for one entry, as enforced by
|
||||||
|
* `validateAlertAuthorizationBatch` in notification-wakeup-service: the JWT is
|
||||||
|
* valid for the whole UTC day it names.
|
||||||
|
*/
|
||||||
|
function coversWholeUtcDay(entry: DelegatedAlertJwt): boolean {
|
||||||
|
const dayStart = Date.parse(`${entry.day}T00:00:00Z`) / 1000;
|
||||||
|
return (
|
||||||
|
entry.nbf < entry.exp &&
|
||||||
|
entry.nbf <= dayStart &&
|
||||||
|
entry.exp >= dayStart + SECONDS_PER_DAY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Date` methods whose answers depend on the device's zone. Jest cannot switch
|
||||||
|
* the process zone inside a test file, so rather than minting under several
|
||||||
|
* zones the test makes these throw: a minter that never calls them produces
|
||||||
|
* the same batch in every zone.
|
||||||
|
*/
|
||||||
|
const ZONE_DEPENDENT_DATE_METHODS = [
|
||||||
|
"getDate",
|
||||||
|
"getDay",
|
||||||
|
"getFullYear",
|
||||||
|
"getHours",
|
||||||
|
"getMilliseconds",
|
||||||
|
"getMinutes",
|
||||||
|
"getMonth",
|
||||||
|
"getSeconds",
|
||||||
|
"getTimezoneOffset",
|
||||||
|
"setDate",
|
||||||
|
"setFullYear",
|
||||||
|
"setHours",
|
||||||
|
"setMilliseconds",
|
||||||
|
"setMinutes",
|
||||||
|
"setMonth",
|
||||||
|
"setSeconds",
|
||||||
|
"toDateString",
|
||||||
|
"toLocaleDateString",
|
||||||
|
"toLocaleString",
|
||||||
|
"toLocaleTimeString",
|
||||||
|
"toString",
|
||||||
|
"toTimeString",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
async function mintWithoutZoneDependentDates(startingAt: Date) {
|
||||||
|
const spies = ZONE_DEPENDENT_DATE_METHODS.map((method) =>
|
||||||
|
jest.spyOn(Date.prototype, method).mockImplementation(() => {
|
||||||
|
throw new Error(`Date.prototype.${method} reads the device's zone`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return await mintAlertAuthorizationBatch(DID, startingAt);
|
||||||
|
} finally {
|
||||||
|
spies.forEach((spy) => spy.mockRestore());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just after and just before a UTC midnight, where the device's calendar day
|
||||||
|
// differs from the UTC one in most zones, and starts whose 100 days cross a US
|
||||||
|
// daylight-saving change.
|
||||||
|
const INSTANTS = [
|
||||||
|
"2026-09-13T00:10:00Z",
|
||||||
|
"2026-09-13T23:50:00Z",
|
||||||
|
"2026-02-20T12:00:00Z",
|
||||||
|
"2026-10-15T07:30:00Z",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("mintAlertAuthorizationBatch", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedRetrieve.mockReset().mockResolvedValue({ did: DID, identity: "{}" });
|
||||||
|
mockedSign
|
||||||
|
.mockReset()
|
||||||
|
.mockImplementation(
|
||||||
|
async (_account: unknown, payload: object) =>
|
||||||
|
`jwt.${JSON.stringify(payload)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(INSTANTS)(
|
||||||
|
"starting at %s, mints a batch the notify-api accepts in any zone",
|
||||||
|
async (instant) => {
|
||||||
|
const batch = await mintWithoutZoneDependentDates(new Date(instant));
|
||||||
|
|
||||||
|
expect(batch.batchId).toEqual(expect.any(String));
|
||||||
|
expect(batch.batchId.length).toBeGreaterThan(0);
|
||||||
|
expect(batch.jwts).toHaveLength(ALERT_AUTHORIZATION_BATCH_DAYS);
|
||||||
|
|
||||||
|
// Sequence 0 is the UTC day containing the start.
|
||||||
|
expect(batch.jwts[0].day).toBe(instant.slice(0, 10));
|
||||||
|
|
||||||
|
const days = new Set(batch.jwts.map((entry) => entry.day));
|
||||||
|
expect(days.size).toBe(ALERT_AUTHORIZATION_BATCH_DAYS);
|
||||||
|
|
||||||
|
batch.jwts.forEach((entry, index) => {
|
||||||
|
expect(entry.sequence).toBe(index);
|
||||||
|
expect(coversWholeUtcDay(entry)).toBe(true);
|
||||||
|
if (index > 0) {
|
||||||
|
const previousStart = Date.parse(
|
||||||
|
`${batch.jwts[index - 1].day}T00:00:00Z`,
|
||||||
|
);
|
||||||
|
expect(Date.parse(`${entry.day}T00:00:00Z`) - previousStart).toBe(
|
||||||
|
SECONDS_PER_DAY * 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The service checks the signed claims against the listed window.
|
||||||
|
expect(mockedSign).toHaveBeenCalledTimes(ALERT_AUTHORIZATION_BATCH_DAYS);
|
||||||
|
mockedSign.mock.calls.forEach(([, payload], index) => {
|
||||||
|
expect(payload).toEqual({
|
||||||
|
nbf: batch.jwts[index].nbf,
|
||||||
|
exp: batch.jwts[index].exp,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// One decryption serves every signature.
|
||||||
|
expect(mockedRetrieve).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("refuses passkey identities without signing", async () => {
|
||||||
|
mockedRetrieve.mockResolvedValue({
|
||||||
|
did: "did:peer:0zExample",
|
||||||
|
passkeyCredIdHex: "abcd",
|
||||||
|
});
|
||||||
|
await expect(mintAlertAuthorizationBatch(DID)).rejects.toBeInstanceOf(
|
||||||
|
UnsupportedIdentityError,
|
||||||
|
);
|
||||||
|
expect(mockedSign).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an unknown DID", async () => {
|
||||||
|
mockedRetrieve.mockResolvedValue(undefined);
|
||||||
|
await expect(mintAlertAuthorizationBatch(DID)).rejects.toThrow(
|
||||||
|
/No account found/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildAlertAuthorizationBody", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedRetrieve.mockReset().mockResolvedValue({ did: DID, identity: "{}" });
|
||||||
|
mockedSign.mockReset().mockResolvedValue("jwt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the fields both alert-authorization routes require", async () => {
|
||||||
|
const body = await buildAlertAuthorizationBody(DID, 0, 30);
|
||||||
|
|
||||||
|
expect(body.batchId).toEqual(expect.any(String));
|
||||||
|
expect(body.notifyHourUtc).toBe(0);
|
||||||
|
expect(body.notifyMinuteUtc).toBe(30);
|
||||||
|
expect(body.timezone).toBe(
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
);
|
||||||
|
expect(body.jwts).toHaveLength(ALERT_AUTHORIZATION_BATCH_DAYS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mints a distinct batch on every call", async () => {
|
||||||
|
const first = await buildAlertAuthorizationBody(DID, 18, 0);
|
||||||
|
const second = await buildAlertAuthorizationBody(DID, 18, 0);
|
||||||
|
expect(first.batchId).not.toBe(second.batchId);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
notificationApiFailureMessage,
|
||||||
|
readNotificationApiBody,
|
||||||
|
} from "@/services/notifications/notificationApiAuth";
|
||||||
|
|
||||||
|
jest.mock("@/libs/endorserServer", () => ({ getHeaders: jest.fn() }));
|
||||||
|
jest.mock("@/services/PlatformServiceFactory", () => ({
|
||||||
|
PlatformServiceFactory: { getInstance: jest.fn() },
|
||||||
|
}));
|
||||||
|
jest.mock("@/utils/logger", () => ({ logger: { warn: jest.fn() } }));
|
||||||
|
jest.mock("@/services/notifications/notificationApiDebugMode", () => ({
|
||||||
|
shouldBypassNotificationAuth: () => false,
|
||||||
|
}));
|
||||||
|
jest.mock("@/services/notifications/NotificationDebugEvents", () => ({
|
||||||
|
logNotification: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("notificationApiFailureMessage", () => {
|
||||||
|
it("reads a device route's `error` sentence", () => {
|
||||||
|
expect(
|
||||||
|
notificationApiFailureMessage(404, { error: "Device not found" }),
|
||||||
|
).toBe("Device not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a send-wakeup `failureReason`", () => {
|
||||||
|
expect(
|
||||||
|
notificationApiFailureMessage(200, {
|
||||||
|
success: false,
|
||||||
|
failureReason: "FCM send failed",
|
||||||
|
fcmTokenSuffix: "abc123",
|
||||||
|
}),
|
||||||
|
).toBe("FCM send failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an auth stage's `message`", () => {
|
||||||
|
expect(
|
||||||
|
notificationApiFailureMessage(401, {
|
||||||
|
success: false,
|
||||||
|
message: "Unauthorized. See server logs at 2026-09-13T12:00:00.000Z",
|
||||||
|
}),
|
||||||
|
).toBe("Unauthorized. See server logs at 2026-09-13T12:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes the status when the body says nothing", () => {
|
||||||
|
expect(notificationApiFailureMessage(401, undefined)).toBe(
|
||||||
|
"unauthorized (expired or invalid auth)",
|
||||||
|
);
|
||||||
|
expect(notificationApiFailureMessage(500, { error: " " })).toBe(
|
||||||
|
"HTTP 500",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readNotificationApiBody", () => {
|
||||||
|
it("returns undefined for a body that is not JSON", async () => {
|
||||||
|
const res = {
|
||||||
|
json: async () => {
|
||||||
|
throw new SyntaxError("Unexpected token O");
|
||||||
|
},
|
||||||
|
} as unknown as Response;
|
||||||
|
await expect(readNotificationApiBody(res)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import {
|
||||||
|
listSmsPhones,
|
||||||
|
registerSmsPhone,
|
||||||
|
SmsApiError,
|
||||||
|
smsErrorMessage,
|
||||||
|
} from "@/services/notifications/smsNotificationApi";
|
||||||
|
import { createEndorserJwtForDid } from "@/libs/endorserServer";
|
||||||
|
|
||||||
|
jest.mock("@/libs/endorserServer", () => ({
|
||||||
|
createEndorserJwtForDid: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock("@/utils/logger", () => ({ logger: { warn: jest.fn() } }));
|
||||||
|
jest.mock("@/services/notifications/NotificationDebugConfig", () => ({
|
||||||
|
getNotificationApiBaseUrl: () => "https://notify.example",
|
||||||
|
}));
|
||||||
|
jest.mock("@/services/notifications/alertAuthorizationBatch", () => ({
|
||||||
|
buildAlertAuthorizationBody: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedSign = createEndorserJwtForDid as unknown as jest.Mock;
|
||||||
|
const mockedFetch = jest.fn();
|
||||||
|
global.fetch = mockedFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const DID = "did:ethr:0x0000000000000000000000000000000000000001";
|
||||||
|
const FALLBACK = "Something went wrong.";
|
||||||
|
|
||||||
|
function answer(status: number, body: unknown) {
|
||||||
|
mockedFetch.mockResolvedValueOnce({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refusal(status: number, body: unknown): Promise<SmsApiError> {
|
||||||
|
answer(status, body);
|
||||||
|
try {
|
||||||
|
await registerSmsPhone(DID, "555-555-0123");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SmsApiError) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("expected the call to be refused");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedFetch.mockReset();
|
||||||
|
mockedSign.mockReset().mockResolvedValue("signed");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listSmsPhones", () => {
|
||||||
|
it("returns the service's body and signs a claim without a number", async () => {
|
||||||
|
const body = {
|
||||||
|
success: true,
|
||||||
|
phones: [
|
||||||
|
{
|
||||||
|
phoneNumber: "+15555550123",
|
||||||
|
verified: true,
|
||||||
|
verifiedAt: "2026-09-01T00:00:00.000Z",
|
||||||
|
createdAt: "2026-08-31T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phoneNumber: "+15555550199",
|
||||||
|
verified: false,
|
||||||
|
verifiedAt: null,
|
||||||
|
createdAt: "2026-09-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
answer(200, body);
|
||||||
|
|
||||||
|
await expect(listSmsPhones(DID)).resolves.toEqual(body);
|
||||||
|
expect(mockedSign).toHaveBeenCalledWith(
|
||||||
|
DID,
|
||||||
|
{
|
||||||
|
claim: {
|
||||||
|
"@context": "https://giftopia.tech",
|
||||||
|
"@type": "SmsNotificationAction",
|
||||||
|
action: "list-phones",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
expect(mockedFetch.mock.calls[0][0]).toBe(
|
||||||
|
"https://notify.example/notify-sms/phone",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds the claim and the query to a queried number", async () => {
|
||||||
|
answer(200, { success: true, phones: [], phoneNumber: "+15555550123" });
|
||||||
|
|
||||||
|
await listSmsPhones(DID, "(555) 555-0123");
|
||||||
|
expect(mockedSign.mock.calls[0][1].claim.phoneNumber).toBe("+15555550123");
|
||||||
|
expect(mockedFetch.mock.calls[0][0]).toBe(
|
||||||
|
"https://notify.example/notify-sms/phone?phoneNumber=%2B15555550123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refusals", () => {
|
||||||
|
it("keeps a coded refusal's extra fields", async () => {
|
||||||
|
const error = await refusal(400, {
|
||||||
|
success: false,
|
||||||
|
error: "SMS_CODE_MISMATCH",
|
||||||
|
message: "That code does not match.",
|
||||||
|
attemptsRemaining: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(error.code).toBe("SMS_CODE_MISMATCH");
|
||||||
|
expect(error.status).toBe(400);
|
||||||
|
expect(error.message).toBe("That code does not match.");
|
||||||
|
expect(smsErrorMessage(error, FALLBACK)).toBe(
|
||||||
|
"That code is not right. 2 tries left.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the identity limit from the refusal", async () => {
|
||||||
|
const error = await refusal(409, {
|
||||||
|
success: false,
|
||||||
|
error: "SMS_PHONE_DID_LIMIT",
|
||||||
|
message: "This number already carries the maximum number of identities.",
|
||||||
|
limit: 5,
|
||||||
|
verifiedCount: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(smsErrorMessage(error, FALLBACK)).toBe(
|
||||||
|
"This number already has 5 identities on it, which is the limit.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show an auth stage's server-log message", async () => {
|
||||||
|
const error = await refusal(401, {
|
||||||
|
success: false,
|
||||||
|
message: "Unauthorized. See server logs at 2026-09-13T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(error.code).toBe("");
|
||||||
|
expect(error.response).toBeUndefined();
|
||||||
|
expect(smsErrorMessage(error, FALLBACK)).toBe(
|
||||||
|
"The notification server could not confirm your identity. Please try again.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names an unreachable Endorser for an uncoded 503", async () => {
|
||||||
|
const error = await refusal(503, {
|
||||||
|
success: false,
|
||||||
|
message: "Authentication service unavailable. See server logs at now",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(smsErrorMessage(error, FALLBACK)).toBe(
|
||||||
|
"The notification server cannot confirm identities right now. Please try again later.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for an answer that is not the service's JSON", async () => {
|
||||||
|
const error = await refusal(502, "<html>Bad Gateway</html>");
|
||||||
|
|
||||||
|
expect(error.code).toBe("");
|
||||||
|
expect(error.message).toBe("HTTP 502");
|
||||||
|
expect(smsErrorMessage(error, FALLBACK)).toBe(FALLBACK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the service's message for a code this app does not know", async () => {
|
||||||
|
const error = await refusal(418, {
|
||||||
|
success: false,
|
||||||
|
error: "SMS_SOMETHING_NEW",
|
||||||
|
message: "A reason the app has no wording for.",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(smsErrorMessage(error, FALLBACK)).toBe(
|
||||||
|
"A reason the app has no wording for.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the fallback for errors that are not SmsApiError", () => {
|
||||||
|
expect(smsErrorMessage(new TypeError("Failed to fetch"), FALLBACK)).toBe(
|
||||||
|
FALLBACK,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -302,6 +302,7 @@ export const PlatformServiceMixin = {
|
|||||||
column === "warnIfProdServer" ||
|
column === "warnIfProdServer" ||
|
||||||
column === "warnIfTestServer" ||
|
column === "warnIfTestServer" ||
|
||||||
column === "reminderFastRolloverForTesting" ||
|
column === "reminderFastRolloverForTesting" ||
|
||||||
|
column === "smsNotificationsEnabled" ||
|
||||||
// contacts
|
// contacts
|
||||||
column === "hideTheirContent" ||
|
column === "hideTheirContent" ||
|
||||||
column === "registered" ||
|
column === "registered" ||
|
||||||
|
|||||||
+642
-31
@@ -143,24 +143,48 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-4 mb-2">
|
|
||||||
<!-- label -->
|
<!--
|
||||||
<div>
|
New Activity Notification: two delivery channels for the same alert.
|
||||||
|
Both let the user pick a time; they differ only in how the alert is
|
||||||
|
delivered (and therefore in the parameters passed to the notify-api):
|
||||||
|
- In-App: on-device notification via the daily-notification-plugin.
|
||||||
|
- SMS: text message sent by the notify-api (opt-in).
|
||||||
|
-->
|
||||||
|
<div class="mt-4 mb-1">
|
||||||
|
<div class="font-semibold">
|
||||||
New Activity Notification
|
New Activity Notification
|
||||||
<button
|
<button
|
||||||
class="text-slate-400 fa-fw cursor-pointer"
|
class="text-slate-400 fa-fw cursor-pointer"
|
||||||
aria-label="Learn more about New Activity notifications"
|
aria-label="Learn more about New Activity notifications"
|
||||||
@click.stop="showNewActivityNotificationInfo"
|
@click.stop="showNewActivityNotificationInfo(false)"
|
||||||
>
|
>
|
||||||
<font-awesome icon="question-circle" aria-hidden="true" />
|
<font-awesome icon="question-circle" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-xs text-slate-500">
|
||||||
|
Get told about new relevant activity for you. Choose a delivery
|
||||||
|
channel below -- you can turn on either one, or both.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Channel 1: In-App notification -->
|
||||||
|
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
|
||||||
|
<!-- label -->
|
||||||
|
<div>
|
||||||
|
<font-awesome
|
||||||
|
icon="comment"
|
||||||
|
class="fa-fw text-slate-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
In-App Notification
|
||||||
|
</div>
|
||||||
<!-- toggle -->
|
<!-- toggle -->
|
||||||
<div
|
<div
|
||||||
class="relative ml-2 cursor-pointer"
|
class="relative ml-2 cursor-pointer"
|
||||||
role="switch"
|
role="switch"
|
||||||
:aria-checked="notifyingNewActivity"
|
:aria-checked="notifyingNewActivity"
|
||||||
aria-label="Toggle New Activity notifications"
|
aria-label="Toggle New Activity notifications in app"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@click.stop.prevent="showNewActivityNotificationChoice()"
|
@click.stop.prevent="showNewActivityNotificationChoice()"
|
||||||
>
|
>
|
||||||
@@ -178,7 +202,12 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="notifyingNewActivity" class="w-full">
|
<div class="mb-2 pl-1">
|
||||||
|
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
||||||
|
Troubleshoot your in-app notifications…
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-if="notifyingNewActivity" class="w-full pl-1">
|
||||||
<div
|
<div
|
||||||
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
|
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
|
||||||
>
|
>
|
||||||
@@ -191,17 +220,128 @@
|
|||||||
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||||
@click="editNewActivityNotification"
|
@click="editNewActivityNotification"
|
||||||
>
|
>
|
||||||
Edit New Activity Notification…
|
Edit In-App Time…
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Channel 2: SMS text messages (opt-in; gated on a verified phone) -->
|
||||||
|
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
|
||||||
|
<!-- label -->
|
||||||
|
<div>
|
||||||
|
<font-awesome
|
||||||
|
icon="message"
|
||||||
|
class="fa-fw text-slate-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
notifyingNewActivitySmsPhone
|
||||||
|
? "SMS Text Message"
|
||||||
|
: "Enable Text Messages"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<!-- master toggle: enables the SMS channel (verifies phone on first use) -->
|
||||||
|
<div
|
||||||
|
class="relative ml-2 cursor-pointer"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="smsEnabled"
|
||||||
|
aria-label="Toggle text message delivery"
|
||||||
|
tabindex="0"
|
||||||
|
@click.stop.prevent="toggleSmsEnabled()"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="smsEnabled"
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only"
|
||||||
|
readonly
|
||||||
|
@click.stop.prevent
|
||||||
|
@change.stop.prevent
|
||||||
|
/>
|
||||||
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||||
|
<div
|
||||||
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="notifyingNewActivitySmsPhone" class="w-full pl-1">
|
||||||
|
<div
|
||||||
|
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200 flex items-center justify-between gap-2 flex-wrap"
|
||||||
|
>
|
||||||
|
<!-- Label and value must share one line: Vue's whitespace
|
||||||
|
condensing deletes a newline-only text node between elements,
|
||||||
|
which would glue "Texting:" to the number. -->
|
||||||
|
<div>
|
||||||
|
<b>Texting:</b> <i>{{ notifyingNewActivitySmsPhone }}</i>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-red-600"
|
||||||
|
aria-label="Forget phone number and disable text messages"
|
||||||
|
@click="forgetSmsPhoneNumber()"
|
||||||
|
>
|
||||||
|
<font-awesome icon="trash-can" class="fa-fw" aria-hidden="true" />
|
||||||
|
Forget Phone Number
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Per-notification SMS toggles: only available while SMS is enabled -->
|
||||||
|
<div v-if="smsEnabled" class="w-full pl-4">
|
||||||
|
<div class="flex items-center justify-between mt-1 mb-2">
|
||||||
|
<div>
|
||||||
|
New Activity Text
|
||||||
|
<button
|
||||||
|
class="text-slate-400 fa-fw cursor-pointer"
|
||||||
|
aria-label="Learn more about New Activity text messages"
|
||||||
|
@click.stop="showNewActivityNotificationInfo(true)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="question-circle" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- toggle -->
|
||||||
|
<div
|
||||||
|
class="relative ml-2 cursor-pointer"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="notifyingNewActivitySms"
|
||||||
|
aria-label="Toggle New Activity text messages"
|
||||||
|
tabindex="0"
|
||||||
|
@click.stop.prevent="showNewActivitySmsNotificationChoice()"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="notifyingNewActivitySms"
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only"
|
||||||
|
readonly
|
||||||
|
@click.stop.prevent
|
||||||
|
@change.stop.prevent
|
||||||
|
/>
|
||||||
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||||
|
<div
|
||||||
|
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="notifyingNewActivitySms" class="w-full">
|
||||||
|
<div
|
||||||
|
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<b>Time:</b>
|
||||||
|
{{ notifyingNewActivitySmsTime.replace(" ", " ") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-2 text-center">
|
<div class="mt-2 text-center">
|
||||||
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
<button
|
||||||
Troubleshoot your notifications…
|
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||||
</router-link>
|
@click="editNewActivitySmsNotification"
|
||||||
|
>
|
||||||
|
Edit SMS Time…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<PushNotificationPermission ref="pushNotificationPermission" />
|
<PushNotificationPermission ref="pushNotificationPermission" />
|
||||||
|
<SmsVerificationDialog ref="smsVerificationDialog" />
|
||||||
|
<ChoiceButtonDialog ref="smsOptInChoiceDialog" />
|
||||||
|
|
||||||
<!-- User Profile -->
|
<!-- User Profile -->
|
||||||
<section
|
<section
|
||||||
@@ -443,6 +583,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
data-testid="serverUrlsToggle"
|
||||||
|
class="text-slate-500 text-sm font-bold mt-4 cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-expanded="showServerSettings"
|
||||||
|
aria-controls="sectionServerUrls"
|
||||||
|
@click="showServerSettings = !showServerSettings"
|
||||||
|
@keydown.enter="showServerSettings = !showServerSettings"
|
||||||
|
>
|
||||||
|
Server URLs
|
||||||
|
<font-awesome
|
||||||
|
:icon="showServerSettings ? 'chevron-down' : 'chevron-right'"
|
||||||
|
class="fa-fw ml-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
<div v-if="showServerSettings" id="sectionServerUrls" class="pl-2">
|
||||||
<div id="sectionClaimServer">
|
<div id="sectionClaimServer">
|
||||||
<h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2>
|
<h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2>
|
||||||
<div
|
<div
|
||||||
@@ -463,8 +621,8 @@
|
|||||||
placeholder="Enter API server URL"
|
placeholder="Enter API server URL"
|
||||||
/>
|
/>
|
||||||
<div id="apiServerDescription" class="sr-only" role="tooltip">
|
<div id="apiServerDescription" class="sr-only" role="tooltip">
|
||||||
Enter the URL for the claim server. You can use the buttons below to
|
Enter the URL for the claim server. You can use the buttons below
|
||||||
quickly set common server URLs.
|
to quickly set common server URLs.
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="apiServerInput != apiServer"
|
v-if="apiServerInput != apiServer"
|
||||||
@@ -514,7 +672,11 @@
|
|||||||
<!-- toggle -->
|
<!-- toggle -->
|
||||||
<div class="relative ml-2">
|
<div class="relative ml-2">
|
||||||
<!-- input -->
|
<!-- input -->
|
||||||
<input v-model="warnIfProdServer" type="checkbox" class="sr-only" />
|
<input
|
||||||
|
v-model="warnIfProdServer"
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only"
|
||||||
|
/>
|
||||||
<!-- line -->
|
<!-- line -->
|
||||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||||
<!-- dot -->
|
<!-- dot -->
|
||||||
@@ -534,7 +696,11 @@
|
|||||||
<!-- toggle -->
|
<!-- toggle -->
|
||||||
<div class="relative ml-2">
|
<div class="relative ml-2">
|
||||||
<!-- input -->
|
<!-- input -->
|
||||||
<input v-model="warnIfTestServer" type="checkbox" class="sr-only" />
|
<input
|
||||||
|
v-model="warnIfTestServer"
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only"
|
||||||
|
/>
|
||||||
<!-- line -->
|
<!-- line -->
|
||||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||||
<!-- dot -->
|
<!-- dot -->
|
||||||
@@ -546,7 +712,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notification Push Server setting - only show on web platforms -->
|
<!-- Notification Push Server setting - only show on web platforms -->
|
||||||
<div v-if="!isNativePlatform">
|
<!-- This will be removed because we're no longer using PWA notifications. -->
|
||||||
|
<div v-if="false">
|
||||||
<h2 class="text-slate-500 text-sm font-bold mb-2">
|
<h2 class="text-slate-500 text-sm font-bold mb-2">
|
||||||
Notification Push Server
|
Notification Push Server
|
||||||
</h2>
|
</h2>
|
||||||
@@ -593,7 +760,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="text-slate-500 text-sm font-bold mb-2">Partner Server URL</h2>
|
<h2 class="text-slate-500 text-sm font-bold mb-2">
|
||||||
|
Partner Server URL
|
||||||
|
</h2>
|
||||||
<div class="px-3 py-4">
|
<div class="px-3 py-4">
|
||||||
<input
|
<input
|
||||||
v-model="partnerApiServerInput"
|
v-model="partnerApiServerInput"
|
||||||
@@ -613,26 +782,32 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||||
@click="partnerApiServerInput = AppConstants.PROD_PARTNER_API_SERVER"
|
@click="
|
||||||
|
partnerApiServerInput = AppConstants.PROD_PARTNER_API_SERVER
|
||||||
|
"
|
||||||
>
|
>
|
||||||
Use Prod
|
Use Prod
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||||
@click="partnerApiServerInput = AppConstants.TEST_PARTNER_API_SERVER"
|
@click="
|
||||||
|
partnerApiServerInput = AppConstants.TEST_PARTNER_API_SERVER
|
||||||
|
"
|
||||||
>
|
>
|
||||||
Use Test
|
Use Test
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||||
@click="partnerApiServerInput = AppConstants.LOCAL_PARTNER_API_SERVER"
|
@click="
|
||||||
|
partnerApiServerInput = AppConstants.LOCAL_PARTNER_API_SERVER
|
||||||
|
"
|
||||||
>
|
>
|
||||||
Use Local
|
Use Local
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="!partnerApiServerInput" class="px-4 text-sm">
|
<span v-if="!partnerApiServerInput" class="px-4 text-sm">
|
||||||
When that setting is blank, this app will use the default partner server
|
When that setting is blank, this app will use the default partner
|
||||||
URL:
|
server URL:
|
||||||
{{ DEFAULT_PARTNER_API_SERVER }}
|
{{ DEFAULT_PARTNER_API_SERVER }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -642,6 +817,22 @@
|
|||||||
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
|
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="text-slate-500 text-sm font-bold"
|
||||||
|
>Notify Server URL</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<span class="text-sm break-all">{{ notifyApiServer }}</span>
|
||||||
|
<button
|
||||||
|
class="text-slate-400 fa-fw cursor-pointer"
|
||||||
|
aria-label="Learn more about the notify server URL"
|
||||||
|
@click="showNotifyServerInfo"
|
||||||
|
>
|
||||||
|
<font-awesome icon="circle-info" aria-hidden="true"></font-awesome>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
for="toggleHideRegisterPromptOnNewContact"
|
for="toggleHideRegisterPromptOnNewContact"
|
||||||
class="flex items-center justify-between cursor-pointer mt-4"
|
class="flex items-center justify-between cursor-pointer mt-4"
|
||||||
@@ -742,6 +933,13 @@
|
|||||||
>
|
>
|
||||||
Logs
|
Logs
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'test' }"
|
||||||
|
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
|
||||||
|
>
|
||||||
|
Test Page
|
||||||
|
</router-link>
|
||||||
|
|
||||||
<!-- Non-production bundles only; route `dev-notifications` must exist
|
<!-- Non-production bundles only; route `dev-notifications` must exist
|
||||||
(see `includeDevToolkitRoutes`). -->
|
(see `includeDevToolkitRoutes`). -->
|
||||||
<router-link
|
<router-link
|
||||||
@@ -751,12 +949,6 @@
|
|||||||
>
|
>
|
||||||
Notification Debug Panel
|
Notification Debug Panel
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
|
||||||
:to="{ name: 'test' }"
|
|
||||||
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
|
|
||||||
>
|
|
||||||
Test Page
|
|
||||||
</router-link>
|
|
||||||
|
|
||||||
<div class="flex mt-2">
|
<div class="flex mt-2">
|
||||||
<button>
|
<button>
|
||||||
@@ -792,10 +984,12 @@ import { copyToClipboard } from "../services/ClipboardService";
|
|||||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||||
import { Capacitor } from "@capacitor/core";
|
import { Capacitor } from "@capacitor/core";
|
||||||
|
|
||||||
|
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
|
||||||
import EntityIcon from "../components/EntityIcon.vue";
|
import EntityIcon from "../components/EntityIcon.vue";
|
||||||
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
||||||
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
|
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
|
import SmsVerificationDialog from "../components/SmsVerificationDialog.vue";
|
||||||
import TopMessage from "../components/TopMessage.vue";
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||||
import DataExportSection from "../components/DataExportSection.vue";
|
import DataExportSection from "../components/DataExportSection.vue";
|
||||||
@@ -839,9 +1033,17 @@ import { includeDevToolkitRoutes } from "@/utils/includeDevToolkitRoutes";
|
|||||||
import { AccountSettings, isApiError } from "@/interfaces/accountView";
|
import { AccountSettings, isApiError } from "@/interfaces/accountView";
|
||||||
import {
|
import {
|
||||||
NotificationService,
|
NotificationService,
|
||||||
configureNativeFetcherIfReady,
|
authorizeSmsAlertSearch,
|
||||||
buildDualScheduleConfig,
|
buildDualScheduleConfig,
|
||||||
|
configureNativeFetcherIfReady,
|
||||||
|
deleteSmsPhone,
|
||||||
|
getNotificationApiBaseUrl,
|
||||||
|
listSmsPhones,
|
||||||
|
localTimeTextToUtc,
|
||||||
|
revokeSmsAlertSearch,
|
||||||
|
smsErrorMessage,
|
||||||
syncStarredPlansToNativePlugin,
|
syncStarredPlansToNativePlugin,
|
||||||
|
UnsupportedIdentityError,
|
||||||
} from "@/services/notifications";
|
} from "@/services/notifications";
|
||||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||||
// Profile data interface (inlined from ProfileService)
|
// Profile data interface (inlined from ProfileService)
|
||||||
@@ -860,7 +1062,11 @@ interface PushNotificationPermissionRef {
|
|||||||
open: (
|
open: (
|
||||||
title: string,
|
title: string,
|
||||||
callback: (success: boolean, timeText: string, message?: string) => void,
|
callback: (success: boolean, timeText: string, message?: string) => void,
|
||||||
options?: { skipSchedule?: boolean; rolloverIntervalMinutes?: number },
|
options?: {
|
||||||
|
skipSchedule?: boolean;
|
||||||
|
rolloverIntervalMinutes?: number;
|
||||||
|
timeOnly?: boolean;
|
||||||
|
},
|
||||||
) => void;
|
) => void;
|
||||||
hourInput?: string;
|
hourInput?: string;
|
||||||
minuteInput?: string;
|
minuteInput?: string;
|
||||||
@@ -870,6 +1076,7 @@ interface PushNotificationPermissionRef {
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
|
ChoiceButtonDialog,
|
||||||
EntityIcon,
|
EntityIcon,
|
||||||
ImageMethodDialog,
|
ImageMethodDialog,
|
||||||
LMap,
|
LMap,
|
||||||
@@ -877,6 +1084,7 @@ interface PushNotificationPermissionRef {
|
|||||||
LTileLayer,
|
LTileLayer,
|
||||||
PushNotificationPermission,
|
PushNotificationPermission,
|
||||||
QuickNav,
|
QuickNav,
|
||||||
|
SmsVerificationDialog,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
UserNameDialog,
|
UserNameDialog,
|
||||||
DataExportSection,
|
DataExportSection,
|
||||||
@@ -900,6 +1108,11 @@ export default class AccountViewView extends Vue {
|
|||||||
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
|
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
|
||||||
readonly isDev: boolean = includeDevToolkitRoutes;
|
readonly isDev: boolean = includeDevToolkitRoutes;
|
||||||
|
|
||||||
|
// Server URLs section starts collapsed
|
||||||
|
showServerSettings: boolean = false;
|
||||||
|
// Read-only here; the Notification Debug screen can override it
|
||||||
|
notifyApiServer: string = getNotificationApiBaseUrl();
|
||||||
|
|
||||||
// Identity and settings properties
|
// Identity and settings properties
|
||||||
activeDid: string = "";
|
activeDid: string = "";
|
||||||
apiServer: string = "";
|
apiServer: string = "";
|
||||||
@@ -931,6 +1144,18 @@ export default class AccountViewView extends Vue {
|
|||||||
// Notification properties
|
// Notification properties
|
||||||
notifyingNewActivity: boolean = false;
|
notifyingNewActivity: boolean = false;
|
||||||
notifyingNewActivityTime: string = "";
|
notifyingNewActivityTime: string = "";
|
||||||
|
// SMS channel (opt-in), backed by the notify-api's `/notify-sms` routes.
|
||||||
|
// Settings hold what the service cannot report back: it has a route to list
|
||||||
|
// registered numbers but none to read an authorization, so the chosen time
|
||||||
|
// and the paused/active distinction live here.
|
||||||
|
/** Master switch: the SMS channel is on (requires a verified phone). */
|
||||||
|
smsEnabled: boolean = false;
|
||||||
|
/** Verified phone number; kept when smsEnabled turns off, cleared by Forget. */
|
||||||
|
notifyingNewActivitySmsPhone: string = "";
|
||||||
|
notifyingNewActivitySms: boolean = false;
|
||||||
|
notifyingNewActivitySmsTime: string = "";
|
||||||
|
/** Guard: one in-flight `/notify-sms` write at a time. */
|
||||||
|
smsRequestInProgress: boolean = false;
|
||||||
notifyingReminder: boolean = false;
|
notifyingReminder: boolean = false;
|
||||||
notifyingReminderMessage: string = "";
|
notifyingReminderMessage: string = "";
|
||||||
notifyingReminderTime: string = "";
|
notifyingReminderTime: string = "";
|
||||||
@@ -1117,6 +1342,13 @@ export default class AccountViewView extends Vue {
|
|||||||
!!settings.searchBoxes && settings.searchBoxes.length > 0;
|
!!settings.searchBoxes && settings.searchBoxes.length > 0;
|
||||||
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
|
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
|
||||||
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
|
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
|
||||||
|
this.notifyingNewActivitySmsPhone =
|
||||||
|
settings.notifyingNewActivitySmsPhone || "";
|
||||||
|
this.notifyingNewActivitySmsTime =
|
||||||
|
settings.notifyingNewActivitySmsTime || "";
|
||||||
|
this.notifyingNewActivitySms = !!this.notifyingNewActivitySmsTime;
|
||||||
|
this.smsEnabled =
|
||||||
|
!!settings.smsNotificationsEnabled && !!this.notifyingNewActivitySmsPhone;
|
||||||
this.notifyingReminder = !!settings.notifyingReminderTime;
|
this.notifyingReminder = !!settings.notifyingReminderTime;
|
||||||
this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
|
this.notifyingReminderMessage = settings.notifyingReminderMessage || "";
|
||||||
this.notifyingReminderTime = settings.notifyingReminderTime || "";
|
this.notifyingReminderTime = settings.notifyingReminderTime || "";
|
||||||
@@ -1145,6 +1377,63 @@ export default class AccountViewView extends Vue {
|
|||||||
void syncStarredPlansToNativePlugin(planIds);
|
void syncStarredPlansToNativePlugin(planIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.notifyingNewActivitySmsPhone) {
|
||||||
|
void this.reconcileSmsPhoneFromServer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the active identity signs through a passkey rather than a key. */
|
||||||
|
private async activeIdentityIsPasskey(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const account = await retrieveAccountMetadata(this.activeDid);
|
||||||
|
return !account?.identity && !!account?.passkeyCredIdHex;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("[AccountViewView] Could not read account metadata", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the stored number against the service's own list.
|
||||||
|
*
|
||||||
|
* The service can drop a verification without the app hearing about it -- a
|
||||||
|
* `STOP` text unverifies every registration of that number, whoever sent it
|
||||||
|
* -- so a number this view shows as verified may not be. Only a definite
|
||||||
|
* answer changes anything: a failed call leaves the stored state alone
|
||||||
|
* rather than presenting a network problem as an opt-out.
|
||||||
|
*/
|
||||||
|
async reconcileSmsPhoneFromServer(): Promise<void> {
|
||||||
|
if (await this.activeIdentityIsPasskey()) {
|
||||||
|
// Every signature costs a WebAuthn prompt, which nobody expects from
|
||||||
|
// merely opening this page.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { phones } = await listSmsPhones(this.activeDid);
|
||||||
|
const stored = this.notifyingNewActivitySmsPhone;
|
||||||
|
const match = phones.find((phone) => phone.phoneNumber === stored);
|
||||||
|
if (match?.verified) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"[AccountViewView] SMS number no longer verified server-side; clearing",
|
||||||
|
);
|
||||||
|
await this.$saveSettings({
|
||||||
|
notifyingNewActivitySmsPhone: "",
|
||||||
|
notifyingNewActivitySmsTime: "",
|
||||||
|
smsNotificationsEnabled: false,
|
||||||
|
});
|
||||||
|
this.notifyingNewActivitySmsPhone = "";
|
||||||
|
this.smsEnabled = false;
|
||||||
|
this.notifyingNewActivitySms = false;
|
||||||
|
this.notifyingNewActivitySmsTime = "";
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
"[AccountViewView] Could not check SMS registrations; keeping stored state",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
// call fn, copy text to the clipboard, then redo fn after 2 seconds
|
||||||
@@ -1216,9 +1505,11 @@ export default class AccountViewView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async showNewActivityNotificationInfo(): Promise<void> {
|
async showNewActivityNotificationInfo(isSms: boolean): Promise<void> {
|
||||||
this.notify.confirm(
|
this.notify.confirm(
|
||||||
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_INFO,
|
isSms
|
||||||
|
? ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_SMS_INFO
|
||||||
|
: ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_INFO,
|
||||||
async () => {
|
async () => {
|
||||||
await (this.$router as Router).push({
|
await (this.$router as Router).push({
|
||||||
name: "help-notification-types",
|
name: "help-notification-types",
|
||||||
@@ -1266,6 +1557,317 @@ export default class AccountViewView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the SMS channel as a whole ("Enable Text Messages" / "SMS Text
|
||||||
|
* Message" master switch).
|
||||||
|
*
|
||||||
|
* Enabling first shows an opt-in choice (see more info / enter phone
|
||||||
|
* number / cancel) before any phone number is requested. Only "Enter
|
||||||
|
* Phone Number" proceeds to SmsVerificationDialog, where the toggle turns
|
||||||
|
* on once the texted code is confirmed.
|
||||||
|
*
|
||||||
|
* Turning the toggle off revokes the alert authorization -- which is what
|
||||||
|
* actually stops the texts -- while leaving the number verified with the
|
||||||
|
* service, so re-enabling costs nothing. "Forget Phone Number" is the
|
||||||
|
* destructive path that also deletes the registration and so forces a fresh
|
||||||
|
* code next time.
|
||||||
|
*/
|
||||||
|
async toggleSmsEnabled(): Promise<void> {
|
||||||
|
if (this.smsRequestInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.smsEnabled) {
|
||||||
|
// Pause the channel; the verified number stays for easy re-enable.
|
||||||
|
// The revoke runs even when no alert is showing as on, in case the
|
||||||
|
// service still holds a batch this app lost track of -- but only a
|
||||||
|
// failure that leaves texts actually arriving blocks the toggle.
|
||||||
|
const hadAlerts = this.notifyingNewActivitySms;
|
||||||
|
const stopped = await this.stopSmsAlerts(!hadAlerts);
|
||||||
|
if (!stopped && hadAlerts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.$saveSettings({
|
||||||
|
notifyingNewActivitySmsTime: "",
|
||||||
|
smsNotificationsEnabled: false,
|
||||||
|
});
|
||||||
|
this.smsEnabled = false;
|
||||||
|
this.notifyingNewActivitySms = false;
|
||||||
|
this.notifyingNewActivitySmsTime = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.notifyingNewActivitySmsPhone) {
|
||||||
|
// Number already verified: just switch the channel back on. Nothing is
|
||||||
|
// authorized yet -- the per-notification toggle is what asks for texts.
|
||||||
|
await this.$saveSettings({ smsNotificationsEnabled: true });
|
||||||
|
this.smsEnabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(this.$refs.smsOptInChoiceDialog as ChoiceButtonDialog).open({
|
||||||
|
title: "Enable Text Messages",
|
||||||
|
text: ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.SMS_OPT_IN_CHOICE,
|
||||||
|
option1Text: "See More Info",
|
||||||
|
option2Text: "Enter Phone Number",
|
||||||
|
onOption1: async () => {
|
||||||
|
await (this.$router as Router).push({
|
||||||
|
name: "help-notification-types",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onOption2: () => {
|
||||||
|
(this.$refs.smsVerificationDialog as SmsVerificationDialog).open(
|
||||||
|
this.activeDid,
|
||||||
|
async (success: boolean, phoneNumber?: string) => {
|
||||||
|
if (success && phoneNumber) {
|
||||||
|
await this.$saveSettings({
|
||||||
|
notifyingNewActivitySmsPhone: phoneNumber,
|
||||||
|
smsNotificationsEnabled: true,
|
||||||
|
});
|
||||||
|
this.notifyingNewActivitySmsPhone = phoneNumber;
|
||||||
|
this.smsEnabled = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget the verified phone number entirely: the authorization is revoked,
|
||||||
|
* the registration is deleted from the notify-api, and every SMS
|
||||||
|
* notification becomes unavailable until a number is verified again.
|
||||||
|
*/
|
||||||
|
async forgetSmsPhoneNumber(): Promise<void> {
|
||||||
|
this.notify.confirm(
|
||||||
|
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.SMS_FORGET_PHONE_CONFIRM,
|
||||||
|
async () => {
|
||||||
|
if (this.smsRequestInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const phoneNumber = this.notifyingNewActivitySmsPhone;
|
||||||
|
this.smsRequestInProgress = true;
|
||||||
|
try {
|
||||||
|
// Revoke first: a deleted number with a live batch would leave the
|
||||||
|
// scheduler listing an identity it can no longer text.
|
||||||
|
await revokeSmsAlertSearch(this.activeDid);
|
||||||
|
if (phoneNumber) {
|
||||||
|
await deleteSmsPhone(this.activeDid, phoneNumber);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"[AccountViewView] Could not forget SMS phone number:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
smsErrorMessage(
|
||||||
|
error,
|
||||||
|
"Could not remove your phone number. Please try again.",
|
||||||
|
),
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
this.smsRequestInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.$saveSettings({
|
||||||
|
notifyingNewActivitySmsPhone: "",
|
||||||
|
notifyingNewActivitySmsTime: "",
|
||||||
|
smsNotificationsEnabled: false,
|
||||||
|
});
|
||||||
|
this.notifyingNewActivitySmsPhone = "";
|
||||||
|
this.smsEnabled = false;
|
||||||
|
this.notifyingNewActivitySms = false;
|
||||||
|
this.notifyingNewActivitySmsTime = "";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the "New Activity Text" notification (only reachable while the
|
||||||
|
* SMS channel is enabled).
|
||||||
|
*
|
||||||
|
* On: authorize the notify-api to run this identity's daily alertSearch and
|
||||||
|
* text the result. Off: revoke that authorization, which is the only thing
|
||||||
|
* that stops the texts.
|
||||||
|
*/
|
||||||
|
async showNewActivitySmsNotificationChoice(): Promise<void> {
|
||||||
|
if (this.smsRequestInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.notifyingNewActivitySms) {
|
||||||
|
const stopped = await this.stopSmsAlerts();
|
||||||
|
if (!stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.$saveSettings({ notifyingNewActivitySmsTime: "" });
|
||||||
|
this.notifyingNewActivitySms = false;
|
||||||
|
this.notifyingNewActivitySmsTime = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.promptSmsTimeAndEnable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke this identity's SMS alert authorization.
|
||||||
|
*
|
||||||
|
* @param quiet when true, a failure is logged but not shown -- for the
|
||||||
|
* speculative revoke of an authorization this app does not believe exists
|
||||||
|
* @returns true when the service confirmed it, false when the caller should
|
||||||
|
* leave the toggle where it was
|
||||||
|
*/
|
||||||
|
private async stopSmsAlerts(quiet: boolean = false): Promise<boolean> {
|
||||||
|
this.smsRequestInProgress = true;
|
||||||
|
try {
|
||||||
|
await revokeSmsAlertSearch(this.activeDid);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[AccountViewView] Could not revoke SMS alerts:", error);
|
||||||
|
if (!quiet) {
|
||||||
|
this.notify.error(
|
||||||
|
smsErrorMessage(
|
||||||
|
error,
|
||||||
|
"Could not turn text messages off. Please try again.",
|
||||||
|
),
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.smsRequestInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt for the send time and hand the notify-api a fresh authorization.
|
||||||
|
*
|
||||||
|
* The time picker is used for its clock only: nothing is scheduled on this
|
||||||
|
* device, since the texts come from the server.
|
||||||
|
*/
|
||||||
|
async promptSmsTimeAndEnable(): Promise<void> {
|
||||||
|
(this.$refs.pushNotificationPermission as PushNotificationPermission).open(
|
||||||
|
DAILY_CHECK_TITLE,
|
||||||
|
async (success: boolean, timeText: string) => {
|
||||||
|
if (!success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const authorized = await this.authorizeSmsAlerts(timeText);
|
||||||
|
if (!authorized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.$saveSettings({ notifyingNewActivitySmsTime: timeText });
|
||||||
|
this.notifyingNewActivitySms = true;
|
||||||
|
this.notifyingNewActivitySmsTime = timeText;
|
||||||
|
},
|
||||||
|
{ timeOnly: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit the time for the SMS delivery channel.
|
||||||
|
*
|
||||||
|
* The service stores the hour on the JWT batch and has no route to change it
|
||||||
|
* alone, so a new time means a new batch. That is the documented correction
|
||||||
|
* path -- it is also how a user fixes the hour after a daylight-saving
|
||||||
|
* change, since the stored time is UTC and does not follow the wall clock.
|
||||||
|
*/
|
||||||
|
async editNewActivitySmsNotification(): Promise<void> {
|
||||||
|
const dialog = this.$refs
|
||||||
|
.pushNotificationPermission as PushNotificationPermission;
|
||||||
|
|
||||||
|
dialog.open(
|
||||||
|
DAILY_CHECK_TITLE,
|
||||||
|
async (success: boolean, timeText: string) => {
|
||||||
|
if (!success) return;
|
||||||
|
const authorized = await this.authorizeSmsAlerts(timeText);
|
||||||
|
if (!authorized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.$saveSettings({ notifyingNewActivitySmsTime: timeText });
|
||||||
|
this.notifyingNewActivitySmsTime = timeText;
|
||||||
|
this.notify.success(
|
||||||
|
"New Activity SMS time updated.",
|
||||||
|
TIMEOUTS.STANDARD,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeOnly: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-populate the dialog with the current SMS time
|
||||||
|
setTimeout(() => {
|
||||||
|
const timeMatch = this.notifyingNewActivitySmsTime.match(
|
||||||
|
/(\d+):(\d+)\s*(AM|PM)/i,
|
||||||
|
);
|
||||||
|
if (timeMatch) {
|
||||||
|
let hour = parseInt(timeMatch[1], 10);
|
||||||
|
const minute = timeMatch[2];
|
||||||
|
const isAm = timeMatch[3].toUpperCase() === "AM";
|
||||||
|
if (hour === 12) {
|
||||||
|
hour = 12;
|
||||||
|
} else if (hour > 12) {
|
||||||
|
hour = hour - 12;
|
||||||
|
}
|
||||||
|
const dialogComponent =
|
||||||
|
dialog as unknown as PushNotificationPermissionRef;
|
||||||
|
if (dialogComponent) {
|
||||||
|
dialogComponent.hourInput = hour.toString();
|
||||||
|
dialogComponent.minuteInput = minute;
|
||||||
|
dialogComponent.hourAm = isAm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a delegated JWT batch and upload it with the chosen hour, converted
|
||||||
|
* to the UTC pair the notify-api schedules on.
|
||||||
|
*
|
||||||
|
* Minting 100 signatures takes a moment on a phone, so the toggle is held
|
||||||
|
* until the service has the batch.
|
||||||
|
*
|
||||||
|
* @param timeText 12-hour local reading from the time picker
|
||||||
|
* @returns true when the service accepted the authorization
|
||||||
|
*/
|
||||||
|
private async authorizeSmsAlerts(timeText: string): Promise<boolean> {
|
||||||
|
const utcTime = localTimeTextToUtc(timeText);
|
||||||
|
if (!utcTime) {
|
||||||
|
logger.error(
|
||||||
|
"[AccountViewView] Unparseable SMS notification time:",
|
||||||
|
timeText,
|
||||||
|
);
|
||||||
|
this.notify.error(
|
||||||
|
"That time did not make sense. Please pick it again.",
|
||||||
|
TIMEOUTS.STANDARD,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.smsRequestInProgress = true;
|
||||||
|
try {
|
||||||
|
await authorizeSmsAlertSearch(
|
||||||
|
this.activeDid,
|
||||||
|
utcTime.notifyHourUtc,
|
||||||
|
utcTime.notifyMinuteUtc,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[AccountViewView] Could not authorize SMS alerts:", error);
|
||||||
|
this.notify.error(
|
||||||
|
error instanceof UnsupportedIdentityError
|
||||||
|
? error.message
|
||||||
|
: smsErrorMessage(
|
||||||
|
error,
|
||||||
|
"Could not turn text messages on. Please try again.",
|
||||||
|
),
|
||||||
|
TIMEOUTS.LONG,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.smsRequestInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure native fetcher, sync starred plans, and schedule API-driven dual notification.
|
* Configure native fetcher, sync starred plans, and schedule API-driven dual notification.
|
||||||
*/
|
*/
|
||||||
@@ -1741,6 +2343,8 @@ export default class AccountViewView extends Vue {
|
|||||||
this.notifyingReminder = false;
|
this.notifyingReminder = false;
|
||||||
this.notifyingReminderMessage = "";
|
this.notifyingReminderMessage = "";
|
||||||
this.notifyingReminderTime = "";
|
this.notifyingReminderTime = "";
|
||||||
|
// The SMS channel is deliberately untouched: this runs when the browser's
|
||||||
|
// web-push subscription has gone missing, and texts do not travel over it.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2101,6 +2705,13 @@ export default class AccountViewView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showNotifyServerInfo(): void {
|
||||||
|
this.notify.info(
|
||||||
|
ACCOUNT_VIEW_CONSTANTS.INFO.NOTIFY_SERVER_INFO,
|
||||||
|
TIMEOUTS.VERY_LONG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async saveProfile(): Promise<void> {
|
async saveProfile(): Promise<void> {
|
||||||
this.savingProfile = true;
|
this.savingProfile = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -39,31 +39,76 @@
|
|||||||
something, so you can record thanks in here.
|
something, so you can record thanks in here.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
This is a reliable message, but it doesn't contain any details about
|
This is your own message activated in the app, but it doesn't contain any details about
|
||||||
activity that might be especially interesting to you.
|
activity that might be especially interesting to you.
|
||||||
|
Note that the timing is not precise: the device may deliver it a bit later than scheduled.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold mt-4">New Activity Notifications</h2>
|
<h2 class="text-xl font-semibold mt-4">New Activity Notifications -- In App</h2>
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
The New Activity Notification will be sent to you when there is new, relevant activity
|
The New Activity Notification will be activated in your app when there is new, relevant
|
||||||
for you.
|
activity for you.
|
||||||
It will only trigger if something involves you or a project of interest; it will not
|
It will only trigger if something involves you or a project of interest; it will not
|
||||||
bug you for other, general activity.
|
bug you for other, general activity.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
|
||||||
This type is not as reliable as a Reminder Notification because mobile devices often
|
|
||||||
suppress such notifications to save battery. (If you want to quickly check for relevant
|
|
||||||
activity daily, use the Reminder Notification and open the app and look for a large green
|
|
||||||
button that points out new activity that is personal to you. We are working on other
|
|
||||||
ways to notify you more reliably.
|
|
||||||
<router-link class="text-blue-500" to="/help">
|
|
||||||
go here to follow us or contact us
|
|
||||||
<font-awesome icon="chevron-right" class="fa-fw"></font-awesome>
|
|
||||||
</router-link>.)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold mt-4">New Activity Notifications -- Texts From A Server</h2>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
This New Activity Notification SMS will be sent to you when there is new, relevant
|
||||||
|
activity for you.
|
||||||
|
It will only trigger if something involves you or a project of interest; it will not
|
||||||
|
bug you for other, general activity.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2">Notes and caveats for text messages:</p>
|
||||||
|
<ul class="list-disc list-outside ml-5 mt-1">
|
||||||
|
<li>
|
||||||
|
Text messages are opt-in: they are only sent after you enter your
|
||||||
|
phone number and confirm it with a verification code.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Standard message and data rates from your carrier may apply.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Message frequency is at most one New Activity text per day, at or
|
||||||
|
after the time you choose. The time is stored in UTC, so it shifts
|
||||||
|
by an hour when daylight saving starts or ends; set it again to put
|
||||||
|
it back where you want it.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Identities that sign with a passkey cannot turn this on, because
|
||||||
|
the server needs credentials it can use while your phone is asleep.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
You can stop at any time: turn off the toggle in your settings, use
|
||||||
|
"Forget Phone Number" to remove your number entirely, or reply STOP
|
||||||
|
to any message. Reply HELP for help.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Delivery is not guaranteed or precisely timed; carriers may delay
|
||||||
|
or drop messages, and neither we nor the carriers are liable for
|
||||||
|
late or undelivered texts.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Only US phone numbers are currently supported.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Your phone number is used only to send you these notifications; it
|
||||||
|
is stored on the notification server and is deleted when you choose
|
||||||
|
"Forget Phone Number".
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
See our
|
||||||
|
<a href="https://timesafari.app/help-terms" target="_blank" class="text-blue-500">
|
||||||
|
Terms & Conditions and Privacy Policy docs here.
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<!-- eslint-enable -->
|
<!-- eslint-enable -->
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="p-6 pb-24 max-w-3xl mx-auto" role="main">
|
<main id="Content" class="p-6 pb-24 max-w-3xl mx-auto" role="main">
|
||||||
<div class="flex items-center gap-4 mb-6">
|
<!-- Sub View Heading -->
|
||||||
<h1 class="text-2xl font-bold leading-none">Notification Debug</h1>
|
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
||||||
<router-link
|
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
||||||
:to="{ name: 'account' }"
|
Notification Debug
|
||||||
class="ms-auto text-sm text-blue-600"
|
</h1>
|
||||||
|
|
||||||
|
<!-- Back -->
|
||||||
|
<a
|
||||||
|
class="order-first text-lg text-center leading-none p-1"
|
||||||
|
@click="$router.go(-1)"
|
||||||
>
|
>
|
||||||
Back to Account
|
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
|
||||||
</router-link>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
Vendored
+30
@@ -1,5 +1,35 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The triple-slash line above loads Vite's client types
|
||||||
|
* (node_modules/vite/client.d.ts, which references types/importMeta.d.ts).
|
||||||
|
* That file declares its own ImportMetaEnv, and TypeScript merges it with the
|
||||||
|
* interface below, so import.meta.env also has these fields that Vite fills in
|
||||||
|
* at dev and build time:
|
||||||
|
*
|
||||||
|
* BASE_URL: string the `base` config option
|
||||||
|
* MODE: string the --mode value; defaults to "development" for
|
||||||
|
* `vite dev` and "production" for `vite build`
|
||||||
|
* (this repo also uses "test" and "capacitor")
|
||||||
|
* DEV: boolean process.env.NODE_ENV !== "production"
|
||||||
|
* PROD: boolean process.env.NODE_ENV === "production"
|
||||||
|
* SSR: boolean true when running server-side rendering
|
||||||
|
*
|
||||||
|
* More about modes: https://vite.dev/guide/env-and-mode
|
||||||
|
*
|
||||||
|
* DEV and PROD follow NODE_ENV, not MODE. Vite sets NODE_ENV to
|
||||||
|
* "development" for `vite dev` and "production" for `vite build`, but only
|
||||||
|
* when the shell has not already set it. scripts/build-web.sh exports
|
||||||
|
* NODE_ENV=test or development for non-production web builds, so DEV is true
|
||||||
|
* in those builds. The Android, iOS, and Electron scripts leave NODE_ENV
|
||||||
|
* unset, so DEV is false in every one of their builds, including dev builds.
|
||||||
|
*
|
||||||
|
* Vite's ImportMetaEnv also has an index signature `[key: string]: any`, so
|
||||||
|
* any VITE_* variable type-checks without being declared here. Declaring one
|
||||||
|
* below only narrows its type from `any`.
|
||||||
|
*
|
||||||
|
* src/env.d.ts repeats the same vite/client reference.
|
||||||
|
*/
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_APP_TITLE: string;
|
readonly VITE_APP_TITLE: string;
|
||||||
// more env variables...
|
// more env variables...
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ test('Confirm test API setting (may fail if you are running your own Time Safari
|
|||||||
// Load account view
|
// Load account view
|
||||||
await page.goto('./account');
|
await page.goto('./account');
|
||||||
await page.getByTestId('advancedSettings').click();
|
await page.getByTestId('advancedSettings').click();
|
||||||
|
await page.getByTestId('serverUrlsToggle').click();
|
||||||
|
|
||||||
// look into the config file: if it starts Time Safari, it might say which server it should set by default
|
// look into the config file: if it starts Time Safari, it might say which server it should set by default
|
||||||
const webServer = testInfo.config.webServer;
|
const webServer = testInfo.config.webServer;
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ test('Record an offer', async ({ page }) => {
|
|||||||
|
|
||||||
// click on the number of new offers to go to the list page
|
// click on the number of new offers to go to the list page
|
||||||
await offerNumElem.click();
|
await offerNumElem.click();
|
||||||
await expect(page.getByText('New Offers To Your Projects', { exact: true })).toBeVisible();
|
// "Offer" for exactly one new offer, "Offers" otherwise
|
||||||
|
await expect(page.getByText(/^New Offers? To Your Projects$/)).toBeVisible();
|
||||||
// get the icon child of the showOffersToUserProjects
|
// get the icon child of the showOffersToUserProjects
|
||||||
await page.getByTestId('showOffersToUserProjects').locator('div > svg.fa-chevron-right').click();
|
await page.getByTestId('showOffersToUserProjects').locator('div > svg.fa-chevron-right').click();
|
||||||
await expect(page.getByText(description)).toBeVisible();
|
await expect(page.getByText(description)).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user