Consolidate notify-api JWT minting; add wire types (WIP before SMS merge)
This commit is contained in:
@@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [?] - 2026
|
||||
### Added
|
||||
- Full flow for setting up SMS notifications
|
||||
- SMS notifications talk to the notify-api: phone registration and code
|
||||
verification, delegated alertSearch JWT batches with a send hour, and
|
||||
revocation to stop the texts
|
||||
- Push-channel client for the notify-api's alert authorization
|
||||
(`authorizePushAlertSearch`, `revokePushAlertSearch`), built on the same batch
|
||||
minter as the SMS channel
|
||||
|
||||
|
||||
## [1.3.8] - 2026
|
||||
### Added
|
||||
- Device wake-up for notifications
|
||||
|
||||
@@ -578,7 +578,7 @@ curl -sS -w "\nHTTP %{http_code}\n" "$BASE/health"
|
||||
3. Confirm **Backend Status → URL** matches the saved ngrok host.
|
||||
4. Enable **Test Mode** if using dev backend behavior.
|
||||
|
||||
**Expected outcome:** **Active** URL in the panel equals your ngrok `https://…` host. Subsequent app requests use that base (not `DEFAULT_NOTIFY_API_SERVER`) for `/notifications/register` and `/notifications/refresh`.
|
||||
**Expected outcome:** **Active** URL in the panel equals your ngrok `https://…` host. Subsequent app requests use that base (not the default `DEFAULT_NOTIFY_API_SERVER`) for `/notifications/register` and `/notifications/refresh`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -281,6 +281,7 @@ Before ngrok end-to-end testing, confirm:
|
||||
|
||||
## 6. Configure the Notification Debug Panel backend override
|
||||
|
||||
|
||||
The app normally calls `DEFAULT_NOTIFY_API_SERVER` (from `VITE_DEFAULT_NOTIFY_API_SERVER`, falling back to `AppString.PROD_NOTIFY_API_SERVER`). That is independent of `APP_SERVER`. For local wakeup testing, override the notification API base URL in the Debug Panel without rebuilding.
|
||||
|
||||
For a full panel reference (configuration, URL resolution order, authentication, and troubleshooting), see [notification-debug-panel.md](./notification-debug-panel.md).
|
||||
|
||||
@@ -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”
|
||||
```
|
||||
@@ -159,6 +159,13 @@ export default class PushNotificationPermission extends Vue {
|
||||
pushType = "";
|
||||
/** When true, dialog only returns time/message to parent; parent does cancel+schedule (avoids double schedule on edit). */
|
||||
skipScheduleForOpen = false;
|
||||
/**
|
||||
* When true, the dialog is a time picker and nothing else: no permission
|
||||
* request, no web-push subscription, no settings write. Used by delivery
|
||||
* channels that carry their own schedule -- SMS sends its hour to the
|
||||
* notify-api rather than arming anything on this device.
|
||||
*/
|
||||
timeOnlyForOpen = false;
|
||||
/** When set (e.g. 10), passed to plugin for dev/test fast rollover. */
|
||||
rolloverIntervalMinutesForSchedule: number | undefined = undefined;
|
||||
serviceWorkerReady = false;
|
||||
@@ -174,14 +181,27 @@ export default class PushNotificationPermission extends Vue {
|
||||
async open(
|
||||
pushType: string,
|
||||
callback?: (success: boolean, time: string, message?: string) => void,
|
||||
options?: { skipSchedule?: boolean; rolloverIntervalMinutes?: number },
|
||||
options?: {
|
||||
skipSchedule?: boolean;
|
||||
rolloverIntervalMinutes?: number;
|
||||
timeOnly?: boolean;
|
||||
},
|
||||
) {
|
||||
this.callback = callback || this.callback;
|
||||
this.isVisible = true;
|
||||
this.pushType = pushType;
|
||||
this.skipScheduleForOpen = options?.skipSchedule ?? false;
|
||||
this.timeOnlyForOpen = options?.timeOnly ?? false;
|
||||
this.rolloverIntervalMinutesForSchedule = options?.rolloverIntervalMinutes;
|
||||
|
||||
// Time-only callers never subscribe to anything, so the web-push
|
||||
// handshake would only be a way to fail before showing a clock.
|
||||
if (this.timeOnlyForOpen) {
|
||||
this.serviceWorkerReady = true;
|
||||
this.messageInput = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Native platforms: Skip web push initialization
|
||||
if (this.isNativePlatform) {
|
||||
logger.debug(
|
||||
@@ -589,8 +609,8 @@ export default class PushNotificationPermission extends Vue {
|
||||
* For native platforms, always returns true (no VAPID needed)
|
||||
*/
|
||||
get isSystemReady(): boolean {
|
||||
if (this.isNativePlatform) {
|
||||
return true; // Native doesn't need VAPID/service worker
|
||||
if (this.isNativePlatform || this.timeOnlyForOpen) {
|
||||
return true; // Neither needs VAPID/service worker
|
||||
}
|
||||
return this.serviceWorkerReady && !!this.vapidKey;
|
||||
}
|
||||
@@ -601,8 +621,8 @@ export default class PushNotificationPermission extends Vue {
|
||||
* For native platforms, always returns true (no VAPID needed)
|
||||
*/
|
||||
get canShowNotificationForm(): boolean {
|
||||
if (this.isNativePlatform) {
|
||||
return true; // Native doesn't need VAPID/service worker
|
||||
if (this.isNativePlatform || this.timeOnlyForOpen) {
|
||||
return true; // Neither needs VAPID/service worker
|
||||
}
|
||||
return this.serviceWorkerReady && !!this.vapidKey;
|
||||
}
|
||||
@@ -672,6 +692,12 @@ export default class PushNotificationPermission extends Vue {
|
||||
* Close only after async flow completes so success/error $notify runs while component is mounted (fixes Android).
|
||||
*/
|
||||
async handleTurnOnNotifications() {
|
||||
if (this.timeOnlyForOpen) {
|
||||
// Nothing to arm on this device; the caller owns whatever the time means.
|
||||
this.callback(true, this.notificationTimeText, this.messageInput);
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (this.isNativePlatform) {
|
||||
await this.turnOnNativeNotifications();
|
||||
} else {
|
||||
|
||||
@@ -477,16 +477,10 @@ function formatRealWakeupStatusMessage(
|
||||
>,
|
||||
): string {
|
||||
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."];
|
||||
if (typeof body?.message === "string" && body.message.trim()) {
|
||||
parts.push(body.message.trim());
|
||||
}
|
||||
if (typeof body?.tokenSuffix === "string" && body.tokenSuffix.trim()) {
|
||||
parts.push(`token …${body.tokenSuffix.trim()}`);
|
||||
const suffix = result.responseBody?.fcmTokenSuffix?.trim();
|
||||
if (suffix) {
|
||||
parts.push(`token …${suffix}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export const ACCOUNT_VIEW_CONSTANTS = {
|
||||
NO_PROFILE_LOCATION: "No profile location is saved.",
|
||||
RELOAD_VAPID:
|
||||
"Now reload the app to get a new VAPID to use with this push server.",
|
||||
NOTIFY_SERVER_INFO:
|
||||
"The notify server URL can be modified on the Notification Debug screen.",
|
||||
},
|
||||
|
||||
// Warning messages
|
||||
|
||||
@@ -28,6 +28,7 @@ export enum AppString {
|
||||
|
||||
PROD_NOTIFY_API_SERVER = "https://notify-api.timesafari.app",
|
||||
TEST_NOTIFY_API_SERVER = "https://test-notify-api.timesafari.app",
|
||||
LOCAL_NOTIFY_API_SERVER = "http://127.0.0.1:3003",
|
||||
|
||||
NO_CONTACT_NAME = "(no name)",
|
||||
}
|
||||
@@ -50,6 +51,7 @@ export const DEFAULT_PARTNER_API_SERVER =
|
||||
export const DEFAULT_PUSH_SERVER =
|
||||
import.meta.env.VITE_DEFAULT_PUSH_SERVER || AppString.PROD_PUSH_SERVER;
|
||||
|
||||
/** Base URL of the notify-api (FCM wakeup registration and SMS notifications). */
|
||||
export const DEFAULT_NOTIFY_API_SERVER =
|
||||
import.meta.env.VITE_DEFAULT_NOTIFY_API_SERVER ||
|
||||
AppString.PROD_NOTIFY_API_SERVER;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* Delegated authorization JWTs for notification-wakeup-service via notify-api.
|
||||
* Distinct from the native background prefetch pool in backgroundJwt.ts.
|
||||
*/
|
||||
export const DELEGATED_NOTIFICATION_JWT_COUNT = 100;
|
||||
@@ -276,6 +276,17 @@ const MIGRATIONS = [
|
||||
ALTER TABLE settings ADD COLUMN reminderFastRolloverForTesting BOOLEAN DEFAULT FALSE;
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "010_add_sms_notification_settings",
|
||||
sql: `
|
||||
-- Verified mobile number this identity registered with the notify-api
|
||||
ALTER TABLE settings ADD COLUMN notifyingNewActivitySmsPhone TEXT;
|
||||
-- Local time of day the notify-api was told to text, blank when off
|
||||
ALTER TABLE settings ADD COLUMN notifyingNewActivitySmsTime TEXT;
|
||||
-- Master switch for the SMS channel; a paused channel keeps its number
|
||||
ALTER TABLE settings ADD COLUMN smsNotificationsEnabled BOOLEAN DEFAULT FALSE;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,11 +50,15 @@ export type Settings = {
|
||||
lastViewedClaimId?: string;
|
||||
|
||||
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
|
||||
notifyingNewActivitySmsPhone?: string; // verified mobile number registered with the notify-api for text alerts
|
||||
notifyingNewActivitySmsTime?: string; // set to their chosen time if they have turned on new-activity texts
|
||||
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
|
||||
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
|
||||
/** Dev/test only: use 10-minute rollover interval for daily reminder (plugin rolloverIntervalMinutes) */
|
||||
reminderFastRolloverForTesting?: boolean;
|
||||
|
||||
smsNotificationsEnabled?: boolean; // master switch for the text-message channel
|
||||
|
||||
partnerApiServer?: string; // partner server API URL
|
||||
|
||||
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface AccountSettings {
|
||||
bbox: BoundingBox;
|
||||
}>;
|
||||
notifyingNewActivityTime?: string;
|
||||
notifyingNewActivitySmsPhone?: string;
|
||||
notifyingNewActivitySmsTime?: string;
|
||||
smsNotificationsEnabled?: boolean;
|
||||
notifyingReminderMessage?: string;
|
||||
notifyingReminderTime?: string;
|
||||
starredPlanHandleIds?: string[];
|
||||
@@ -80,6 +83,12 @@ export interface ProfileState {
|
||||
export interface NotificationState {
|
||||
notifyingNewActivity: boolean;
|
||||
notifyingNewActivityTime: string;
|
||||
/** Master switch for the text-message channel. */
|
||||
smsEnabled: boolean;
|
||||
/** Verified number, kept while the channel is paused so re-enabling is free. */
|
||||
notifyingNewActivitySmsPhone: string;
|
||||
notifyingNewActivitySms: boolean;
|
||||
notifyingNewActivitySmsTime: string;
|
||||
notifyingReminder: boolean;
|
||||
notifyingReminderMessage: string;
|
||||
notifyingReminderTime: string;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Batch of per-local-day delegated JWTs for notify-api / wakeup-service.
|
||||
*
|
||||
* Not authentication JWTs, not alertSearch cursor ULIDs, and not the native
|
||||
* background prefetch pool (`mintBackgroundJwtTokenPool`).
|
||||
*/
|
||||
|
||||
export interface DelegatedNotificationJwtWindow {
|
||||
/** 1-based; 1 is today in the user's timezone. */
|
||||
sequence: number;
|
||||
/** Calendar date of this slot, YYYY-MM-DD in the user's timezone. */
|
||||
localDate: string;
|
||||
/** Unix seconds at 00:00:00 of this local day. */
|
||||
nbf: number;
|
||||
/** Unix seconds at 00:00:00 of the following local day. */
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface DelegatedNotificationJwtSlot
|
||||
extends DelegatedNotificationJwtWindow {
|
||||
jwt: string;
|
||||
}
|
||||
|
||||
export interface DelegatedNotificationJwtBatch {
|
||||
did: string;
|
||||
timeZone: string;
|
||||
mintedAtEpoch: number;
|
||||
tokens: DelegatedNotificationJwtSlot[];
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
export * from "./alertSearch";
|
||||
export * from "./claims";
|
||||
export * from "./delegatedNotificationJwt";
|
||||
export * from "./claims-result";
|
||||
export * from "./common";
|
||||
export * from "./deepLinks";
|
||||
export * from "./limits";
|
||||
export * from "./notifyApi";
|
||||
export * from "./records";
|
||||
export * from "./user";
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Wire payloads for the notify-api (notification-wakeup-service): request
|
||||
* bodies, query parameters, the JWT payloads the app signs for it, and the
|
||||
* bodies it answers with.
|
||||
*
|
||||
* Each type names the route it belongs to and holds the fields that route reads
|
||||
* or writes; a field the app sends and the service ignores is marked "Not read
|
||||
* by the service." The service's code is the source of truth, in
|
||||
* notification-wakeup-service: `src/routes/notifications.ts`,
|
||||
* `src/routes/notifySms.ts`, `src/routes/debug.ts`, `src/middleware/`, and
|
||||
* `src/services/alertAuthorization.ts`.
|
||||
*
|
||||
* Two kinds of JWT travel to the service:
|
||||
* - The Bearer token on each request authenticates the caller, and the service
|
||||
* takes the DID from it; no body carries the DID. On `/notify-sms` that token
|
||||
* also carries an {@link SmsNotificationActionClaim}.
|
||||
* - The delegated JWTs inside an {@link AlertAuthorizationRequestBody} are
|
||||
* stored credentials the service later presents to Endorser and Partner.
|
||||
* They authenticate nothing about the upload that carries them.
|
||||
*
|
||||
* Refusals come in these shapes, by where they are raised:
|
||||
* - {@link NotifyApiUncodedFailure}: a message and no code, from the auth
|
||||
* stages in front of every authenticated route, and from a push
|
||||
* alert-authorization route that fails to store or delete.
|
||||
* - {@link NotificationDeviceRouteFailure}: a sentence under `error`, from
|
||||
* register and refresh.
|
||||
* - {@link DebugSendWakeupResponse} with `success: false`.
|
||||
* - {@link AlertAuthorizationBatchFailure} and {@link NotifySmsFailure}: coded.
|
||||
*
|
||||
* A proxy in front of the service can answer with none of these, or with no
|
||||
* JSON at all.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refusal with a message and no code. The auth stages send it with 401 for a
|
||||
* missing, invalid, or Endorser-rejected Bearer token and 503 when Endorser
|
||||
* cannot be reached; their messages end with a timestamp for finding the server
|
||||
* log. The push alert-authorization routes send it with 500 when storing or
|
||||
* deleting fails.
|
||||
*/
|
||||
export interface NotifyApiUncodedFailure {
|
||||
success: false;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device routes: /notifications/register, /notifications/refresh, /debug
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Local-test switch on the device routes. `true` with no `Authorization`
|
||||
* header makes the service skip JWT and Endorser checks and file the request
|
||||
* under a synthetic test user. A request carrying a Bearer token is
|
||||
* authenticated normally whatever this says, and no other route reads it.
|
||||
*/
|
||||
export interface NotifyApiTestModeFlag {
|
||||
testMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of `POST /notifications/register`: store this device's FCM token under
|
||||
* the caller's DID, keyed by `deviceId`. Success is a bare 200 with a
|
||||
* plain-text body; a failure to store is a bare 500.
|
||||
*/
|
||||
export interface NotificationRegisterRequest extends NotifyApiTestModeFlag {
|
||||
/** Stable per-install id; trimmed by the service and required non-empty. */
|
||||
deviceId: string;
|
||||
/** Required non-empty. */
|
||||
fcmToken: string;
|
||||
/** `Capacitor.getPlatform()`: `"ios"`, `"android"`, or `"web"`. Required non-empty. */
|
||||
platform: string;
|
||||
/** The DID comes from the Bearer token; a body with a `userId` key is rejected. */
|
||||
userId?: never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of `POST /notifications/refresh`. The service finds the caller's device
|
||||
* by `deviceId`, or by `fcmToken` when no `deviceId` is sent, and answers 400
|
||||
* when neither is present and non-empty. When both are sent they must name the
|
||||
* same device, or the answer is 404.
|
||||
*/
|
||||
export type NotificationRefreshRequest = NotifyApiTestModeFlag & {
|
||||
/** Not read by the service. */
|
||||
platform?: string;
|
||||
} & (
|
||||
| { deviceId: string; fcmToken?: string }
|
||||
| { deviceId?: string; fcmToken: string }
|
||||
);
|
||||
|
||||
/** Success body of `POST /notifications/refresh`. */
|
||||
export interface NotificationRefreshResponse {
|
||||
shouldNotify: boolean;
|
||||
/** Instants for the device to schedule, as Unix milliseconds. */
|
||||
nextNotifications: Array<{ timestamp: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refusal from `POST /notifications/register` (400) or
|
||||
* `POST /notifications/refresh` (400, or 404 when no device of the caller's
|
||||
* matches). `error` is a sentence such as `"Device not found"`, not a code.
|
||||
*/
|
||||
export interface NotificationDeviceRouteFailure {
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of `POST /debug/send-wakeup`, which pushes a WAKEUP_PING to one of the
|
||||
* caller's devices. The `/debug` routes exist only on a service started with
|
||||
* `DEBUG_ENDPOINT` on.
|
||||
*/
|
||||
export interface DebugSendWakeupRequest extends NotifyApiTestModeFlag {
|
||||
/** A token registered under the caller's DID; required non-empty. */
|
||||
fcmToken: string;
|
||||
/** Not read by the service. */
|
||||
deviceId?: string;
|
||||
/** Not read by the service. */
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every answer `POST /debug/send-wakeup` writes itself. A 200 still carries
|
||||
* `success: false` when the push was skipped or failed, so `success`, not the
|
||||
* status, says whether a WAKEUP_PING went out.
|
||||
*/
|
||||
export interface DebugSendWakeupResponse {
|
||||
success: boolean;
|
||||
/**
|
||||
* Why no push went out: `"fcmToken is required"` (400), `"Device not found"`
|
||||
* (404), or with a 200, `"Device was notified within the eligibility
|
||||
* threshold"` or `"FCM send failed"`.
|
||||
*/
|
||||
failureReason?: string;
|
||||
/** Last six characters of the token; absent on the 400. */
|
||||
fcmTokenSuffix?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alert authorization: /notifications/alert-authorization and
|
||||
// /notify-sms/alert-authorization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Payload the app signs for one {@link DelegatedAlertJwt}; the signer adds
|
||||
* `iss` (the DID) and `iat`. The service requires the signed `nbf` and `exp` to
|
||||
* equal the values listed beside the JWT.
|
||||
*/
|
||||
export interface DelegatedAlertJwtPayload {
|
||||
/** Unix seconds, at or before the midnight UTC that opens the entry's `day`. */
|
||||
nbf: number;
|
||||
/** Unix seconds, at or after the midnight UTC that closes the entry's `day`. */
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/** One day's delegated credential inside an {@link AlertAuthorizationRequestBody}. */
|
||||
export interface DelegatedAlertJwt {
|
||||
/** Integer; the batch's values are consecutive, starting anywhere. */
|
||||
sequence: number;
|
||||
/** UTC calendar day, `YYYY-MM-DD`, distinct across the batch. */
|
||||
day: string;
|
||||
/** Unix seconds; equal to the signed `nbf`. */
|
||||
nbf: number;
|
||||
/** Unix seconds; equal to the signed `exp`. */
|
||||
exp: number;
|
||||
/** Signed by the caller's DID, which must be a `did:ethr`. */
|
||||
jwt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of `PUT /notifications/alert-authorization` (push) and
|
||||
* `POST /notify-sms/alert-authorization` (SMS), which validate it with one
|
||||
* rule. A stored batch replaces the unused inventory the DID holds in that
|
||||
* channel; JWTs already spent stay behind.
|
||||
*/
|
||||
export interface AlertAuthorizationRequestBody {
|
||||
/** Required non-empty; echoed in the response. */
|
||||
batchId: string;
|
||||
/** UTC hour, integer 0-23. */
|
||||
notifyHourUtc: number;
|
||||
/** UTC minute, integer 0-59. */
|
||||
notifyMinuteUtc: number;
|
||||
/**
|
||||
* IANA zone name such as `"America/Denver"`. Validated and stored; no
|
||||
* scheduling decision reads it.
|
||||
*/
|
||||
timezone?: string;
|
||||
/** Exactly 100 entries. */
|
||||
jwts: DelegatedAlertJwt[];
|
||||
}
|
||||
|
||||
/** Success body of storing a batch, on either alert-authorization route. */
|
||||
export interface AlertAuthorizationResponse {
|
||||
success: true;
|
||||
batchId: string;
|
||||
/** The stored UTC hour; null only for a batch stored without one. */
|
||||
notifyHourUtc: number | null;
|
||||
/** The stored UTC minute; null only for a batch stored without one. */
|
||||
notifyMinuteUtc: number | null;
|
||||
timezone: string | null;
|
||||
/** JWTs stored from this batch. */
|
||||
storedCount: number;
|
||||
/** Unused JWTs the DID holds in this channel once the batch is stored. */
|
||||
unusedCount: number;
|
||||
}
|
||||
|
||||
/** Success body of `DELETE` on either alert-authorization route. */
|
||||
export interface AlertAuthorizationRevokeResponse {
|
||||
success: true;
|
||||
/** Batches removed, spent ones included. */
|
||||
deletedBatches: number;
|
||||
/** JWTs removed, spent ones included. */
|
||||
deletedJwts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coded refusal of a batch (400) on either alert-authorization route: it failed
|
||||
* validation, or the caller's identity cannot hold one. Nothing was stored.
|
||||
*/
|
||||
export interface AlertAuthorizationBatchFailure {
|
||||
success: false;
|
||||
error:
|
||||
| "ALERT_AUTHORIZATION_INVALID_BATCH"
|
||||
| "DELEGATED_JWT_UNSUPPORTED_IDENTITY";
|
||||
message: string;
|
||||
/** Specific problems, at most 20 plus a count of the rest. */
|
||||
details: string[];
|
||||
}
|
||||
|
||||
/** Refusal from `PUT` or `DELETE /notifications/alert-authorization`. */
|
||||
export type AlertAuthorizationFailure =
|
||||
| AlertAuthorizationBatchFailure
|
||||
| NotifyApiUncodedFailure;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SMS channel: /notify-sms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* What a `/notify-sms` action claim authorizes. The service checks `action`
|
||||
* against the route and, for handset actions, checks that `phoneNumber`
|
||||
* normalizes to the same E.164 number the request names.
|
||||
*/
|
||||
export type SmsActionTarget =
|
||||
// Bound to one handset; the number must match the request's.
|
||||
| {
|
||||
action: "register-phone" | "verify-phone" | "delete-phone";
|
||||
phoneNumber: string;
|
||||
}
|
||||
// Bound to a number only when the request sends `?phoneNumber=`.
|
||||
| { action: "list-phones"; phoneNumber?: string }
|
||||
// Acts on the DID's whole alert authorization; the service reads no number.
|
||||
| {
|
||||
action: "authorize-alert-search" | "revoke-alert-search";
|
||||
phoneNumber?: undefined;
|
||||
};
|
||||
|
||||
/** Actions the service's `requireSmsActionJwt` stage recognizes, one per route. */
|
||||
export type SmsAction = SmsActionTarget["action"];
|
||||
|
||||
/** The `claim` in the Bearer JWT on every `/notify-sms` request. */
|
||||
export type SmsNotificationActionClaim = {
|
||||
/** Claim namespace; distinct from `https://giftopia.me`, the link in the texts. */
|
||||
"@context": "https://giftopia.tech";
|
||||
"@type": "SmsNotificationAction";
|
||||
} & SmsActionTarget;
|
||||
|
||||
/**
|
||||
* Payload the app signs as the Bearer JWT on every `/notify-sms` request; the
|
||||
* signer adds `iss`, `iat`, and `exp`. The service refuses a token whose `iat`
|
||||
* is further than `SMS_ACTION_JWT_MAX_AGE_SEC` from its clock or whose `exp`
|
||||
* has passed, and accepts each token once.
|
||||
*/
|
||||
export interface SmsActionJwtPayload {
|
||||
claim: SmsNotificationActionClaim;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query of `GET /notify-sms/phone`. With `phoneNumber`, the response adds the
|
||||
* DIDs verified on that number, which the service reveals only to a DID that
|
||||
* has verified it too.
|
||||
*/
|
||||
export interface SmsPhoneListQuery {
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
/** One of the caller's registrations, as `GET /notify-sms/phone` lists it. */
|
||||
export interface SmsPhoneRegistration {
|
||||
/** The full E.164 number; these are the caller's own. */
|
||||
phoneNumber: string;
|
||||
verified: boolean;
|
||||
/** ISO 8601; null until verified. */
|
||||
verifiedAt: string | null;
|
||||
/** ISO 8601. */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success body of `GET /notify-sms/phone`, oldest registration first. An
|
||||
* identity with no registrations gets an empty list.
|
||||
*/
|
||||
export interface SmsPhoneListResponse {
|
||||
success: true;
|
||||
phones: SmsPhoneRegistration[];
|
||||
/** The queried number, normalized; present exactly when `?phoneNumber=` was sent. */
|
||||
phoneNumber?: string;
|
||||
/**
|
||||
* Every DID verified on the queried number, the caller's included; present
|
||||
* exactly when `?phoneNumber=` was sent.
|
||||
*/
|
||||
dids?: string[];
|
||||
}
|
||||
|
||||
/** Body of `POST /notify-sms/phone`: record the number, unverified, and text it a code. */
|
||||
export interface SmsPhoneRegisterRequest {
|
||||
/** E.164; the service normalizes, taking ten bare digits as a US number. */
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success body of `POST /notify-sms/phone`. `phoneNumber` is masked, such as
|
||||
* `+1555*****23`, so it cannot stand in for the number that was sent.
|
||||
*/
|
||||
export type SmsPhoneRegisterResponse =
|
||||
// The caller had already verified this number; no text was sent.
|
||||
| { success: true; phoneNumber: string; verified: true }
|
||||
| {
|
||||
success: true;
|
||||
phoneNumber: string;
|
||||
verified: false;
|
||||
/** ISO 8601; when the texted code stops working. */
|
||||
expiresAt: string;
|
||||
/**
|
||||
* The plaintext code, only from a service run with both
|
||||
* `NODE_ENV=test-local` and `SMS_DEV_ECHO_CODE`.
|
||||
*/
|
||||
devCode?: string;
|
||||
};
|
||||
|
||||
/** Body of `PUT /notify-sms/phone`: match the texted code. */
|
||||
export interface SmsPhoneVerifyRequest {
|
||||
phoneNumber: string;
|
||||
/** The six digits as texted. */
|
||||
code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success body of `PUT /notify-sms/phone`, also for a number the caller had
|
||||
* already verified. `phoneNumber` is masked.
|
||||
*/
|
||||
export interface SmsPhoneVerifyResponse {
|
||||
success: true;
|
||||
phoneNumber: string;
|
||||
verified: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Body, and also query, of `DELETE /notify-sms/phone`. The service reads the
|
||||
* body first and falls back to the query, because proxies may drop DELETE
|
||||
* bodies.
|
||||
*/
|
||||
export interface SmsPhoneDeleteRequest {
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
/** Success body of `DELETE /notify-sms/phone`; `deleted` is false when the number was not registered. */
|
||||
export interface SmsPhoneDeleteResponse {
|
||||
success: true;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
/** Codes of `/notify-sms` refusals that carry nothing beyond the message. */
|
||||
export type NotifySmsPlainErrorCode =
|
||||
| "SMS_DISABLED" // 503
|
||||
| "SMS_NOT_CONFIGURED" // 500
|
||||
| "SMS_PHONE_INVALID" // 400
|
||||
| "SMS_PHONE_BLOCKED" // 403
|
||||
| "SMS_PHONE_NOT_VERIFIED_BY_CALLER" // 403
|
||||
| "SMS_RECIPIENT_NOT_ALLOWED" // 403
|
||||
| "SMS_CODE_RATE_LIMITED" // 429
|
||||
| "SMS_CODE_SEND_FAILED" // 502
|
||||
| "SMS_CODE_EXPIRED" // 400
|
||||
| "SMS_CODE_ATTEMPTS_EXHAUSTED" // 429
|
||||
| "SMS_NO_VERIFIED_PHONE" // 409
|
||||
| "SMS_ALERT_AUTHORIZATION_FAILED" // 500
|
||||
| "SMS_ALERT_AUTHORIZATION_DELETE_FAILED" // 500
|
||||
| "SMS_ACTION_JWT_NOT_AUTHENTICATED" // 500
|
||||
| "SMS_ACTION_JWT_MISSING_CLAIM" // 403
|
||||
| "SMS_ACTION_JWT_WRONG_ACTION" // 403
|
||||
| "SMS_ACTION_JWT_PHONE_MISMATCH" // 403
|
||||
| "SMS_ACTION_JWT_STALE" // 401
|
||||
| "SMS_ACTION_JWT_EXPIRED" // 401
|
||||
| "SMS_ACTION_JWT_REPLAYED"; // 401
|
||||
|
||||
/**
|
||||
* A coded refusal from any `/notify-sms` route. The auth stages in front of
|
||||
* these routes refuse with a {@link NotifyApiUncodedFailure} instead.
|
||||
*/
|
||||
export type NotifySmsFailure =
|
||||
| { success: false; error: NotifySmsPlainErrorCode; message: string }
|
||||
| {
|
||||
success: false;
|
||||
error: "SMS_CODE_MISMATCH"; // 400
|
||||
message: string;
|
||||
/** Wrong codes left before this code is cleared. */
|
||||
attemptsRemaining: number;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: "SMS_PHONE_DID_LIMIT"; // 409
|
||||
message: string;
|
||||
/** Most identities one number may carry. */
|
||||
limit: number;
|
||||
/** Identities other than the caller already verified on the number. */
|
||||
verifiedCount: number;
|
||||
/**
|
||||
* Every DID verified on the number. Sent only by `PUT`, whose caller has
|
||||
* just proved possession of the handset.
|
||||
*/
|
||||
dids?: string[];
|
||||
}
|
||||
| AlertAuthorizationBatchFailure;
|
||||
@@ -8,8 +8,10 @@
|
||||
* - Authentication JWT: short-lived access token (`iss`/`iat`/`exp`) sent as
|
||||
* `Authorization: Bearer` for interactive API calls (`accessToken` /
|
||||
* `getHeaders`). Identifies the requester DID.
|
||||
* - Delegated notification JWT: 100 per-local-day tokens from
|
||||
* `mintDelegatedNotificationJwtBatch` for notify-api / wakeup-service.
|
||||
* - Delegated notification JWT: a batch of 100 tokens, each valid for one whole
|
||||
* UTC day, from `mintAlertAuthorizationBatch`
|
||||
* (`@/services/notifications/alertAuthorizationBatch`), uploaded to the
|
||||
* notify-api's push or SMS `alert-authorization` route.
|
||||
* - Native background pool: `mintBackgroundJwtTokenPool` for daily-notification
|
||||
* plugin prefetch. Unrelated to alertSearch and to the delegated batch.
|
||||
* - alertSearch cursor ULID: server-issued record/JWT primary id (26-char
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Mint 100 delegated notification JWTs (one local day each) for notify-api.
|
||||
*
|
||||
* JWT kinds (do not mix):
|
||||
* - Authentication JWT: short-lived `accessToken` / `getHeaders` Bearer for the
|
||||
* setup request itself (not generated here).
|
||||
* - Delegated notification JWT: this module. Signed like other Endorser JWTs
|
||||
* (`createEndorserJwtForDid`). `nbf`/`exp` are that local day's bounds.
|
||||
* Sequence is array order: index 0 / sequence 1 = today.
|
||||
* - Native background pool: `mintBackgroundJwtTokenPool` — unchanged, unused here.
|
||||
* - alertSearch cursor ULID: server record id, not a signed token.
|
||||
*
|
||||
* Timezone: device IANA zone via Luxon `DateTime.local().zoneName` (same source
|
||||
* as project create/edit). Not persisted in settings. Pass `timeZone` to override.
|
||||
*
|
||||
* Passkey (JWANT) identities cannot carry per-day nbf/exp; minting throws.
|
||||
*/
|
||||
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import { DELEGATED_NOTIFICATION_JWT_COUNT } from "@/constants/delegatedNotificationJwt";
|
||||
import type {
|
||||
DelegatedNotificationJwtBatch,
|
||||
DelegatedNotificationJwtSlot,
|
||||
DelegatedNotificationJwtWindow,
|
||||
} from "@/interfaces/delegatedNotificationJwt";
|
||||
import { isFromPasskey } from "@/libs/crypto/vc";
|
||||
import { createEndorserJwtForDid } from "@/libs/endorserServer";
|
||||
import { retrieveAccountMetadata } from "@/libs/util";
|
||||
|
||||
export function resolveUserTimeZone(timeZone?: string): string {
|
||||
const zone = timeZone ?? DateTime.local().zoneName ?? undefined;
|
||||
if (!zone) {
|
||||
throw new Error("Could not determine the user's timezone.");
|
||||
}
|
||||
const probe = DateTime.now().setZone(zone);
|
||||
if (!probe.isValid) {
|
||||
throw new Error(
|
||||
"Invalid timezone for delegated notification JWTs: " + zone,
|
||||
);
|
||||
}
|
||||
return zone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-day [nbf, exp) windows for sequence 1..count.
|
||||
* DST is handled by Luxon startOf('day') in the given IANA zone.
|
||||
*/
|
||||
export function buildDelegatedNotificationJwtWindows(
|
||||
count: number = DELEGATED_NOTIFICATION_JWT_COUNT,
|
||||
timeZone?: string,
|
||||
now: Date = new Date(),
|
||||
): DelegatedNotificationJwtWindow[] {
|
||||
const zone = resolveUserTimeZone(timeZone);
|
||||
const todayStart = DateTime.fromJSDate(now, { zone }).startOf("day");
|
||||
const windows: DelegatedNotificationJwtWindow[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const dayStart = todayStart.plus({ days: i }).startOf("day");
|
||||
const nextStart = dayStart.plus({ days: 1 }).startOf("day");
|
||||
windows.push({
|
||||
sequence: i + 1,
|
||||
localDate: dayStart.toFormat("yyyy-LL-dd"),
|
||||
nbf: Math.floor(dayStart.toSeconds()),
|
||||
exp: Math.floor(nextStart.toSeconds()),
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
export function delegatedNotificationJwtStrings(
|
||||
batch: DelegatedNotificationJwtBatch,
|
||||
): string[] {
|
||||
return batch.tokens.map((slot) => slot.jwt);
|
||||
}
|
||||
|
||||
export async function mintDelegatedNotificationJwtBatch(
|
||||
did: string,
|
||||
options?: { timeZone?: string; now?: Date },
|
||||
): Promise<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.localDate}`,
|
||||
});
|
||||
tokens.push({ ...window, jwt });
|
||||
}
|
||||
|
||||
return { did, timeZone, mintedAtEpoch, tokens };
|
||||
}
|
||||
@@ -438,6 +438,18 @@ router.beforeEach(async (to, _from, next) => {
|
||||
// sessionStorage may be unavailable
|
||||
}
|
||||
|
||||
// Keep diagnostic pages reachable when identity creation fails (e.g. a
|
||||
// failed migration leaves every DB call throwing), so the user can open
|
||||
// Profile, then Advanced Settings, then the Test Page or Logs, instead of
|
||||
// being bounced back to /start forever.
|
||||
const diagnosticRoutes = ["/account", "/test", "/logs"];
|
||||
if (diagnosticRoutes.includes(to.path)) {
|
||||
logger.info(
|
||||
`[Router] 🩺 Allowing diagnostic route ${to.path} despite identity creation failure`,
|
||||
);
|
||||
return next();
|
||||
}
|
||||
|
||||
// Redirect to start page if identity creation fails
|
||||
// This allows users to manually create an identity or troubleshoot
|
||||
logger.info(
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { PushNotificationSchema } from "@capacitor/push-notifications";
|
||||
import type {
|
||||
NotificationRefreshRequest,
|
||||
NotificationRefreshResponse,
|
||||
} from "@/interfaces/notifyApi";
|
||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||
import { getOrCreateDeviceId } from "./deviceId";
|
||||
import { REMINDER_ID_DAILY_REMINDER } from "./reminderIds";
|
||||
@@ -29,8 +33,9 @@ import {
|
||||
} from "./notificationLog";
|
||||
import {
|
||||
getNotificationApiHeaders,
|
||||
httpAuthErrorMessage,
|
||||
logSkippingRefreshDueToMissingAuth,
|
||||
notificationApiFailureMessage,
|
||||
readNotificationApiBody,
|
||||
} from "./notificationApiAuth";
|
||||
import { logNotification } from "./NotificationDebugEvents";
|
||||
|
||||
@@ -607,27 +612,35 @@ export async function refreshNotificationsWithDiagnostics(options?: {
|
||||
deviceId = await getOrCreateDeviceId();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"[NativeNotificationService] Could not obtain deviceId; refresh proceeding without deviceId",
|
||||
"[NativeNotificationService] Could not obtain deviceId; skipping refresh",
|
||||
err,
|
||||
);
|
||||
}
|
||||
if (!deviceId) {
|
||||
// The service finds the device by deviceId or fcmToken and answers 400
|
||||
// without either, so there is no request worth sending.
|
||||
const errorMessage = "no deviceId (cannot identify this device)";
|
||||
logRefreshFailure(startedAt, errorMessage, undefined, source);
|
||||
return { ok: false, scheduledCount: 0, errorMessage };
|
||||
}
|
||||
|
||||
const body: NotificationRefreshRequest = {
|
||||
deviceId,
|
||||
platform: Capacitor.getPlatform(),
|
||||
testMode: getTestMode(),
|
||||
};
|
||||
const baseUrl = getNotificationApiBaseUrl();
|
||||
const res = await fetch(`${baseUrl}/notifications/refresh`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify({
|
||||
deviceId,
|
||||
platform: Capacitor.getPlatform(),
|
||||
testMode: getTestMode(),
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage =
|
||||
res.status === 401 || res.status === 403
|
||||
? httpAuthErrorMessage(res.status)
|
||||
: res.statusText || `HTTP ${res.status}`;
|
||||
const errorMessage = notificationApiFailureMessage(
|
||||
res.status,
|
||||
await readNotificationApiBody(res),
|
||||
);
|
||||
logger.warn("[NativeNotificationService] refreshNotifications failed", {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
@@ -642,12 +655,11 @@ export async function refreshNotificationsWithDiagnostics(options?: {
|
||||
};
|
||||
}
|
||||
|
||||
const data: unknown = await res.json();
|
||||
const payload = data as NotificationRefreshPayload;
|
||||
const payload = (await res.json()) as NotificationRefreshResponse;
|
||||
const scheduledCount = Array.isArray(payload?.nextNotifications)
|
||||
? payload.nextNotifications.length
|
||||
: 0;
|
||||
await applyNotificationRefreshPayload(data);
|
||||
await applyNotificationRefreshPayload(payload);
|
||||
logRefreshSuccess(startedAt, scheduledCount, source);
|
||||
return { ok: true, scheduledCount };
|
||||
} catch (err) {
|
||||
|
||||
@@ -45,7 +45,7 @@ function writeStorage(key: string, value: string | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Backend URL override, or null when using the default Notification API. */
|
||||
/** Backend URL override, or null when using the default notify-api server. */
|
||||
export function getBackendBaseUrl(): string | null {
|
||||
const raw = readStorage(STORAGE_KEY_BACKEND_URL);
|
||||
if (raw === null) {
|
||||
@@ -100,8 +100,8 @@ export function setBypassAuth(enabled: boolean): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL for `/notifications/*` API calls.
|
||||
* Uses debug override when set; otherwise DEFAULT_NOTIFY_API_SERVER.
|
||||
* Base URL for notify-api calls (`/notifications/*`, `/notify-sms/*`).
|
||||
* Uses debug override when set; otherwise `DEFAULT_NOTIFY_API_SERVER`.
|
||||
*/
|
||||
export function getNotificationApiBaseUrl(): string {
|
||||
const override = getBackendBaseUrl();
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { PushNotificationSchema } from "@capacitor/push-notifications";
|
||||
import type {
|
||||
DebugSendWakeupRequest,
|
||||
DebugSendWakeupResponse,
|
||||
} from "@/interfaces/notifyApi";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { getOrCreateDeviceId } from "./deviceId";
|
||||
import {
|
||||
@@ -31,7 +35,8 @@ import {
|
||||
} from "./firebaseMessagingClient";
|
||||
import {
|
||||
getNotificationApiHeaders,
|
||||
httpAuthErrorMessage,
|
||||
notificationApiFailureMessage,
|
||||
readNotificationApiBody,
|
||||
} from "./notificationApiAuth";
|
||||
import {
|
||||
applyNotificationRefreshPayload,
|
||||
@@ -58,49 +63,30 @@ export type PendingNotificationsResult = {
|
||||
};
|
||||
|
||||
export type SendRealWakeupPingResult =
|
||||
| { ok: true; responseBody?: unknown }
|
||||
| { ok: true; responseBody?: DebugSendWakeupResponse }
|
||||
| {
|
||||
ok: false;
|
||||
errorMessage: string;
|
||||
status?: number;
|
||||
responseBody?: unknown;
|
||||
responseBody?: DebugSendWakeupResponse;
|
||||
};
|
||||
|
||||
function wakeupPingResponseDetail(body: unknown): Record<string, unknown> {
|
||||
if (typeof body !== "object" || body === null) {
|
||||
/** The fields of a send-wakeup answer worth a debug log line. */
|
||||
function wakeupPingResponseDetail(
|
||||
body: DebugSendWakeupResponse | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!body) {
|
||||
return {};
|
||||
}
|
||||
const record = body as Record<string, unknown>;
|
||||
const detail: Record<string, unknown> = {};
|
||||
for (const key of [
|
||||
"success",
|
||||
"message",
|
||||
"reason",
|
||||
"error",
|
||||
"tokenSuffix",
|
||||
"deviceId",
|
||||
] as const) {
|
||||
if (record[key] !== undefined) {
|
||||
detail[key] = record[key];
|
||||
}
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function wakeupPingFailureMessage(status: number, body: unknown): string {
|
||||
if (typeof body === "object" && body !== null) {
|
||||
const record = body as Record<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}`;
|
||||
return {
|
||||
success: body.success,
|
||||
...(body.failureReason !== undefined
|
||||
? { failureReason: body.failureReason }
|
||||
: {}),
|
||||
...(body.fcmTokenSuffix !== undefined
|
||||
? { fcmTokenSuffix: body.fcmTokenSuffix }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isUnimplementedError(e: unknown): boolean {
|
||||
@@ -197,26 +183,30 @@ export const NotificationDebugService = {
|
||||
|
||||
const deviceId = await getOrCreateDeviceId();
|
||||
const baseUrl = getNotificationApiBaseUrl();
|
||||
const body: DebugSendWakeupRequest = {
|
||||
deviceId,
|
||||
fcmToken,
|
||||
platform: Capacitor.getPlatform(),
|
||||
testMode: getTestMode(),
|
||||
};
|
||||
const res = await fetch(`${baseUrl}/debug/send-wakeup`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify({
|
||||
deviceId,
|
||||
fcmToken,
|
||||
platform: Capacitor.getPlatform(),
|
||||
testMode: getTestMode(),
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
let responseBody: unknown;
|
||||
try {
|
||||
responseBody = await res.json();
|
||||
} catch {
|
||||
responseBody = undefined;
|
||||
}
|
||||
const parsed = await readNotificationApiBody(res);
|
||||
const responseBody =
|
||||
typeof parsed === "object" && parsed !== null
|
||||
? (parsed as DebugSendWakeupResponse)
|
||||
: undefined;
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = wakeupPingFailureMessage(res.status, responseBody);
|
||||
// A 200 still reports a skipped or failed push as `success: false`.
|
||||
if (!res.ok || responseBody?.success === false) {
|
||||
const errorMessage = notificationApiFailureMessage(
|
||||
res.status,
|
||||
responseBody,
|
||||
);
|
||||
logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, {
|
||||
status: res.status,
|
||||
token: truncateFcmTokenForLog(fcmToken),
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { NotificationRegisterRequest } from "@/interfaces/notifyApi";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { getOrCreateDeviceId } from "./deviceId";
|
||||
import {
|
||||
@@ -22,8 +23,9 @@ import {
|
||||
} from "./NotificationDebugConfig";
|
||||
import {
|
||||
getNotificationApiHeaders,
|
||||
httpAuthErrorMessage,
|
||||
logNotificationAuthFailure,
|
||||
notificationApiFailureMessage,
|
||||
readNotificationApiBody,
|
||||
} from "./notificationApiAuth";
|
||||
import {
|
||||
logTokenRegistrationFailure,
|
||||
@@ -47,27 +49,29 @@ export async function registerToken(fcmToken: string): Promise<void> {
|
||||
throw new Error(`registerToken auth unavailable: ${auth.message}`);
|
||||
}
|
||||
|
||||
const body: NotificationRegisterRequest = {
|
||||
deviceId,
|
||||
fcmToken,
|
||||
platform: Capacitor.getPlatform(),
|
||||
testMode: getTestMode(),
|
||||
};
|
||||
const res = await fetch(`${baseUrl}/notifications/register`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify({
|
||||
deviceId,
|
||||
fcmToken,
|
||||
platform: Capacitor.getPlatform(),
|
||||
testMode: getTestMode(),
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// Success is a bare 200 with a plain-text body; only a refusal is read.
|
||||
if (!res.ok) {
|
||||
const authDetail =
|
||||
res.status === 401 || res.status === 403
|
||||
? httpAuthErrorMessage(res.status)
|
||||
: `HTTP ${res.status}`;
|
||||
const detail = notificationApiFailureMessage(
|
||||
res.status,
|
||||
await readNotificationApiBody(res),
|
||||
);
|
||||
logger.warn("[NotificationService] registerToken failed", {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
authDetail,
|
||||
detail,
|
||||
});
|
||||
throw new Error(`registerToken failed: ${authDetail}`);
|
||||
throw new Error(`registerToken failed: ${detail}`);
|
||||
}
|
||||
logTokenRegistrationSuccess(fcmToken);
|
||||
} catch (err) {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -52,6 +52,32 @@ export {
|
||||
timeToCron,
|
||||
timeToCronFiveMinutesBefore,
|
||||
} from "./dualScheduleConfig";
|
||||
export {
|
||||
ALERT_AUTHORIZATION_BATCH_DAYS,
|
||||
buildAlertAuthorizationBody,
|
||||
localTimeTextToUtc,
|
||||
mintAlertAuthorizationBatch,
|
||||
UnsupportedIdentityError,
|
||||
} from "./alertAuthorizationBatch";
|
||||
export type { AlertAuthorizationBatch } from "./alertAuthorizationBatch";
|
||||
|
||||
export {
|
||||
authorizePushAlertSearch,
|
||||
PushAlertAuthorizationError,
|
||||
revokePushAlertSearch,
|
||||
} from "./pushAlertAuthorizationApi";
|
||||
|
||||
export {
|
||||
authorizeSmsAlertSearch,
|
||||
deleteSmsPhone,
|
||||
listSmsPhones,
|
||||
normalizePhoneNumber,
|
||||
registerSmsPhone,
|
||||
revokeSmsAlertSearch,
|
||||
SmsApiError,
|
||||
smsErrorMessage,
|
||||
verifySmsPhone,
|
||||
} from "./smsNotificationApi";
|
||||
export type { DualScheduleConfigInput } from "./dualScheduleConfig";
|
||||
|
||||
export {
|
||||
|
||||
@@ -150,3 +150,39 @@ export function httpAuthErrorMessage(status: number): string {
|
||||
}
|
||||
return `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/** A response body parsed as JSON, or undefined when it is empty or not JSON. */
|
||||
export async function readNotificationApiBody(res: Response): Promise<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);
|
||||
} else {
|
||||
// For multi-statement SQL (like migrations), use executeSet method
|
||||
// This handles multiple statements properly
|
||||
if (
|
||||
sql.includes(";") &&
|
||||
sql.split(";").filter((s) => s.trim()).length > 1
|
||||
) {
|
||||
// This handles multiple statements properly.
|
||||
// Strip "--" line comments before splitting: a semicolon inside a
|
||||
// comment would otherwise split mid-comment, yielding a comment-only
|
||||
// statement (SQLITE_MISUSE, "error code 21: not an error") and a
|
||||
// statement that starts with stray comment text.
|
||||
// (No migration uses "--" or ";" inside a string literal.)
|
||||
const statements = sql
|
||||
.replace(/--[^\n]*/g, "")
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s);
|
||||
if (statements.length > 1) {
|
||||
// Multi-statement SQL - use executeSet for proper handling
|
||||
const statements = sql.split(";").filter((s) => s.trim());
|
||||
await this.db!.executeSet(
|
||||
statements.map((stmt) => ({
|
||||
statement: stmt.trim(),
|
||||
statement: stmt,
|
||||
values: [], // Empty values array for non-parameterized statements
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { DelegatedAlertJwt } from "@/interfaces/notifyApi";
|
||||
import {
|
||||
ALERT_AUTHORIZATION_BATCH_DAYS,
|
||||
buildAlertAuthorizationBody,
|
||||
mintAlertAuthorizationBatch,
|
||||
UnsupportedIdentityError,
|
||||
} from "@/services/notifications/alertAuthorizationBatch";
|
||||
import { retrieveFullyDecryptedAccount } from "@/libs/util";
|
||||
import { createEndorserJwtForKey } from "@/libs/crypto/vc";
|
||||
|
||||
jest.mock("@/libs/util", () => ({ retrieveFullyDecryptedAccount: jest.fn() }));
|
||||
jest.mock("@/libs/crypto/vc", () => ({ createEndorserJwtForKey: jest.fn() }));
|
||||
|
||||
const mockedRetrieve = retrieveFullyDecryptedAccount as unknown as jest.Mock;
|
||||
const mockedSign = createEndorserJwtForKey as unknown as jest.Mock;
|
||||
|
||||
const DID = "did:ethr:0x0000000000000000000000000000000000000001";
|
||||
const SECONDS_PER_DAY = 86400;
|
||||
|
||||
/**
|
||||
* The notify-api's acceptance rule for one entry, as enforced by
|
||||
* `validateAlertAuthorizationBatch` in notification-wakeup-service: the JWT is
|
||||
* valid for the whole UTC day it names.
|
||||
*/
|
||||
function coversWholeUtcDay(entry: DelegatedAlertJwt): boolean {
|
||||
const dayStart = Date.parse(`${entry.day}T00:00:00Z`) / 1000;
|
||||
return (
|
||||
entry.nbf < entry.exp &&
|
||||
entry.nbf <= dayStart &&
|
||||
entry.exp >= dayStart + SECONDS_PER_DAY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Date` methods whose answers depend on the device's zone. Jest cannot switch
|
||||
* the process zone inside a test file, so rather than minting under several
|
||||
* zones the test makes these throw: a minter that never calls them produces
|
||||
* the same batch in every zone.
|
||||
*/
|
||||
const ZONE_DEPENDENT_DATE_METHODS = [
|
||||
"getDate",
|
||||
"getDay",
|
||||
"getFullYear",
|
||||
"getHours",
|
||||
"getMilliseconds",
|
||||
"getMinutes",
|
||||
"getMonth",
|
||||
"getSeconds",
|
||||
"getTimezoneOffset",
|
||||
"setDate",
|
||||
"setFullYear",
|
||||
"setHours",
|
||||
"setMilliseconds",
|
||||
"setMinutes",
|
||||
"setMonth",
|
||||
"setSeconds",
|
||||
"toDateString",
|
||||
"toLocaleDateString",
|
||||
"toLocaleString",
|
||||
"toLocaleTimeString",
|
||||
"toString",
|
||||
"toTimeString",
|
||||
] as const;
|
||||
|
||||
async function mintWithoutZoneDependentDates(startingAt: Date) {
|
||||
const spies = ZONE_DEPENDENT_DATE_METHODS.map((method) =>
|
||||
jest.spyOn(Date.prototype, method).mockImplementation(() => {
|
||||
throw new Error(`Date.prototype.${method} reads the device's zone`);
|
||||
}),
|
||||
);
|
||||
try {
|
||||
return await mintAlertAuthorizationBatch(DID, startingAt);
|
||||
} finally {
|
||||
spies.forEach((spy) => spy.mockRestore());
|
||||
}
|
||||
}
|
||||
|
||||
// Just after and just before a UTC midnight, where the device's calendar day
|
||||
// differs from the UTC one in most zones, and starts whose 100 days cross a US
|
||||
// daylight-saving change.
|
||||
const INSTANTS = [
|
||||
"2026-09-13T00:10:00Z",
|
||||
"2026-09-13T23:50:00Z",
|
||||
"2026-02-20T12:00:00Z",
|
||||
"2026-10-15T07:30:00Z",
|
||||
];
|
||||
|
||||
describe("mintAlertAuthorizationBatch", () => {
|
||||
beforeEach(() => {
|
||||
mockedRetrieve.mockReset().mockResolvedValue({ did: DID, identity: "{}" });
|
||||
mockedSign
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
async (_account: unknown, payload: object) =>
|
||||
`jwt.${JSON.stringify(payload)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(INSTANTS)(
|
||||
"starting at %s, mints a batch the notify-api accepts in any zone",
|
||||
async (instant) => {
|
||||
const batch = await mintWithoutZoneDependentDates(new Date(instant));
|
||||
|
||||
expect(batch.batchId).toEqual(expect.any(String));
|
||||
expect(batch.batchId.length).toBeGreaterThan(0);
|
||||
expect(batch.jwts).toHaveLength(ALERT_AUTHORIZATION_BATCH_DAYS);
|
||||
|
||||
// Sequence 0 is the UTC day containing the start.
|
||||
expect(batch.jwts[0].day).toBe(instant.slice(0, 10));
|
||||
|
||||
const days = new Set(batch.jwts.map((entry) => entry.day));
|
||||
expect(days.size).toBe(ALERT_AUTHORIZATION_BATCH_DAYS);
|
||||
|
||||
batch.jwts.forEach((entry, index) => {
|
||||
expect(entry.sequence).toBe(index);
|
||||
expect(coversWholeUtcDay(entry)).toBe(true);
|
||||
if (index > 0) {
|
||||
const previousStart = Date.parse(
|
||||
`${batch.jwts[index - 1].day}T00:00:00Z`,
|
||||
);
|
||||
expect(Date.parse(`${entry.day}T00:00:00Z`) - previousStart).toBe(
|
||||
SECONDS_PER_DAY * 1000,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The service checks the signed claims against the listed window.
|
||||
expect(mockedSign).toHaveBeenCalledTimes(ALERT_AUTHORIZATION_BATCH_DAYS);
|
||||
mockedSign.mock.calls.forEach(([, payload], index) => {
|
||||
expect(payload).toEqual({
|
||||
nbf: batch.jwts[index].nbf,
|
||||
exp: batch.jwts[index].exp,
|
||||
});
|
||||
});
|
||||
|
||||
// One decryption serves every signature.
|
||||
expect(mockedRetrieve).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses passkey identities without signing", async () => {
|
||||
mockedRetrieve.mockResolvedValue({
|
||||
did: "did:peer:0zExample",
|
||||
passkeyCredIdHex: "abcd",
|
||||
});
|
||||
await expect(mintAlertAuthorizationBatch(DID)).rejects.toBeInstanceOf(
|
||||
UnsupportedIdentityError,
|
||||
);
|
||||
expect(mockedSign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an unknown DID", async () => {
|
||||
mockedRetrieve.mockResolvedValue(undefined);
|
||||
await expect(mintAlertAuthorizationBatch(DID)).rejects.toThrow(
|
||||
/No account found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAlertAuthorizationBody", () => {
|
||||
beforeEach(() => {
|
||||
mockedRetrieve.mockReset().mockResolvedValue({ did: DID, identity: "{}" });
|
||||
mockedSign.mockReset().mockResolvedValue("jwt");
|
||||
});
|
||||
|
||||
it("carries the fields both alert-authorization routes require", async () => {
|
||||
const body = await buildAlertAuthorizationBody(DID, 0, 30);
|
||||
|
||||
expect(body.batchId).toEqual(expect.any(String));
|
||||
expect(body.notifyHourUtc).toBe(0);
|
||||
expect(body.notifyMinuteUtc).toBe(30);
|
||||
expect(body.timezone).toBe(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
);
|
||||
expect(body.jwts).toHaveLength(ALERT_AUTHORIZATION_BATCH_DAYS);
|
||||
});
|
||||
|
||||
it("mints a distinct batch on every call", async () => {
|
||||
const first = await buildAlertAuthorizationBody(DID, 18, 0);
|
||||
const second = await buildAlertAuthorizationBody(DID, 18, 0);
|
||||
expect(first.batchId).not.toBe(second.batchId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
notificationApiFailureMessage,
|
||||
readNotificationApiBody,
|
||||
} from "@/services/notifications/notificationApiAuth";
|
||||
|
||||
jest.mock("@/libs/endorserServer", () => ({ getHeaders: jest.fn() }));
|
||||
jest.mock("@/services/PlatformServiceFactory", () => ({
|
||||
PlatformServiceFactory: { getInstance: jest.fn() },
|
||||
}));
|
||||
jest.mock("@/utils/logger", () => ({ logger: { warn: jest.fn() } }));
|
||||
jest.mock("@/services/notifications/notificationApiDebugMode", () => ({
|
||||
shouldBypassNotificationAuth: () => false,
|
||||
}));
|
||||
jest.mock("@/services/notifications/NotificationDebugEvents", () => ({
|
||||
logNotification: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("notificationApiFailureMessage", () => {
|
||||
it("reads a device route's `error` sentence", () => {
|
||||
expect(
|
||||
notificationApiFailureMessage(404, { error: "Device not found" }),
|
||||
).toBe("Device not found");
|
||||
});
|
||||
|
||||
it("reads a send-wakeup `failureReason`", () => {
|
||||
expect(
|
||||
notificationApiFailureMessage(200, {
|
||||
success: false,
|
||||
failureReason: "FCM send failed",
|
||||
fcmTokenSuffix: "abc123",
|
||||
}),
|
||||
).toBe("FCM send failed");
|
||||
});
|
||||
|
||||
it("reads an auth stage's `message`", () => {
|
||||
expect(
|
||||
notificationApiFailureMessage(401, {
|
||||
success: false,
|
||||
message: "Unauthorized. See server logs at 2026-09-13T12:00:00.000Z",
|
||||
}),
|
||||
).toBe("Unauthorized. See server logs at 2026-09-13T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("describes the status when the body says nothing", () => {
|
||||
expect(notificationApiFailureMessage(401, undefined)).toBe(
|
||||
"unauthorized (expired or invalid auth)",
|
||||
);
|
||||
expect(notificationApiFailureMessage(500, { error: " " })).toBe(
|
||||
"HTTP 500",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readNotificationApiBody", () => {
|
||||
it("returns undefined for a body that is not JSON", async () => {
|
||||
const res = {
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token O");
|
||||
},
|
||||
} as unknown as Response;
|
||||
await expect(readNotificationApiBody(res)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
listSmsPhones,
|
||||
registerSmsPhone,
|
||||
SmsApiError,
|
||||
smsErrorMessage,
|
||||
} from "@/services/notifications/smsNotificationApi";
|
||||
import { createEndorserJwtForDid } from "@/libs/endorserServer";
|
||||
|
||||
jest.mock("@/libs/endorserServer", () => ({
|
||||
createEndorserJwtForDid: jest.fn(),
|
||||
}));
|
||||
jest.mock("@/utils/logger", () => ({ logger: { warn: jest.fn() } }));
|
||||
jest.mock("@/services/notifications/NotificationDebugConfig", () => ({
|
||||
getNotificationApiBaseUrl: () => "https://notify.example",
|
||||
}));
|
||||
jest.mock("@/services/notifications/alertAuthorizationBatch", () => ({
|
||||
buildAlertAuthorizationBody: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSign = createEndorserJwtForDid as unknown as jest.Mock;
|
||||
const mockedFetch = jest.fn();
|
||||
global.fetch = mockedFetch as unknown as typeof fetch;
|
||||
|
||||
const DID = "did:ethr:0x0000000000000000000000000000000000000001";
|
||||
const FALLBACK = "Something went wrong.";
|
||||
|
||||
function answer(status: number, body: unknown) {
|
||||
mockedFetch.mockResolvedValueOnce({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
|
||||
});
|
||||
}
|
||||
|
||||
async function refusal(status: number, body: unknown): Promise<SmsApiError> {
|
||||
answer(status, body);
|
||||
try {
|
||||
await registerSmsPhone(DID, "555-555-0123");
|
||||
} catch (error) {
|
||||
if (error instanceof SmsApiError) {
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error("expected the call to be refused");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedSign.mockReset().mockResolvedValue("signed");
|
||||
});
|
||||
|
||||
describe("listSmsPhones", () => {
|
||||
it("returns the service's body and signs a claim without a number", async () => {
|
||||
const body = {
|
||||
success: true,
|
||||
phones: [
|
||||
{
|
||||
phoneNumber: "+15555550123",
|
||||
verified: true,
|
||||
verifiedAt: "2026-09-01T00:00:00.000Z",
|
||||
createdAt: "2026-08-31T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
phoneNumber: "+15555550199",
|
||||
verified: false,
|
||||
verifiedAt: null,
|
||||
createdAt: "2026-09-02T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
answer(200, body);
|
||||
|
||||
await expect(listSmsPhones(DID)).resolves.toEqual(body);
|
||||
expect(mockedSign).toHaveBeenCalledWith(
|
||||
DID,
|
||||
{
|
||||
claim: {
|
||||
"@context": "https://giftopia.tech",
|
||||
"@type": "SmsNotificationAction",
|
||||
action: "list-phones",
|
||||
},
|
||||
},
|
||||
300,
|
||||
);
|
||||
expect(mockedFetch.mock.calls[0][0]).toBe(
|
||||
"https://notify.example/notify-sms/phone",
|
||||
);
|
||||
});
|
||||
|
||||
it("binds the claim and the query to a queried number", async () => {
|
||||
answer(200, { success: true, phones: [], phoneNumber: "+15555550123" });
|
||||
|
||||
await listSmsPhones(DID, "(555) 555-0123");
|
||||
expect(mockedSign.mock.calls[0][1].claim.phoneNumber).toBe("+15555550123");
|
||||
expect(mockedFetch.mock.calls[0][0]).toBe(
|
||||
"https://notify.example/notify-sms/phone?phoneNumber=%2B15555550123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusals", () => {
|
||||
it("keeps a coded refusal's extra fields", async () => {
|
||||
const error = await refusal(400, {
|
||||
success: false,
|
||||
error: "SMS_CODE_MISMATCH",
|
||||
message: "That code does not match.",
|
||||
attemptsRemaining: 2,
|
||||
});
|
||||
|
||||
expect(error.code).toBe("SMS_CODE_MISMATCH");
|
||||
expect(error.status).toBe(400);
|
||||
expect(error.message).toBe("That code does not match.");
|
||||
expect(smsErrorMessage(error, FALLBACK)).toBe(
|
||||
"That code is not right. 2 tries left.",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads the identity limit from the refusal", async () => {
|
||||
const error = await refusal(409, {
|
||||
success: false,
|
||||
error: "SMS_PHONE_DID_LIMIT",
|
||||
message: "This number already carries the maximum number of identities.",
|
||||
limit: 5,
|
||||
verifiedCount: 5,
|
||||
});
|
||||
|
||||
expect(smsErrorMessage(error, FALLBACK)).toBe(
|
||||
"This number already has 5 identities on it, which is the limit.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not show an auth stage's server-log message", async () => {
|
||||
const error = await refusal(401, {
|
||||
success: false,
|
||||
message: "Unauthorized. See server logs at 2026-09-13T12:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(error.code).toBe("");
|
||||
expect(error.response).toBeUndefined();
|
||||
expect(smsErrorMessage(error, FALLBACK)).toBe(
|
||||
"The notification server could not confirm your identity. Please try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names an unreachable Endorser for an uncoded 503", async () => {
|
||||
const error = await refusal(503, {
|
||||
success: false,
|
||||
message: "Authentication service unavailable. See server logs at now",
|
||||
});
|
||||
|
||||
expect(smsErrorMessage(error, FALLBACK)).toBe(
|
||||
"The notification server cannot confirm identities right now. Please try again later.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back for an answer that is not the service's JSON", async () => {
|
||||
const error = await refusal(502, "<html>Bad Gateway</html>");
|
||||
|
||||
expect(error.code).toBe("");
|
||||
expect(error.message).toBe("HTTP 502");
|
||||
expect(smsErrorMessage(error, FALLBACK)).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it("shows the service's message for a code this app does not know", async () => {
|
||||
const error = await refusal(418, {
|
||||
success: false,
|
||||
error: "SMS_SOMETHING_NEW",
|
||||
message: "A reason the app has no wording for.",
|
||||
});
|
||||
|
||||
expect(smsErrorMessage(error, FALLBACK)).toBe(
|
||||
"A reason the app has no wording for.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the fallback for errors that are not SmsApiError", () => {
|
||||
expect(smsErrorMessage(new TypeError("Failed to fetch"), FALLBACK)).toBe(
|
||||
FALLBACK,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -302,6 +302,7 @@ export const PlatformServiceMixin = {
|
||||
column === "warnIfProdServer" ||
|
||||
column === "warnIfTestServer" ||
|
||||
column === "reminderFastRolloverForTesting" ||
|
||||
column === "smsNotificationsEnabled" ||
|
||||
// contacts
|
||||
column === "hideTheirContent" ||
|
||||
column === "registered" ||
|
||||
|
||||
+743
-159
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,50 @@
|
||||
<font-awesome icon="chevron-right" class="fa-fw"></font-awesome>
|
||||
</router-link>.)
|
||||
</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>
|
||||
<!-- eslint-enable -->
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<template>
|
||||
<main class="p-6 pb-24 max-w-3xl mx-auto" role="main">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold leading-none">Notification Debug</h1>
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="ms-auto text-sm text-blue-600"
|
||||
<main id="Content" class="p-6 pb-24 max-w-3xl mx-auto" role="main">
|
||||
<!-- Sub View Heading -->
|
||||
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
||||
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
||||
Notification Debug
|
||||
</h1>
|
||||
|
||||
<!-- Back -->
|
||||
<a
|
||||
class="order-first text-lg text-center leading-none p-1"
|
||||
@click="$router.go(-1)"
|
||||
>
|
||||
Back to Account
|
||||
</router-link>
|
||||
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
Vendored
+30
@@ -1,5 +1,35 @@
|
||||
/// <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 {
|
||||
readonly VITE_APP_TITLE: string;
|
||||
// more env variables...
|
||||
|
||||
@@ -208,6 +208,7 @@ test('Confirm test API setting (may fail if you are running your own Time Safari
|
||||
// Load account view
|
||||
await page.goto('./account');
|
||||
await page.getByTestId('advancedSettings').click();
|
||||
await page.getByTestId('serverUrlsToggle').click();
|
||||
|
||||
// look into the config file: if it starts Time Safari, it might say which server it should set by default
|
||||
const webServer = testInfo.config.webServer;
|
||||
|
||||
Reference in New Issue
Block a user