From ee8fb80cfee177319a4ffe658c03d3c6bff821a8 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sun, 19 Jul 2026 20:23:34 -0600 Subject: [PATCH 1/7] start the SMS notifications, beginning with the UI --- src/constants/accountView.ts | 9 +- src/views/AccountViewView.vue | 201 ++++++++++++++++++++++-- src/views/HelpNotificationTypesView.vue | 25 +-- 3 files changed, 210 insertions(+), 25 deletions(-) diff --git a/src/constants/accountView.ts b/src/constants/accountView.ts index 3953b264..6fee50ff 100644 --- a/src/constants/accountView.ts +++ b/src/constants/accountView.ts @@ -57,9 +57,12 @@ 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? `, REMINDER_INFO: ` diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index 15918df9..affc3938 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. +

+
+ + +
+ +
+
@@ -178,7 +202,7 @@ >
-
+
@@ -191,10 +215,75 @@ 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…
+ + +
+ +
+
+ +
+ +
+
+
+
+
+
+
+ Time: + {{ notifyingNewActivitySmsTime.replace(" ", " ") }} +
+
+ Text to: + {{ notifyingNewActivitySmsPhone || "(number not set)" }} +
+
+
+ +
+
+
Troubleshoot your notifications… @@ -546,7 +635,8 @@
-
+ +

Notification Push Server

@@ -931,6 +1021,12 @@ export default class AccountViewView extends Vue { // Notification properties notifyingNewActivity: boolean = false; notifyingNewActivityTime: string = ""; + // SMS delivery channel for New Activity (opt-in). MOCK-UP: state is currently + // component-local. TODO: persist via settings + register the number and time + // with the notify-api (SMS registration/verification flow). + notifyingNewActivitySms: boolean = false; + notifyingNewActivitySmsTime: string = ""; + notifyingNewActivitySmsPhone: string = ""; notifyingReminder: boolean = false; notifyingReminderMessage: string = ""; notifyingReminderTime: string = ""; @@ -1216,9 +1312,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 +1364,89 @@ export default class AccountViewView extends Vue { } } + /** + * Toggle the SMS delivery channel for New Activity notifications. + * + * MOCK-UP: reuses the time-picker dialog so the UI behaves like the in-app + * channel, but the picked time is only held in component state for now. + * + * TODO (notify-api wiring): on enable, ensure the user has a verified phone + * number, then POST the SMS-notification preference to the notify-api with + * SMS-specific params (channel="sms", phoneNumber, sendTime) -- the same + * alert as the in-app channel, delivered by a different transport. On + * disable, tell the notify-api to remove the SMS preference. + */ + async showNewActivitySmsNotificationChoice(): Promise { + if (!this.notifyingNewActivitySms) { + ( + this.$refs.pushNotificationPermission as PushNotificationPermission + ).open( + DAILY_CHECK_TITLE, + async (success: boolean, timeText: string) => { + if (success) { + // TODO: verify phone number + register SMS preference with notify-api + this.notifyingNewActivitySms = true; + this.notifyingNewActivitySmsTime = timeText; + } + }, + { skipSchedule: true }, + ); + } else { + // TODO: remove SMS preference from notify-api + this.notifyingNewActivitySms = false; + this.notifyingNewActivitySmsTime = ""; + } + } + + /** + * Edit the time for the SMS delivery channel. + * + * MOCK-UP: updates component state only. TODO: PUT the updated sendTime to + * the notify-api SMS preference. + */ + async editNewActivitySmsNotification(): Promise { + const dialog = this.$refs + .pushNotificationPermission as PushNotificationPermission; + + dialog.open( + DAILY_CHECK_TITLE, + async (success: boolean, timeText: string) => { + if (!success) return; + // TODO: PUT updated sendTime to notify-api SMS preference + this.notifyingNewActivitySmsTime = timeText; + this.notify.success( + "New Activity SMS time updated.", + TIMEOUTS.STANDARD, + ); + }, + { skipSchedule: 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); + } + /** * Configure native fetcher, sync starred plans, and schedule API-driven dual notification. */ diff --git a/src/views/HelpNotificationTypesView.vue b/src/views/HelpNotificationTypesView.vue index 35242a35..9516b851 100644 --- a/src/views/HelpNotificationTypesView.vue +++ b/src/views/HelpNotificationTypesView.vue @@ -39,31 +39,32 @@ 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 + 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.

+
+ +

New Activity Notifications -- Texts From A Server

+

- 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 - - .) + 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.

