Merge branch 'notify-api_endpoint-query_merge' into notify-api_endpoint-query

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