diff --git a/.husky/commit-msg b/.husky/commit-msg
index 4b8c242d..cc1ce805 100755
--- a/.husky/commit-msg
+++ b/.husky/commit-msg
@@ -1,9 +1,7 @@
-#!/usr/bin/env bash
#
# Husky Commit Message Hook
# Validates commit message format using commitlint
#
-. "$(dirname -- "$0")/_/husky.sh"
# Run commitlint but don't fail the commit (|| true)
# This provides helpful feedback without blocking commits
diff --git a/.husky/pre-commit b/.husky/pre-commit
index b31dbcc0..f122047f 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,9 +1,7 @@
-#!/usr/bin/env bash
#
# Husky Pre-commit Hook
# Runs lint-fix and Build Architecture Guard on staged files
#
-. "$(dirname -- "$0")/_/husky.sh"
echo "π Running pre-commit hooks..."
diff --git a/.husky/pre-push b/.husky/pre-push
index 2d79fde4..67780a48 100755
--- a/.husky/pre-push
+++ b/.husky/pre-push
@@ -1,9 +1,7 @@
-#!/usr/bin/env bash
#
# Husky Pre-push Hook
# Runs Build Architecture Guard to check commits being pushed
#
-. "$(dirname -- "$0")/_/husky.sh"
echo "π Running Build Architecture Guard (pre-push)..."
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9514f07d..1fb1bbff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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).
+## [?] - 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
### Added
- Device wake-up for notifications
diff --git a/README.md b/README.md
index 2af6c457..0f4688f9 100644
--- a/README.md
+++ b/README.md
@@ -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).
+## Tests
+
+```
+npm run test:web
+
+# ... or do every check:
+npm run test:all
+```
+
## π‘οΈ Build Architecture Guard
This project uses **Husky Git hooks** to protect the build system
diff --git a/doc/local-android-testing-ngrok.md b/doc/local-android-testing-ngrok.md
index 00a9754d..38c7788e 100644
--- a/doc/local-android-testing-ngrok.md
+++ b/doc/local-android-testing-ngrok.md
@@ -14,24 +14,24 @@ End-to-end flow when testing New Activity / silent wake on a physical Android ph
```text
βββββββββββββββββββββββ HTTPS ββββββββββββββββββββββββ
-β Mac (localhost) β ββββββββββββββ β ngrok edge β
-β notification- β tunnel β (public HTTPS URL) β
+β Mac (localhost) β ββββββββββββββ β ngrok edge β
+β notification- β tunnel β (public HTTPS URL) β
β wakeup-service β ββββββββββββ¬ββββββββββββ
ββββββββββββ¬βββββββββββ β
- β β fetch
- β POST /notifications/refresh β POST /notifications/register
- β βΌ
+ β β fetch
+ β POST /notifications/refresh β POST /notifications/register
+ β βΌ
β ββββββββββββββββββββββββ
- β β crowd-funder-for- β
- β β time-pwa (Capacitor β
- β β Android on device) β
+ β β crowd-funder-for- β
+ β β time-pwa (Capacitor β
+ β β Android on device) β
β ββββββββββββ¬ββββββββββββ
β β
β FCM data message (WAKEUP_PING) β daily-notification-plugin
βΌ βΌ (local schedule replace)
βββββββββββββββββββββββ ββββββββββββββββββββββββ
-β Firebase Cloud β ββFCMβββββββββΊ β Android device β
-β Messaging β direct β app.timesafari.app β
+β Firebase Cloud β ββFCMβββββββββΊ β Android device β
+β Messaging β direct β app.timesafari.app β
βββββββββββββββββββββββ ββββββββββββββββββββββββ
```
@@ -578,7 +578,7 @@ curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
3. Confirm **Backend Status β URL** matches the saved ngrok host.
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`.
---
diff --git a/doc/local-ios-testing-ngrok.md b/doc/local-ios-testing-ngrok.md
index 12d982a9..c091f067 100644
--- a/doc/local-ios-testing-ngrok.md
+++ b/doc/local-ios-testing-ngrok.md
@@ -281,6 +281,7 @@ Before ngrok end-to-end testing, confirm:
## 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.
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
diff --git a/doc/sms-notifications.md b/doc/sms-notifications.md
new file mode 100644
index 00000000..7a3c8001
--- /dev/null
+++ b/doc/sms-notifications.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β
+```
diff --git a/package-lock.json b/package-lock.json
index bf3ef7ea..3e190ce7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -153,7 +153,8 @@
"ts-jest": "^29.4.0",
"tsx": "^4.20.4",
"typescript": "~5.2.2",
- "vite": "^5.2.0"
+ "vite": "^5.2.0",
+ "vue-tsc": "^2.1.10"
}
},
"node_modules/@adraffy/ens-normalize": {
@@ -11605,6 +11606,35 @@
"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": {
"version": "0.10.1",
"license": "MIT",
@@ -11663,6 +11693,17 @@
"@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": {
"version": "6.6.4",
"license": "MIT"
@@ -11890,6 +11931,31 @@
"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": {
"version": "3.5.13",
"license": "MIT",
@@ -12155,6 +12221,13 @@
"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": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz",
@@ -15004,6 +15077,13 @@
"version": "1.11.13",
"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": {
"version": "4.3.4",
"license": "MIT",
@@ -23436,6 +23516,13 @@
"license": "Apache-2.0",
"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": {
"version": "4.0.6",
"license": "MIT",
@@ -30292,6 +30379,13 @@
"optional": 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": {
"version": "3.5.13",
"license": "MIT",
@@ -30433,6 +30527,24 @@
"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": {
"version": "1.0.8",
"devOptional": true,
diff --git a/package.json b/package.json
index dddf9695..a8addc95 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"type-safety-check": "./scripts/type-safety-check.sh",
"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",
"test:prerequisites": "node scripts/check-prerequisites.js",
"check:dependencies": "./scripts/check-dependencies.sh",
@@ -282,6 +283,7 @@
"ts-jest": "^29.4.0",
"tsx": "^4.20.4",
"typescript": "~5.2.2",
- "vite": "^5.2.0"
+ "vite": "^5.2.0",
+ "vue-tsc": "^2.1.10"
}
}
diff --git a/src/components/ChoiceButtonDialog.vue b/src/components/ChoiceButtonDialog.vue
index 21624aae..3dae5622 100644
--- a/src/components/ChoiceButtonDialog.vue
+++ b/src/components/ChoiceButtonDialog.vue
@@ -25,6 +25,7 @@
{{ text }}
@@ -32,6 +33,7 @@
@@ -39,6 +41,7 @@
diff --git a/src/components/PushNotificationPermission.vue b/src/components/PushNotificationPermission.vue
index 312789d3..18d0583c 100644
--- a/src/components/PushNotificationPermission.vue
+++ b/src/components/PushNotificationPermission.vue
@@ -159,6 +159,13 @@ export default class PushNotificationPermission extends Vue {
pushType = "";
/** When true, dialog only returns time/message to parent; parent does cancel+schedule (avoids double schedule on edit). */
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. */
rolloverIntervalMinutesForSchedule: number | undefined = undefined;
serviceWorkerReady = false;
@@ -174,14 +181,27 @@ export default class PushNotificationPermission extends Vue {
async open(
pushType: string,
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.isVisible = true;
this.pushType = pushType;
this.skipScheduleForOpen = options?.skipSchedule ?? false;
+ this.timeOnlyForOpen = options?.timeOnly ?? false;
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
if (this.isNativePlatform) {
logger.debug(
@@ -589,8 +609,8 @@ export default class PushNotificationPermission extends Vue {
* For native platforms, always returns true (no VAPID needed)
*/
get isSystemReady(): boolean {
- if (this.isNativePlatform) {
- return true; // Native doesn't need VAPID/service worker
+ if (this.isNativePlatform || this.timeOnlyForOpen) {
+ return true; // Neither needs VAPID/service worker
}
return this.serviceWorkerReady && !!this.vapidKey;
}
@@ -601,8 +621,8 @@ export default class PushNotificationPermission extends Vue {
* For native platforms, always returns true (no VAPID needed)
*/
get canShowNotificationForm(): boolean {
- if (this.isNativePlatform) {
- return true; // Native doesn't need VAPID/service worker
+ if (this.isNativePlatform || this.timeOnlyForOpen) {
+ return true; // Neither needs VAPID/service worker
}
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).
*/
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) {
await this.turnOnNativeNotifications();
} else {
diff --git a/src/components/SmsVerificationDialog.vue b/src/components/SmsVerificationDialog.vue
new file mode 100644
index 00000000..8c955f79
--- /dev/null
+++ b/src/components/SmsVerificationDialog.vue
@@ -0,0 +1,305 @@
+
+
+
+
+
+
Verify Your Phone
+
+ 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.
+
+
+
+ Mobile number
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+ {{ sending ? "Sendingβ¦" : "Send Code" }}
+
+
+ Cancel
+
+
+
+
+
+
+
+
Enter the Code
+
+ We sent a 6-digit code to
+ {{ sentTo }} . Enter it below to finish verifying your number.
+
+
+
+ Verification code
+
+
+
+ {{ errorMessage }}
+
+
+
+
+ {{ sending ? "Resendingβ¦" : "Resend code" }}
+
+
+
+
+
+
+
+ {{ verifying ? "Verifyingβ¦" : "Verify" }}
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
diff --git a/src/components/dev/NotificationDebugPanel.vue b/src/components/dev/NotificationDebugPanel.vue
index 16beb7c9..df86e799 100644
--- a/src/components/dev/NotificationDebugPanel.vue
+++ b/src/components/dev/NotificationDebugPanel.vue
@@ -535,16 +535,10 @@ function formatRealWakeupStatusMessage(
>,
): string {
if (result.ok) {
- const body =
- typeof result.responseBody === "object" && result.responseBody !== null
- ? (result.responseBody as Record)
- : null;
const parts = ["Real WAKEUP_PING sent via backend."];
- if (typeof body?.message === "string" && body.message.trim()) {
- parts.push(body.message.trim());
- }
- if (typeof body?.tokenSuffix === "string" && body.tokenSuffix.trim()) {
- parts.push(`token β¦${body.tokenSuffix.trim()}`);
+ const suffix = result.responseBody?.fcmTokenSuffix?.trim();
+ if (suffix) {
+ parts.push(`token β¦${suffix}`);
}
return parts.join(" ");
}
diff --git a/src/constants/accountView.ts b/src/constants/accountView.ts
index 3953b264..8e72db94 100644
--- a/src/constants/accountView.ts
+++ b/src/constants/accountView.ts
@@ -40,6 +40,8 @@ export const ACCOUNT_VIEW_CONSTANTS = {
NO_PROFILE_LOCATION: "No profile location is saved.",
RELOAD_VAPID:
"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
@@ -57,11 +59,20 @@ export const ACCOUNT_VIEW_CONSTANTS = {
// Notification messages
NOTIFICATIONS: {
NEW_ACTIVITY_INFO: `
- This will only notify you 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'.
+ This will notify you with an app message when there is new relevant activity for you personally.
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: `
This will notify you at a specific time each day.
Note that it does not give you personalized notifications,
diff --git a/src/constants/app.ts b/src/constants/app.ts
index f6b1cb07..0bf856d1 100644
--- a/src/constants/app.ts
+++ b/src/constants/app.ts
@@ -28,6 +28,7 @@ export enum AppString {
PROD_NOTIFY_API_SERVER = "https://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)",
}
@@ -50,6 +51,7 @@ export const DEFAULT_PARTNER_API_SERVER =
export const DEFAULT_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 =
import.meta.env.VITE_DEFAULT_NOTIFY_API_SERVER ||
AppString.PROD_NOTIFY_API_SERVER;
diff --git a/src/constants/delegatedNotificationJwt.ts b/src/constants/delegatedNotificationJwt.ts
deleted file mode 100644
index 0c7336ea..00000000
--- a/src/constants/delegatedNotificationJwt.ts
+++ /dev/null
@@ -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;
diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts
index b9ffc328..30291f1a 100644
--- a/src/db-sql/migration.ts
+++ b/src/db-sql/migration.ts
@@ -276,6 +276,17 @@ const MIGRATIONS = [
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;
+ `,
+ },
];
/**
diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts
index 7e97e09a..b74e6cfd 100644
--- a/src/db/tables/settings.ts
+++ b/src/db/tables/settings.ts
@@ -50,11 +50,15 @@ export type Settings = {
lastViewedClaimId?: string;
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
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
/** Dev/test only: use 10-minute rollover interval for daily reminder (plugin rolloverIntervalMinutes) */
reminderFastRolloverForTesting?: boolean;
+ smsNotificationsEnabled?: boolean; // master switch for the text-message channel
+
partnerApiServer?: string; // partner server API URL
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
diff --git a/src/interfaces/accountView.ts b/src/interfaces/accountView.ts
index 4af6d6c9..bfd37ece 100644
--- a/src/interfaces/accountView.ts
+++ b/src/interfaces/accountView.ts
@@ -31,6 +31,9 @@ export interface AccountSettings {
bbox: BoundingBox;
}>;
notifyingNewActivityTime?: string;
+ notifyingNewActivitySmsPhone?: string;
+ notifyingNewActivitySmsTime?: string;
+ smsNotificationsEnabled?: boolean;
notifyingReminderMessage?: string;
notifyingReminderTime?: string;
starredPlanHandleIds?: string[];
@@ -80,6 +83,12 @@ export interface ProfileState {
export interface NotificationState {
notifyingNewActivity: boolean;
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;
notifyingReminderMessage: string;
notifyingReminderTime: string;
diff --git a/src/interfaces/delegatedNotificationJwt.ts b/src/interfaces/delegatedNotificationJwt.ts
deleted file mode 100644
index a0d436ff..00000000
--- a/src/interfaces/delegatedNotificationJwt.ts
+++ /dev/null
@@ -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[];
-}
diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts
index 887a3589..10d379dc 100644
--- a/src/interfaces/index.ts
+++ b/src/interfaces/index.ts
@@ -1,9 +1,9 @@
export * from "./alertSearch";
export * from "./claims";
-export * from "./delegatedNotificationJwt";
export * from "./claims-result";
export * from "./common";
export * from "./deepLinks";
export * from "./limits";
+export * from "./notifyApi";
export * from "./records";
export * from "./user";
diff --git a/src/interfaces/notifyApi.ts b/src/interfaces/notifyApi.ts
new file mode 100644
index 00000000..57fb412a
--- /dev/null
+++ b/src/interfaces/notifyApi.ts
@@ -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;
diff --git a/src/libs/alertSearch.ts b/src/libs/alertSearch.ts
index b98b9f2f..98589e8e 100644
--- a/src/libs/alertSearch.ts
+++ b/src/libs/alertSearch.ts
@@ -8,8 +8,10 @@
* - Authentication JWT: short-lived access token (`iss`/`iat`/`exp`) sent as
* `Authorization: Bearer` for interactive API calls (`accessToken` /
* `getHeaders`). Identifies the requester DID.
- * - Delegated notification JWT: 100 per-UTC-day tokens from
- * `mintDelegatedNotificationJwtBatch` for notify-api / wakeup-service.
+ * - Delegated notification JWT: a batch of 100 tokens, each valid for one whole
+ * 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
* plugin prefetch. Unrelated to alertSearch and to the delegated batch.
* - alertSearch cursor ULID: server-issued record/JWT primary id (26-char
diff --git a/src/libs/delegatedNotificationJwt.ts b/src/libs/delegatedNotificationJwt.ts
deleted file mode 100644
index 0a7142fe..00000000
--- a/src/libs/delegatedNotificationJwt.ts
+++ /dev/null
@@ -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 {
- 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 };
-}
diff --git a/src/router/index.ts b/src/router/index.ts
index 3baa6d0e..5390ffa3 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -438,6 +438,18 @@ router.beforeEach(async (to, _from, next) => {
// 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
// This allows users to manually create an identity or troubleshoot
logger.info(
diff --git a/src/services/notifications/NativeNotificationService.ts b/src/services/notifications/NativeNotificationService.ts
index 9c46ff96..d0051528 100644
--- a/src/services/notifications/NativeNotificationService.ts
+++ b/src/services/notifications/NativeNotificationService.ts
@@ -13,6 +13,10 @@
import { Capacitor } from "@capacitor/core";
import type { PushNotificationSchema } from "@capacitor/push-notifications";
+import type {
+ NotificationRefreshRequest,
+ NotificationRefreshResponse,
+} from "@/interfaces/notifyApi";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { getOrCreateDeviceId } from "./deviceId";
import { REMINDER_ID_DAILY_REMINDER } from "./reminderIds";
@@ -29,8 +33,9 @@ import {
} from "./notificationLog";
import {
getNotificationApiHeaders,
- httpAuthErrorMessage,
logSkippingRefreshDueToMissingAuth,
+ notificationApiFailureMessage,
+ readNotificationApiBody,
} from "./notificationApiAuth";
import { logNotification } from "./NotificationDebugEvents";
@@ -607,27 +612,35 @@ export async function refreshNotificationsWithDiagnostics(options?: {
deviceId = await getOrCreateDeviceId();
} catch (err) {
logger.warn(
- "[NativeNotificationService] Could not obtain deviceId; refresh proceeding without deviceId",
+ "[NativeNotificationService] Could not obtain deviceId; skipping refresh",
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 res = await fetch(`${baseUrl}/notifications/refresh`, {
method: "POST",
headers: auth.headers,
- body: JSON.stringify({
- deviceId,
- platform: Capacitor.getPlatform(),
- testMode: getTestMode(),
- }),
+ body: JSON.stringify(body),
});
if (!res.ok) {
- const errorMessage =
- res.status === 401 || res.status === 403
- ? httpAuthErrorMessage(res.status)
- : res.statusText || `HTTP ${res.status}`;
+ const errorMessage = notificationApiFailureMessage(
+ res.status,
+ await readNotificationApiBody(res),
+ );
logger.warn("[NativeNotificationService] refreshNotifications failed", {
status: res.status,
statusText: res.statusText,
@@ -642,12 +655,11 @@ export async function refreshNotificationsWithDiagnostics(options?: {
};
}
- const data: unknown = await res.json();
- const payload = data as NotificationRefreshPayload;
+ const payload = (await res.json()) as NotificationRefreshResponse;
const scheduledCount = Array.isArray(payload?.nextNotifications)
? payload.nextNotifications.length
: 0;
- await applyNotificationRefreshPayload(data);
+ await applyNotificationRefreshPayload(payload);
logRefreshSuccess(startedAt, scheduledCount, source);
return { ok: true, scheduledCount };
} catch (err) {
diff --git a/src/services/notifications/NotificationDebugConfig.ts b/src/services/notifications/NotificationDebugConfig.ts
index 53c0934d..9d757ae6 100644
--- a/src/services/notifications/NotificationDebugConfig.ts
+++ b/src/services/notifications/NotificationDebugConfig.ts
@@ -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 {
const raw = readStorage(STORAGE_KEY_BACKEND_URL);
if (raw === null) {
@@ -104,8 +104,8 @@ export function setBypassAuth(enabled: boolean): void {
}
/**
- * Base URL for `/notifications/*` API calls.
- * Uses debug override when set; otherwise DEFAULT_NOTIFY_API_SERVER.
+ * Base URL for notify-api calls (`/notifications/*`, `/notify-sms/*`).
+ * Uses debug override when set; otherwise `DEFAULT_NOTIFY_API_SERVER`.
*/
export function getNotificationApiBaseUrl(): string {
const override = getBackendBaseUrl();
diff --git a/src/services/notifications/NotificationDebugService.ts b/src/services/notifications/NotificationDebugService.ts
index 569a745b..93c514de 100644
--- a/src/services/notifications/NotificationDebugService.ts
+++ b/src/services/notifications/NotificationDebugService.ts
@@ -9,6 +9,10 @@
import { Capacitor } from "@capacitor/core";
import type { PushNotificationSchema } from "@capacitor/push-notifications";
+import type {
+ DebugSendWakeupRequest,
+ DebugSendWakeupResponse,
+} from "@/interfaces/notifyApi";
import { logger } from "@/utils/logger";
import { getOrCreateDeviceId } from "./deviceId";
import {
@@ -31,7 +35,8 @@ import {
} from "./firebaseMessagingClient";
import {
getNotificationApiHeaders,
- httpAuthErrorMessage,
+ notificationApiFailureMessage,
+ readNotificationApiBody,
} from "./notificationApiAuth";
import {
applyNotificationRefreshPayload,
@@ -62,49 +67,30 @@ export type PendingNotificationsResult = {
};
export type SendRealWakeupPingResult =
- | { ok: true; responseBody?: unknown }
+ | { ok: true; responseBody?: DebugSendWakeupResponse }
| {
ok: false;
errorMessage: string;
status?: number;
- responseBody?: unknown;
+ responseBody?: DebugSendWakeupResponse;
};
-function wakeupPingResponseDetail(body: unknown): Record {
- if (typeof body !== "object" || body === null) {
+/** The fields of a send-wakeup answer worth a debug log line. */
+function wakeupPingResponseDetail(
+ body: DebugSendWakeupResponse | undefined,
+): Record {
+ if (!body) {
return {};
}
- const record = body as Record;
- const detail: Record = {};
- for (const key of [
- "success",
- "message",
- "reason",
- "error",
- "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;
- 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}`;
+ return {
+ success: body.success,
+ ...(body.failureReason !== undefined
+ ? { failureReason: body.failureReason }
+ : {}),
+ ...(body.fcmTokenSuffix !== undefined
+ ? { fcmTokenSuffix: body.fcmTokenSuffix }
+ : {}),
+ };
}
function isUnimplementedError(e: unknown): boolean {
@@ -225,26 +211,30 @@ export const NotificationDebugService = {
const deviceId = await getOrCreateDeviceId();
const baseUrl = getNotificationApiBaseUrl();
+ const body: DebugSendWakeupRequest = {
+ deviceId,
+ fcmToken,
+ platform: Capacitor.getPlatform(),
+ testMode: getTestMode(),
+ };
const res = await fetch(`${baseUrl}/debug/send-wakeup`, {
method: "POST",
headers: auth.headers,
- body: JSON.stringify({
- deviceId,
- fcmToken,
- platform: Capacitor.getPlatform(),
- testMode: getTestMode(),
- }),
+ body: JSON.stringify(body),
});
- let responseBody: unknown;
- try {
- responseBody = await res.json();
- } catch {
- responseBody = undefined;
- }
+ const parsed = await readNotificationApiBody(res);
+ const responseBody =
+ typeof parsed === "object" && parsed !== null
+ ? (parsed as DebugSendWakeupResponse)
+ : undefined;
- if (!res.ok) {
- const errorMessage = wakeupPingFailureMessage(res.status, responseBody);
+ // A 200 still reports a skipped or failed push as `success: false`.
+ if (!res.ok || responseBody?.success === false) {
+ const errorMessage = notificationApiFailureMessage(
+ res.status,
+ responseBody,
+ );
logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, {
status: res.status,
token: truncateFcmTokenForLog(fcmToken),
diff --git a/src/services/notifications/NotificationService.ts b/src/services/notifications/NotificationService.ts
index 3d573c22..95436334 100644
--- a/src/services/notifications/NotificationService.ts
+++ b/src/services/notifications/NotificationService.ts
@@ -14,6 +14,7 @@
*/
import { Capacitor } from "@capacitor/core";
+import type { NotificationRegisterRequest } from "@/interfaces/notifyApi";
import { logger } from "@/utils/logger";
import { getOrCreateDeviceId } from "./deviceId";
import {
@@ -22,8 +23,9 @@ import {
} from "./NotificationDebugConfig";
import {
getNotificationApiHeaders,
- httpAuthErrorMessage,
logNotificationAuthFailure,
+ notificationApiFailureMessage,
+ readNotificationApiBody,
} from "./notificationApiAuth";
import {
logTokenRegistrationFailure,
@@ -47,27 +49,29 @@ export async function registerToken(fcmToken: string): Promise {
throw new Error(`registerToken auth unavailable: ${auth.message}`);
}
+ const body: NotificationRegisterRequest = {
+ deviceId,
+ fcmToken,
+ platform: Capacitor.getPlatform(),
+ testMode: getTestMode(),
+ };
const res = await fetch(`${baseUrl}/notifications/register`, {
method: "POST",
headers: auth.headers,
- body: JSON.stringify({
- deviceId,
- fcmToken,
- platform: Capacitor.getPlatform(),
- testMode: getTestMode(),
- }),
+ body: JSON.stringify(body),
});
+ // Success is a bare 200 with a plain-text body; only a refusal is read.
if (!res.ok) {
- const authDetail =
- res.status === 401 || res.status === 403
- ? httpAuthErrorMessage(res.status)
- : `HTTP ${res.status}`;
+ const detail = notificationApiFailureMessage(
+ res.status,
+ await readNotificationApiBody(res),
+ );
logger.warn("[NotificationService] registerToken failed", {
status: res.status,
statusText: res.statusText,
- authDetail,
+ detail,
});
- throw new Error(`registerToken failed: ${authDetail}`);
+ throw new Error(`registerToken failed: ${detail}`);
}
logTokenRegistrationSuccess(fcmToken);
} catch (err) {
diff --git a/src/services/notifications/alertAuthorizationBatch.ts b/src/services/notifications/alertAuthorizationBatch.ts
new file mode 100644
index 00000000..ce2d39e6
--- /dev/null
+++ b/src/services/notifications/alertAuthorizationBatch.ts
@@ -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 {
+ 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 {
+ 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(),
+ };
+}
diff --git a/src/services/notifications/index.ts b/src/services/notifications/index.ts
index 56cb39fb..b2a3033a 100644
--- a/src/services/notifications/index.ts
+++ b/src/services/notifications/index.ts
@@ -60,6 +60,32 @@ export {
timeToCron,
timeToCronFiveMinutesBefore,
} 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 {
diff --git a/src/services/notifications/notificationApiAuth.ts b/src/services/notifications/notificationApiAuth.ts
index e6739797..5c01ca59 100644
--- a/src/services/notifications/notificationApiAuth.ts
+++ b/src/services/notifications/notificationApiAuth.ts
@@ -168,3 +168,39 @@ export function httpAuthErrorMessage(status: number): string {
}
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 {
+ 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);
+}
diff --git a/src/services/notifications/pushAlertAuthorizationApi.ts b/src/services/notifications/pushAlertAuthorizationApi.ts
new file mode 100644
index 00000000..a2f79805
--- /dev/null
+++ b/src/services/notifications/pushAlertAuthorizationApi.ts
@@ -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(
+ did: string,
+ method: "PUT" | "DELETE",
+ body?: AlertAuthorizationRequestBody,
+): Promise {
+ 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 {
+ const body = await buildAlertAuthorizationBody(
+ did,
+ notifyHourUtc,
+ notifyMinuteUtc,
+ );
+ return pushAlertAuthorizationRequest(
+ 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 {
+ return pushAlertAuthorizationRequest(
+ did,
+ "DELETE",
+ );
+}
diff --git a/src/services/notifications/smsNotificationApi.ts b/src/services/notifications/smsNotificationApi.ts
new file mode 100644
index 00000000..e100796b
--- /dev/null
+++ b/src/services/notifications/smsNotificationApi.ts
@@ -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 {
+ // 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(
+ did: string,
+ options: SmsRequestOptions,
+): Promise {
+ 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 {
+ const normalized = phoneNumber ? normalizePhoneNumber(phoneNumber) : "";
+ return smsRequest(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 {
+ const normalized = normalizePhoneNumber(phoneNumber);
+ return smsRequest(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 {
+ const normalized = normalizePhoneNumber(phoneNumber);
+ return smsRequest(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 {
+ const normalized = normalizePhoneNumber(phoneNumber);
+ return smsRequest(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 {
+ const body = await buildAlertAuthorizationBody(
+ did,
+ notifyHourUtc,
+ notifyMinuteUtc,
+ );
+ return smsRequest(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 {
+ return smsRequest(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;
+ }
+}
diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts
index 784ee69d..d54f43b7 100644
--- a/src/services/platforms/CapacitorPlatformService.ts
+++ b/src/services/platforms/CapacitorPlatformService.ts
@@ -590,16 +590,22 @@ export class CapacitorPlatformService
await this.db!.run(sql, params);
} else {
// For multi-statement SQL (like migrations), use executeSet method
- // This handles multiple statements properly
- if (
- sql.includes(";") &&
- sql.split(";").filter((s) => s.trim()).length > 1
- ) {
+ // This handles multiple statements properly.
+ // Strip "--" line comments before splitting: a semicolon inside a
+ // comment would otherwise split mid-comment, yielding a comment-only
+ // 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
- const statements = sql.split(";").filter((s) => s.trim());
await this.db!.executeSet(
statements.map((stmt) => ({
- statement: stmt.trim(),
+ statement: stmt,
values: [], // Empty values array for non-parameterized statements
})),
);
diff --git a/src/test/alertAuthorizationBatch.test.ts b/src/test/alertAuthorizationBatch.test.ts
new file mode 100644
index 00000000..6c8fc0bb
--- /dev/null
+++ b/src/test/alertAuthorizationBatch.test.ts
@@ -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);
+ });
+});
diff --git a/src/test/notificationApiAuth.test.ts b/src/test/notificationApiAuth.test.ts
new file mode 100644
index 00000000..fe727530
--- /dev/null
+++ b/src/test/notificationApiAuth.test.ts
@@ -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();
+ });
+});
diff --git a/src/test/smsNotificationApi.test.ts b/src/test/smsNotificationApi.test.ts
new file mode 100644
index 00000000..88904ef9
--- /dev/null
+++ b/src/test/smsNotificationApi.test.ts
@@ -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 {
+ 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, "Bad Gateway");
+
+ 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,
+ );
+ });
+});
diff --git a/src/utils/PlatformServiceMixin.ts b/src/utils/PlatformServiceMixin.ts
index 8e65df92..96acb244 100644
--- a/src/utils/PlatformServiceMixin.ts
+++ b/src/utils/PlatformServiceMixin.ts
@@ -302,6 +302,7 @@ export const PlatformServiceMixin = {
column === "warnIfProdServer" ||
column === "warnIfTestServer" ||
column === "reminderFastRolloverForTesting" ||
+ column === "smsNotificationsEnabled" ||
// contacts
column === "hideTheirContent" ||
column === "registered" ||
diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue
index 15918df9..b475943f 100644
--- a/src/views/AccountViewView.vue
+++ b/src/views/AccountViewView.vue
@@ -143,24 +143,48 @@
-
-
-
+
+
+
+
New Activity Notification
+
+ Get told about new relevant activity for you. Choose a delivery
+ channel below -- you can turn on either one, or both.
+
+
+
+
+
+
+
+
+ In-App Notification
+
@@ -178,7 +202,12 @@
>
-
+
+
+ Troubleshoot your in-app notifications…
+
+
+
@@ -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"
@click="editNewActivityNotification"
>
- Edit New Activity Notificationβ¦
+ Edit In-App Timeβ¦
-
-
- Troubleshoot your notifications…
-
+
+
+
+
+
+
+ {{
+ notifyingNewActivitySmsPhone
+ ? "SMS Text Message"
+ : "Enable Text Messages"
+ }}
+
+
+
+
+
+
+
+
+ Texting: {{ notifyingNewActivitySmsPhone }}
+
+
+
+ Forget Phone Number
+
+
+
+
+
+
+
+ New Activity Text
+
+
+
+
+
+
+
+
+
+
+ Time:
+ {{ notifyingNewActivitySmsTime.replace(" ", " ") }}
+
+
+
+
+ Edit SMS Timeβ¦
+
+
+
+
+
-
-
Claim Server
-
-
- Claim Server Configuration
-
-
API Server URL
-
-
- Enter the URL for the claim server. You can use the buttons below to
- quickly set common server URLs.
-
-
+ Server URLs
+
+
+
+
+
Claim Server
+
-
-
-
+
+ Claim Server Configuration
+
+
API Server URL
+
+
+ Enter the URL for the claim server. You can use the buttons below
+ to quickly set common server URLs.
+
+
+
+
+
+
+ Use Prod
+
+
+ Use Test
+
+
+ Use Local
+
+
+
+
+
+
+ Show warning if on prod server
+
+
+
+
+
+
+ Show warning if on non-prod server
+
+
+
+
+
+
+
+
+
+ Notification Push Server
+
+
+
+
+
+
Use Prod
- Use Test
+ Use Test 1
- Use Local
+ Use Test 2
+
+ When that setting is blank, this app will use the default web push
+ server URL:
+ {{ DEFAULT_PUSH_SERVER }}
+
-
-
- Show warning if on prod server
-
-
-
-
-
-
- Show warning if on non-prod server
-
-
-
-
-
-
-
-
Partner Server URL
-
-
-
-
-
-
- Use Prod
-
-
- Use Test
-
-
- Use Local
-
-
-
- When that setting is blank, this app will use the default partner server
- URL:
- {{ DEFAULT_PARTNER_API_SERVER }}
-
+
+ Image Server URL
+
+ {{ DEFAULT_IMAGE_API_SERVER }}
+
-
-
Image Server URL
-
-
{{ DEFAULT_IMAGE_API_SERVER }}
+
+ Notify Server URL
+
+ {{ notifyApiServer }}
+
+
+
+
Logs
+
+ Test Page
+
+
Notification Debug Panel
-
- Test Page
-
@@ -792,10 +984,12 @@ import { copyToClipboard } from "../services/ClipboardService";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { Capacitor } from "@capacitor/core";
+import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
import EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
import QuickNav from "../components/QuickNav.vue";
+import SmsVerificationDialog from "../components/SmsVerificationDialog.vue";
import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import DataExportSection from "../components/DataExportSection.vue";
@@ -839,9 +1033,17 @@ import { includeDevToolkitRoutes } from "@/utils/includeDevToolkitRoutes";
import { AccountSettings, isApiError } from "@/interfaces/accountView";
import {
NotificationService,
- configureNativeFetcherIfReady,
+ authorizeSmsAlertSearch,
buildDualScheduleConfig,
+ configureNativeFetcherIfReady,
+ deleteSmsPhone,
+ getNotificationApiBaseUrl,
+ listSmsPhones,
+ localTimeTextToUtc,
+ revokeSmsAlertSearch,
+ smsErrorMessage,
syncStarredPlansToNativePlugin,
+ UnsupportedIdentityError,
} from "@/services/notifications";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
// Profile data interface (inlined from ProfileService)
@@ -860,7 +1062,11 @@ interface PushNotificationPermissionRef {
open: (
title: string,
callback: (success: boolean, timeText: string, message?: string) => void,
- options?: { skipSchedule?: boolean; rolloverIntervalMinutes?: number },
+ options?: {
+ skipSchedule?: boolean;
+ rolloverIntervalMinutes?: number;
+ timeOnly?: boolean;
+ },
) => void;
hourInput?: string;
minuteInput?: string;
@@ -870,6 +1076,7 @@ interface PushNotificationPermissionRef {
@Component({
components: {
+ ChoiceButtonDialog,
EntityIcon,
ImageMethodDialog,
LMap,
@@ -877,6 +1084,7 @@ interface PushNotificationPermissionRef {
LTileLayer,
PushNotificationPermission,
QuickNav,
+ SmsVerificationDialog,
TopMessage,
UserNameDialog,
DataExportSection,
@@ -900,6 +1108,11 @@ export default class AccountViewView extends Vue {
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
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
activeDid: string = "";
apiServer: string = "";
@@ -931,6 +1144,18 @@ export default class AccountViewView extends Vue {
// Notification properties
notifyingNewActivity: boolean = false;
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;
notifyingReminderMessage: string = "";
notifyingReminderTime: string = "";
@@ -1117,6 +1342,13 @@ export default class AccountViewView extends Vue {
!!settings.searchBoxes && settings.searchBoxes.length > 0;
this.notifyingNewActivity = !!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.notifyingReminderMessage = settings.notifyingReminderMessage || "";
this.notifyingReminderTime = settings.notifyingReminderTime || "";
@@ -1145,6 +1377,63 @@ export default class AccountViewView extends Vue {
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 {
+ 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 {
+ 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
@@ -1216,9 +1505,11 @@ export default class AccountViewView extends Vue {
}
}
- async showNewActivityNotificationInfo(): Promise {
+ async showNewActivityNotificationInfo(isSms: boolean): Promise {
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 () => {
await (this.$router as Router).push({
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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ (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 {
+ 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 {
+ 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.
*/
@@ -1741,6 +2343,8 @@ export default class AccountViewView extends Vue {
this.notifyingReminder = false;
this.notifyingReminderMessage = "";
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 {
this.savingProfile = true;
try {
diff --git a/src/views/HelpNotificationTypesView.vue b/src/views/HelpNotificationTypesView.vue
index 35242a35..373a7d03 100644
--- a/src/views/HelpNotificationTypesView.vue
+++ b/src/views/HelpNotificationTypesView.vue
@@ -39,31 +39,76 @@
something, so you can record thanks in here.
- 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.
+ Note that the timing is not precise: the device may deliver it a bit later than scheduled.
- New Activity Notifications
+ New Activity Notifications -- In App
- The New Activity Notification will be sent to you when there is new, relevant activity
- for you.
+ The New Activity Notification will be activated in your app 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.
-
- 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.
-
- go here to follow us or contact us
-
- .)
-
+
+ New Activity Notifications -- Texts From A Server
+
+
+ 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.
+
+
Notes and caveats for text messages:
+
+
+ Text messages are opt-in: they are only sent after you enter your
+ phone number and confirm it with a verification code.
+
+
+ Standard message and data rates from your carrier may apply.
+
+
+ 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.
+
+
+ Identities that sign with a passkey cannot turn this on, because
+ the server needs credentials it can use while your phone is asleep.
+
+
+ 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.
+
+
+ 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.
+
+
+ Only US phone numbers are currently supported.
+
+
+ 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".
+
+
+ See our
+
+ Terms & Conditions and Privacy Policy docs here.
+
+
+
+
+
diff --git a/src/views/dev/NotificationDebugView.vue b/src/views/dev/NotificationDebugView.vue
index abb0254b..d210afbf 100644
--- a/src/views/dev/NotificationDebugView.vue
+++ b/src/views/dev/NotificationDebugView.vue
@@ -1,13 +1,18 @@
-
-
-
Notification Debug
-
+
+
+/*
+ * 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 {
readonly VITE_APP_TITLE: string;
// more env variables...
diff --git a/test-playwright/00-noid-tests.spec.ts b/test-playwright/00-noid-tests.spec.ts
index 61b52eae..44604515 100644
--- a/test-playwright/00-noid-tests.spec.ts
+++ b/test-playwright/00-noid-tests.spec.ts
@@ -208,6 +208,7 @@ test('Confirm test API setting (may fail if you are running your own Time Safari
// Load account view
await page.goto('./account');
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
const webServer = testInfo.config.webServer;
diff --git a/test-playwright/50-record-offer.spec.ts b/test-playwright/50-record-offer.spec.ts
index 895f947e..f8463ceb 100644
--- a/test-playwright/50-record-offer.spec.ts
+++ b/test-playwright/50-record-offer.spec.ts
@@ -85,7 +85,8 @@ test('Record an offer', async ({ page }) => {
// click on the number of new offers to go to the list page
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
await page.getByTestId('showOffersToUserProjects').locator('div > svg.fa-chevron-right').click();
await expect(page.getByText(description)).toBeVisible();