+
From 3f291b0d63dc02f2ec070d09ceb172f20a990f90 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Thu, 13 Aug 2026 09:45:16 -0600 Subject: [PATCH 2/7] add a more comprehensive demo UI workflow for SMS signup --- CHANGELOG.md | 5 + src/components/SmsVerificationDialog.vue | 284 +++++++++++++++++++++++ src/constants/accountView.ts | 2 + src/views/AccountViewView.vue | 232 +++++++++++++----- src/views/HelpNotificationTypesView.vue | 36 ++- 5 files changed, 502 insertions(+), 57 deletions(-) create mode 100644 src/components/SmsVerificationDialog.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 9514f07d..f055705d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ 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 + + ## [1.3.8] - 2026 ### Added - Device wake-up for notifications diff --git a/src/components/SmsVerificationDialog.vue b/src/components/SmsVerificationDialog.vue new file mode 100644 index 00000000..aafb27f7 --- /dev/null +++ b/src/components/SmsVerificationDialog.vue @@ -0,0 +1,284 @@ + + + diff --git a/src/constants/accountView.ts b/src/constants/accountView.ts index 6fee50ff..fb5b5029 100644 --- a/src/constants/accountView.ts +++ b/src/constants/accountView.ts @@ -65,6 +65,8 @@ export const ACCOUNT_VIEW_CONSTANTS = { Standard message and data rates may apply, and you can turn it off at any time. Do you want more details? `, + 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/views/AccountViewView.vue b/src/views/AccountViewView.vue index affc3938..1f4b2734 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -220,7 +220,7 @@
- +
@@ -229,26 +229,23 @@ class="fa-fw text-slate-400" aria-hidden="true" /> - SMS Text Message - + {{ + notifyingNewActivitySmsPhone + ? "SMS Text Message" + : "Enable Text Messages" + }}
- +
-
+
+
- Time: - {{ notifyingNewActivitySmsTime.replace(" ", " ") }} + Texting: {{ notifyingNewActivitySmsPhone }}
+ +
+
+ +
+
- Text to: - {{ notifyingNewActivitySmsPhone || "(number not set)" }} + New Activity Text + +
+ +
+ +
+
-
- +
+ Time: + {{ notifyingNewActivitySmsTime.replace(" ", " ") }} +
+
+
+ +
@@ -291,6 +341,7 @@ +
{ - if (!this.notifyingNewActivitySms) { - ( - this.$refs.pushNotificationPermission as PushNotificationPermission - ).open( - DAILY_CHECK_TITLE, - async (success: boolean, timeText: string) => { - if (success) { - // TODO: verify phone number + register SMS preference with notify-api - this.notifyingNewActivitySms = true; - this.notifyingNewActivitySmsTime = timeText; - } - }, - { skipSchedule: true }, - ); - } else { - // TODO: remove SMS preference from notify-api + async toggleSmsEnabled(): Promise { + if (this.smsEnabled) { + // Pause the channel; the verified number stays for easy re-enable. + // TODO(notify-api): disable all SMS preferences server-side. + this.smsEnabled = false; this.notifyingNewActivitySms = false; this.notifyingNewActivitySmsTime = ""; + return; } + + if (this.notifyingNewActivitySmsPhone) { + // Number already verified: just switch the channel back on. + this.smsEnabled = true; + return; + } + + (this.$refs.smsVerificationDialog as SmsVerificationDialog).open( + async (success: boolean, phoneNumber?: string) => { + if (success && phoneNumber) { + this.notifyingNewActivitySmsPhone = phoneNumber; + this.smsEnabled = true; + } + }, + ); + } + + /** + * Forget the verified phone number entirely: the master toggle turns off + * 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 () => { + // TODO(notify-api): DELETE /api/sms/register to remove the number + // and all SMS preferences server-side. + this.notifyingNewActivitySmsPhone = ""; + this.smsEnabled = false; + this.notifyingNewActivitySms = false; + this.notifyingNewActivitySmsTime = ""; + }, + ); + } + + /** + * Toggle the "New Activity Text" notification (only reachable while the + * SMS channel is enabled). + * + * TODO (notify-api wiring): on enable, POST the SMS-notification preference + * with SMS-specific params (channel="sms", phoneNumber, sendTime) -- the + * same alert as the in-app channel, delivered by a different transport. On + * disable, tell the notify-api to remove it. + */ + async showNewActivitySmsNotificationChoice(): Promise { + if (this.notifyingNewActivitySms) { + this.notifyingNewActivitySms = false; + this.notifyingNewActivitySmsTime = ""; + return; + } + await this.promptSmsTimeAndEnable(); + } + + /** + * Prompt for the SMS send time (reusing the time-picker dialog) and enable + * the SMS channel on success. + * + * MOCK-UP: the chosen time is held in component state only. + */ + async promptSmsTimeAndEnable(): Promise { + (this.$refs.pushNotificationPermission as PushNotificationPermission).open( + DAILY_CHECK_TITLE, + async (success: boolean, timeText: string) => { + if (success) { + // TODO: register SMS preference with notify-api (phone + time) + this.notifyingNewActivitySms = true; + this.notifyingNewActivitySmsTime = timeText; + } + }, + { skipSchedule: true }, + ); } /** @@ -1922,6 +2040,10 @@ export default class AccountViewView extends Vue { this.notifyingReminder = false; this.notifyingReminderMessage = ""; this.notifyingReminderTime = ""; + // SMS channel (mock-up, component-local). TODO: also tell the notify-api. + // Leaves the verified phone number in place intentionally. + this.notifyingNewActivitySms = false; + this.notifyingNewActivitySmsTime = ""; } /** diff --git a/src/views/HelpNotificationTypesView.vue b/src/views/HelpNotificationTypesView.vue index 9516b851..a42c0835 100644 --- a/src/views/HelpNotificationTypesView.vue +++ b/src/views/HelpNotificationTypesView.vue @@ -58,11 +58,43 @@

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. + 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 the + time you choose. +
  • +
  • + 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". +
  • +
From b02a1b29c5a89c8359cdf71cac20ecbff42184f0 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sat, 22 Aug 2026 13:01:48 -0600 Subject: [PATCH 3/7] add T&C/privacy link for notification help --- src/views/HelpNotificationTypesView.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/views/HelpNotificationTypesView.vue b/src/views/HelpNotificationTypesView.vue index a42c0835..a0ea10c7 100644 --- a/src/views/HelpNotificationTypesView.vue +++ b/src/views/HelpNotificationTypesView.vue @@ -94,6 +94,12 @@ is stored on the notification server and is deleted when you choose "Forget Phone Number". +
  • + See our + + Terms & Conditions and Privacy Policy docs here. + +
  • From 0c400b87976f43573b311cdd025c53195143ea39 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sun, 30 Aug 2026 11:07:36 -0600 Subject: [PATCH 4/7] fix spacing in a doc diagram --- doc/local-android-testing-ngrok.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/doc/local-android-testing-ngrok.md b/doc/local-android-testing-ngrok.md index 00a9754d..4317779e 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 │ └─────────────────────┘ └──────────────────────┘ ``` From 940e851d3c4a3c5d20e498637c2ddb2c1b238ffa Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sun, 30 Aug 2026 17:55:23 -0600 Subject: [PATCH 5/7] adjust the UX for SMS text opt-in, to show terms immediately --- src/components/ChoiceButtonDialog.vue | 3 ++ src/constants/accountView.ts | 4 +++ src/views/AccountViewView.vue | 42 +++++++++++++++++++-------- 3 files changed, 37 insertions(+), 12 deletions(-) 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 }}

    +
    { - if (success && phoneNumber) { - this.notifyingNewActivitySmsPhone = phoneNumber; - this.smsEnabled = true; - } + (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( + async (success: boolean, phoneNumber?: string) => { + if (success && phoneNumber) { + this.notifyingNewActivitySmsPhone = phoneNumber; + this.smsEnabled = true; + } + }, + ); + }, + }); } /** From 59993cb96a8ee627d6750563ec08ff48da892991 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sun, 30 Aug 2026 17:59:01 -0600 Subject: [PATCH 6/7] fix husky git hooks (because it was complaining about those lines) --- .husky/commit-msg | 2 -- .husky/pre-commit | 2 -- .husky/pre-push | 2 -- 3 files changed, 6 deletions(-) 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)..." From 240c3c5a765b1e126c6f108afc394622b935a05e Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sun, 13 Sep 2026 17:08:23 -0600 Subject: [PATCH 7/7] Consolidate notify-api JWT minting; add wire types (WIP before SMS merge) --- CHANGELOG.md | 11 + doc/local-android-testing-ngrok.md | 2 +- doc/local-ios-testing-ngrok.md | 1 + doc/sms-notifications.md | 30 + src/components/PushNotificationPermission.vue | 36 +- src/components/dev/NotificationDebugPanel.vue | 12 +- src/constants/accountView.ts | 2 + src/constants/app.ts | 2 + src/constants/delegatedNotificationJwt.ts | 5 - src/db-sql/migration.ts | 11 + src/db/tables/settings.ts | 4 + src/interfaces/accountView.ts | 9 + src/interfaces/delegatedNotificationJwt.ts | 29 - src/interfaces/index.ts | 2 +- src/interfaces/notifyApi.ts | 424 ++++++++ src/libs/alertSearch.ts | 6 +- src/libs/delegatedNotificationJwt.ts | 113 --- src/router/index.ts | 12 + .../NativeNotificationService.ts | 40 +- .../notifications/NotificationDebugConfig.ts | 6 +- .../notifications/NotificationDebugService.ts | 90 +- .../notifications/NotificationService.ts | 30 +- .../notifications/alertAuthorizationBatch.ts | 211 ++++ src/services/notifications/index.ts | 26 + .../notifications/notificationApiAuth.ts | 36 + .../pushAlertAuthorizationApi.ts | 169 ++++ .../notifications/smsNotificationApi.ts | 453 +++++++++ .../platforms/CapacitorPlatformService.ts | 20 +- src/test/alertAuthorizationBatch.test.ts | 183 ++++ src/test/notificationApiAuth.test.ts | 63 ++ src/test/smsNotificationApi.test.ts | 182 ++++ src/utils/PlatformServiceMixin.ts | 1 + src/views/AccountViewView.vue | 902 +++++++++++++++--- src/views/HelpNotificationTypesView.vue | 44 + src/views/dev/NotificationDebugView.vue | 21 +- src/vite-env.d.ts | 30 + test-playwright/00-noid-tests.spec.ts | 1 + 37 files changed, 2800 insertions(+), 419 deletions(-) create mode 100644 doc/sms-notifications.md delete mode 100644 src/constants/delegatedNotificationJwt.ts delete mode 100644 src/interfaces/delegatedNotificationJwt.ts create mode 100644 src/interfaces/notifyApi.ts delete mode 100644 src/libs/delegatedNotificationJwt.ts create mode 100644 src/services/notifications/alertAuthorizationBatch.ts create mode 100644 src/services/notifications/pushAlertAuthorizationApi.ts create mode 100644 src/services/notifications/smsNotificationApi.ts create mode 100644 src/test/alertAuthorizationBatch.test.ts create mode 100644 src/test/notificationApiAuth.test.ts create mode 100644 src/test/smsNotificationApi.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9514f07d..77e8a833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ 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 +- SMS notifications talk to the notify-api: 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/doc/local-android-testing-ngrok.md b/doc/local-android-testing-ngrok.md index 00a9754d..39db6746 100644 --- a/doc/local-android-testing-ngrok.md +++ b/doc/local-android-testing-ngrok.md @@ -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/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/dev/NotificationDebugPanel.vue b/src/components/dev/NotificationDebugPanel.vue index f0736c27..b542d494 100644 --- a/src/components/dev/NotificationDebugPanel.vue +++ b/src/components/dev/NotificationDebugPanel.vue @@ -477,16 +477,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..d514fa6f 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 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 d0cf0dd1..00000000 --- a/src/interfaces/delegatedNotificationJwt.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Batch of per-local-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 today in the user's timezone. */ - sequence: number; - /** Calendar date of this slot, YYYY-MM-DD in the user's timezone. */ - localDate: string; - /** Unix seconds at 00:00:00 of this local day. */ - nbf: number; - /** Unix seconds at 00:00:00 of the following local 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 ab021238..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-local-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 f0d2ed48..00000000 --- a/src/libs/delegatedNotificationJwt.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Mint 100 delegated notification JWTs (one local 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 local day's bounds. - * Sequence is array order: index 0 / sequence 1 = today. - * - 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). Not persisted in settings. 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; -} - -/** - * Local-day [nbf, exp) windows for sequence 1..count. - * DST is handled by Luxon startOf('day') in the given IANA zone. - */ -export function buildDelegatedNotificationJwtWindows( - count: number = DELEGATED_NOTIFICATION_JWT_COUNT, - timeZone?: string, - now: Date = new Date(), -): DelegatedNotificationJwtWindow[] { - const zone = resolveUserTimeZone(timeZone); - const todayStart = DateTime.fromJSDate(now, { zone }).startOf("day"); - const windows: DelegatedNotificationJwtWindow[] = []; - for (let i = 0; i < count; i++) { - const dayStart = todayStart.plus({ days: i }).startOf("day"); - const nextStart = dayStart.plus({ days: 1 }).startOf("day"); - windows.push({ - sequence: i + 1, - localDate: 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.localDate}`, - }); - 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 7b1f9de7..7343546e 100644 --- a/src/services/notifications/NotificationDebugConfig.ts +++ b/src/services/notifications/NotificationDebugConfig.ts @@ -45,7 +45,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) { @@ -100,8 +100,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 9174090d..d4610597 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, @@ -58,49 +63,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 { @@ -197,26 +183,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 5505f958..64a25864 100644 --- a/src/services/notifications/index.ts +++ b/src/services/notifications/index.ts @@ -52,6 +52,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 9b1d73ed..9d942d67 100644 --- a/src/services/notifications/notificationApiAuth.ts +++ b/src/services/notifications/notificationApiAuth.ts @@ -150,3 +150,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..3f51080b 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -178,7 +178,12 @@ > -
    +
    + + Troubleshoot your in-app notifications… + +
    +
    @@ -195,10 +200,119 @@
    -
    - - Troubleshoot your notifications… - + + +
    + +
    +
    + +
    + +
    +
    +
    +
    +
    +
    + +
    + Texting: {{ notifyingNewActivitySmsPhone }} +
    + +
    +
    + +
    +
    +
    + New Activity Text + +
    + +
    + +
    +
    +
    +
    +
    +
    +
    + Time: + {{ notifyingNewActivitySmsTime.replace(" ", " ") }} +
    +
    +
    + +
    +
    @@ -443,123 +557,196 @@ -
    -

    Claim Server

    -
    -

    - Claim Server Configuration -

    - - - - -
    +

    + Claim Server Configuration +

    + + + + +
    + + + +
    +
    + + + + +
    + + + +
    +

    + Notification Push Server +

    +
    + +
    + + When that setting is blank, this app will use the default web push + server URL: + {{ DEFAULT_PUSH_SERVER }} +
    - - - -
    - - -

    - Notification Push Server + Partner Server URL

    - - When that setting is blank, this app will use the default web push + + When that setting is blank, this app will use the default partner server URL: - {{ DEFAULT_PUSH_SERVER }} + {{ DEFAULT_PARTNER_API_SERVER }} -
    -

    Partner Server URL

    -
    - - - - - -
    - - 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 }} + +