Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7604a92f60 | ||
|
|
4bf2fc1009 | ||
|
|
97f4f59eff | ||
|
|
6baf2a98ac | ||
|
|
44695a51cc | ||
|
|
044344026c | ||
|
|
fe9a2f0cbb | ||
|
|
240c3c5a76 | ||
|
|
4fcc9a40d0 | ||
|
|
2afe748292 | ||
|
|
34a8c51d2f | ||
|
|
59993cb96a | ||
|
|
940e851d3c | ||
|
|
d5bcdbae3f | ||
|
|
46a2e0aaf5 | ||
|
|
b02a1b29c5 | ||
|
|
3f291b0d63 | ||
|
|
ee8fb80cfe |
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Husky Commit Message Hook
|
||||
# Validates commit message format using commitlint
|
||||
#
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
# Run commitlint but don't fail the commit (|| true)
|
||||
# This provides helpful feedback without blocking commits
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Husky Pre-commit Hook
|
||||
# Runs lint-fix and Build Architecture Guard on staged files
|
||||
#
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
echo "🔍 Running pre-commit hooks..."
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Husky Pre-push Hook
|
||||
# Runs Build Architecture Guard to check commits being pushed
|
||||
#
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
echo "🔍 Running Build Architecture Guard (pre-push)..."
|
||||
|
||||
|
||||
+14
-12
@@ -1721,20 +1721,10 @@ VITE_APP_SERVER=https://timesafari.app
|
||||
fi
|
||||
```
|
||||
|
||||
2. **Platform-Specific Overrides**
|
||||
2. **Environment File Loading**
|
||||
|
||||
```bash
|
||||
# scripts/build-android.sh
|
||||
if [ "$BUILD_MODE" = "development" ]; then
|
||||
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
|
||||
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
|
||||
fi
|
||||
```
|
||||
|
||||
3. **Environment File Loading**
|
||||
|
||||
```bash
|
||||
# scripts/build-web.sh
|
||||
# scripts/build-web.sh, build-android.sh, build-ios.sh, build-electron.sh
|
||||
local env_file=".env.$BUILD_MODE" # .env.development, .env.test, .env.production
|
||||
if [ -f "$env_file" ]; then
|
||||
load_env_file "$env_file"
|
||||
@@ -1746,6 +1736,18 @@ VITE_APP_SERVER=https://timesafari.app
|
||||
fi
|
||||
```
|
||||
|
||||
3. **Platform-Specific Overrides**
|
||||
|
||||
These run last so the platform address wins over the `.env` files.
|
||||
|
||||
```bash
|
||||
# scripts/build-android.sh
|
||||
if [ "$BUILD_MODE" = "development" ]; then
|
||||
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
|
||||
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
|
||||
fi
|
||||
```
|
||||
|
||||
4. **Application Usage**
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [?] - 2026
|
||||
### Added
|
||||
- Full flow for setting up SMS notifications: phone registration and code
|
||||
verification, delegated alertSearch JWT batches with a send hour, and
|
||||
revocation to stop the texts
|
||||
- Notification debug panel action to mint and upload a push-channel alertSearch
|
||||
authorization, built on the same batch minter as the SMS channel
|
||||
### Fixed
|
||||
- Native build scripts set NODE_ENV, so `import.meta.env.DEV` matches the build
|
||||
mode instead of always reporting a production build
|
||||
- Android and iOS development builds keep their emulator and `--api-ip` API
|
||||
addresses, which the `.env` files had been overwriting
|
||||
|
||||
|
||||
## [1.3.8] - 2026
|
||||
### Added
|
||||
- Device wake-up for notifications
|
||||
|
||||
@@ -43,6 +43,15 @@ Assumes Xcode and Xcode Command Line Tools are installed.
|
||||
|
||||
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
npm run test:web
|
||||
|
||||
# ... or do every check:
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
## 🛡️ Build Architecture Guard
|
||||
|
||||
This project uses **Husky Git hooks** to protect the build system
|
||||
|
||||
@@ -98,7 +98,17 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
|
||||
: ""));
|
||||
}
|
||||
|
||||
/** One pool entry per UTC day (epoch day mod pool size); else primary jwtToken. */
|
||||
/**
|
||||
* Picks the pool entry whose validity window covers today, falling back to the
|
||||
* primary jwtToken when no pool is configured.
|
||||
*
|
||||
* <p>Each pooled JWT carries nbf/exp spanning exactly one UTC day, and the minter
|
||||
* (mintBackgroundJwtTokenPool) files the token for a given day at index
|
||||
* {@code epochDay % size}. That is why the index below is the raw epoch day rather
|
||||
* than a count from when the pool arrived: this side keeps no mint date, and the
|
||||
* same arithmetic on both ends is what lines the slot up with the day it covers.
|
||||
* A token read from the wrong slot is outside its window and Endorser rejects it.
|
||||
*/
|
||||
private String selectBearerTokenForRequest() {
|
||||
List<String> pool = jwtTokenPool;
|
||||
if (pool == null || pool.isEmpty()) {
|
||||
|
||||
@@ -128,19 +128,29 @@ Your Android device and computer **must be on the same Wi-Fi network** for the d
|
||||
|
||||
### Step 3: Configure API Endpoints
|
||||
|
||||
Create or edit `.env.development` with your computer's IP:
|
||||
Pass your computer's IP to the build with `--api-ip`. The build script points
|
||||
the claim and partner APIs at that address:
|
||||
|
||||
```bash
|
||||
# .env.development - for physical device testing
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=http://192.168.1.100:3000
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=http://192.168.1.100:3000
|
||||
npm run build:android:dev -- --api-ip 192.168.1.100
|
||||
```
|
||||
|
||||
**Important**: Replace `192.168.1.100` with your actual IP address.
|
||||
|
||||
Without `--api-ip`, a development build uses `10.0.2.2:3000`, which reaches the
|
||||
host machine from an emulator but not from a physical device.
|
||||
|
||||
The build script applies `--api-ip` after loading `.env.development`, so the
|
||||
flag wins for the claim and partner APIs. Other addresses come from that file,
|
||||
which development web builds share:
|
||||
|
||||
```bash
|
||||
# .env.development
|
||||
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
|
||||
VITE_DEFAULT_NOTIFY_API_SERVER=https://test-notify-api.timesafari.app
|
||||
VITE_APP_SERVER=http://192.168.1.100:8080
|
||||
```
|
||||
|
||||
**Important**: Replace `192.168.1.100` with your actual IP address.
|
||||
|
||||
### Step 4: Start Your Local Server
|
||||
|
||||
If testing against local API servers, ensure they're accessible from the network:
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Background New Activity JWT pool
|
||||
|
||||
How the app credentials native background prefetch for New Activity.
|
||||
|
||||
## 1. What the pool is for
|
||||
|
||||
Background prefetch runs in WorkManager on Android and a background task on iOS,
|
||||
with no JavaScript executing. It calls Endorser directly and needs a Bearer JWT
|
||||
that was minted while the app was awake, possibly days earlier.
|
||||
|
||||
The app mints a pool of `BACKGROUND_JWT_POOL_SIZE` JWTs and hands them to the
|
||||
plugin through `configureNativeFetcher`. Each covers one UTC day, and the native
|
||||
fetcher picks the one matching the day it runs.
|
||||
|
||||
Source: `src/libs/crypto/backgroundJwtPool.ts`,
|
||||
`src/services/notifications/nativeFetcherConfig.ts`,
|
||||
`src/constants/backgroundJwt.ts`.
|
||||
|
||||
## 2. Token shape
|
||||
|
||||
Each token in the pool carries:
|
||||
|
||||
| Claim | Value |
|
||||
|-------|-------|
|
||||
| `iss` | the minting DID |
|
||||
| `iat` | mint time |
|
||||
| `nbf` | its day's opening midnight, minus `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` |
|
||||
| `exp` | its day's closing midnight, plus `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` |
|
||||
|
||||
The slack widens the window at both ends for clock skew between the device and
|
||||
Endorser. Widening is safe; narrowing can leave a prefetch inside the day with
|
||||
no usable token.
|
||||
|
||||
Two properties follow from the day windows, and both are load-bearing:
|
||||
|
||||
- **Each token grants one day.** A token read from a log line or a captured
|
||||
header buys one day of Endorser access rather than the whole grant.
|
||||
- **The tokens are distinct.** ES256K signing is deterministic, so JWTs built
|
||||
from identical payloads are byte-identical. Differing windows are what keep
|
||||
the pool from collapsing into one string repeated `POOL_SIZE` times, which
|
||||
would defeat any duplicate-token rule the server applies.
|
||||
|
||||
## 3. Slot ordering
|
||||
|
||||
Both native fetchers select with `pool[epochDay % pool.size()]` and hold no
|
||||
record of when the pool was minted. The minter therefore files the token
|
||||
covering a given UTC day at index `epochDay % BACKGROUND_JWT_POOL_SIZE`.
|
||||
|
||||
Any `POOL_SIZE` consecutive days hit every index exactly once, so the array is
|
||||
dense whatever day minting starts on.
|
||||
|
||||
This is a contract across three languages. Changing the index arithmetic on one
|
||||
side without the others produces tokens presented outside their windows, which
|
||||
Endorser rejects with no local error. `src/test/backgroundJwtPool.test.ts`
|
||||
asserts the invariant by replaying the native selector against the minted pool.
|
||||
|
||||
Implementations: `TimeSafariNativeFetcher.selectBearerTokenForRequest` in
|
||||
`android/app/src/main/java/app/timesafari/` and `ios/App/App/`.
|
||||
|
||||
## 4. Identities that can mint
|
||||
|
||||
Seed-phrase (`did:ethr`) identities only.
|
||||
|
||||
Passkey (`did:peer`) identities raise
|
||||
`BackgroundJwtUnsupportedIdentityError`. Each of their signatures is a WebAuthn
|
||||
assertion, so minting a pool would raise one biometric prompt per token, and
|
||||
`createJwtNavigator` overrides the day window with a one-minute `exp` — the
|
||||
tokens would expire long before the prefetch they were minted for.
|
||||
`configureNativeFetcherIfReady` catches the error and leaves prefetch
|
||||
unconfigured.
|
||||
|
||||
The delegated alertSearch batch rejects the same identities, with the
|
||||
notify-api answering `DELEGATED_JWT_UNSUPPORTED_IDENTITY`.
|
||||
|
||||
## 5. Lifecycle
|
||||
|
||||
| Event | Action |
|
||||
|-------|--------|
|
||||
| App foreground, startup, notification-time change | `configureNativeFetcherIfReady` mints a pool and configures the fetcher |
|
||||
| Active identity changes (`$setActiveDid`) | `clearNativeFetcherPool` drops the pool the fetcher holds |
|
||||
|
||||
The identity is decrypted once per mint and reused for every signature.
|
||||
Decrypting per token costs seconds on a phone, and minting runs on every
|
||||
foreground.
|
||||
|
||||
## 6. What the pool bounds
|
||||
|
||||
The grant is `BACKGROUND_JWT_POOL_SIZE` days wide and each token inside it is
|
||||
one day wide.
|
||||
|
||||
`clearNativeFetcherPool` is custody, not revocation. Endorser exposes no
|
||||
revocation mechanism, so a token that left the device before the clear stays
|
||||
valid until its window closes. What the clear bounds is the ordinary case — an
|
||||
account switch, a sign-out, a shared or lost handset — where no copy was taken
|
||||
and the device's own store is the only remaining exposure.
|
||||
|
||||
Anti-replay in the strict sense is unavailable on this path. It would require
|
||||
either a server-side one-time-use store, which Endorser does not offer, or
|
||||
per-request signing, which would put the private key in native code. Day-scoped
|
||||
windows narrow the exposure instead of eliminating it.
|
||||
|
||||
## 7. Constants
|
||||
|
||||
All in `src/constants/backgroundJwt.ts`.
|
||||
|
||||
| Constant | Meaning |
|
||||
|----------|---------|
|
||||
| `BACKGROUND_JWT_POOL_SIZE` | Consecutive UTC days the pool covers, one token each. The whole forward grant a user authorizes per mint. |
|
||||
| `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` | Padding on each end of a day window, for clock skew. |
|
||||
| `BACKGROUND_JWT_SECONDS_PER_DAY` | The day frame each slot is cut from. |
|
||||
| `BACKGROUND_JWT_EXPIRY_DAYS` / `_SECONDS` | Lifetime for the single-token background path. |
|
||||
|
||||
Past the last covered day the pool carries no credential and prefetch stops
|
||||
until the app opens again.
|
||||
|
||||
## 8. A different credential
|
||||
|
||||
The notify-api's delegated alertSearch batch
|
||||
(`src/services/notifications/alertAuthorizationBatch.ts`) is a separate
|
||||
credential with a separate inventory. It authorizes the notification service to
|
||||
run a user's daily alertSearch server-side; this pool authorizes the user's own
|
||||
device to prefetch. They share the day-window shape and nothing else. See
|
||||
`notification-wakeup-service/README.md`.
|
||||
|
||||
## 9. Rejected
|
||||
|
||||
- **A unique `jti` per slot, with one shared long `exp`.** A `jti` is an
|
||||
identifier, not a replay defense: it does nothing unless the server keeps a
|
||||
seen-set, and Endorser's behavior here was never confirmed. Day windows make
|
||||
the tokens distinct for the same cost while also bounding each one.
|
||||
- **One long-lived token instead of a pool.** Fails if Endorser rejects
|
||||
duplicate JWT strings across days. That policy question is open (§10), so the
|
||||
design does not depend on the answer.
|
||||
- **Sizing the pool as `expiryDays + buffer`.** The rationale was headroom for
|
||||
duplicate-token rules. With one token per day, the pool size is the grant
|
||||
length in days and needs no separate buffer term.
|
||||
- **Per-request signing in native code (DPoP-style).** The only true anti-replay
|
||||
option, rejected to keep one signing implementation in TypeScript rather than
|
||||
forking crypto into Java and Swift.
|
||||
- **Routing all New Activity through the notification service and deleting this
|
||||
path.** Rejected: it would force every user onto server-side delegation, and
|
||||
the service is single-replica, so the direct device-to-Endorser path has no
|
||||
equivalent.
|
||||
|
||||
## 10. Open questions
|
||||
|
||||
- **Endorser duplicate-JWT policy.** Whether Endorser rejects a Bearer JWT
|
||||
string it has already seen is unconfirmed. The pool is correct either way; the
|
||||
answer would determine whether a pool is required at all.
|
||||
- **Maximum `exp` Endorser accepts.** Day-scoped windows are well inside any
|
||||
plausible limit, so this gates only the single-token path.
|
||||
- **Plugin behavior on an empty pool.** `clearNativeFetcherPool` passes empty
|
||||
credentials to `configureNativeFetcher`. A plugin build that rejects them
|
||||
leaves the previous pool in place; the failure is logged rather than reported
|
||||
as a successful clear.
|
||||
@@ -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).
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
# Plan: Background New Activity JWT — extended expiry + token pool
|
||||
|
||||
**Date:** 2026-03-27 14:29 PST
|
||||
**Status:** Draft for implementation
|
||||
**Audience:** TimeSafari / crowd-funder developers
|
||||
**Related:** `doc/endorser-jwt-background-prefetch-options.md`, `android/.../TimeSafariNativeFetcher.java`, `src/services/notifications/nativeFetcherConfig.ts`, `src/libs/crypto/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem statement
|
||||
|
||||
Background prefetch for New Activity calls Endorser with a Bearer JWT configured via `configureNativeFetcher`. The token previously came from `getHeaders()` → `accessToken()`, which used **`exp` ≈ 60 seconds** (`src/libs/crypto/index.ts`). Prefetch runs **minutes later** in WorkManager **without JavaScript**, so the JWT can be **expired** before the POST (`JWT_VERIFY_FAILED`).
|
||||
|
||||
**Goals:**
|
||||
|
||||
1. Use JWTs whose **`exp`** covers the gap between **last app-side configure** and **prefetch** (and ideally days without opening the app).
|
||||
2. Optionally support a **pool** of distinct JWT strings so Endorser can enforce **duplicate-JWT** / **one-time-use** rules without breaking daily prefetch. **Pool size** should follow **`expiryDays + buffer`** (one distinct token per day over the JWT lifetime, plus headroom for retries / edge cases); **implementation uses `BACKGROUND_JWT_POOL_SIZE = 100`** until policy changes.
|
||||
3. Keep pool size and expiry policy **easy to change** (constants / remote config later).
|
||||
|
||||
---
|
||||
|
||||
## 2. Guiding principles
|
||||
|
||||
| Principle | Implication |
|
||||
|-----------|-------------|
|
||||
| **Background has no JS** | Token selection and HTTP must run in **native** (or plugin) code using **persisted** data. |
|
||||
| **Single source of truth for signing** | Continue using **`createEndorserJwtForDid`** (same keys as today); do not fork crypto in Java/Kotlin. |
|
||||
| **Configurable pool size** | One constant `BACKGROUND_JWT_POOL_SIZE`; **currently 100**. Size should satisfy **`≥ expiryDays + buffer`** (see below). |
|
||||
| **Phased delivery** | Ship **extended expiry** first; add **pool** when server duplicate rules require it or in the same release if coordinated. |
|
||||
|
||||
### 2.1 Pool size rationale (`expiryDays + buffer`)
|
||||
|
||||
For **one New Activity prefetch per day**, each day should use a **distinct** JWT string if the server rejects reuse. Over the JWT lifetime (aligned with **`exp`**), you need at least **one token per day** the pool might be used without regeneration.
|
||||
|
||||
**Rule of thumb:**
|
||||
|
||||
```text
|
||||
BACKGROUND_JWT_POOL_SIZE ≥ ceil(BACKGROUND_JWT_EXPIRY_DAYS) + BACKGROUND_JWT_POOL_BUFFER
|
||||
```
|
||||
|
||||
- **`BACKGROUND_JWT_EXPIRY_DAYS`** — human-facing match to `exp` (e.g. **90**); convert to `BACKGROUND_JWT_EXPIRY_SECONDS` for the payload.
|
||||
- **`BACKGROUND_JWT_POOL_BUFFER`** — extra slots for **same-day retries**, manual tests, or stricter duplicate rules (e.g. **10**).
|
||||
|
||||
**Example:** 90‑day `exp` + buffer 10 ⇒ **minimum 100** logical slots. **This plan keeps `BACKGROUND_JWT_POOL_SIZE = 100`** as the shipped default so it matches that example; if `expiryDays` or buffer change later, **bump the constant** so the inequality still holds.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phases
|
||||
|
||||
### Phase A — Extended expiry only (minimum viable)
|
||||
|
||||
**Scope**
|
||||
|
||||
- Introduce a dedicated mint path for **background / native fetcher** use (name TBD, e.g. `accessTokenForBackgroundNotifications(did)`), producing **one** JWT per configure call with:
|
||||
- `iss`: DID (unchanged)
|
||||
- `iat`: now
|
||||
- `exp`: now + **`BACKGROUND_JWT_EXPIRY_SECONDS`** (derived from **`BACKGROUND_JWT_EXPIRY_DAYS`**; see §2.1 / Phase B constants — **confirm** with Endorser policy)
|
||||
- Optional: `jti` or nonce for uniqueness if needed for logging/debug
|
||||
|
||||
- **`configureNativeFetcherIfReady`** should pass this token (or keep using a thin wrapper) instead of reusing the **60s** `accessToken()` when configuring native fetcher **only** — **do not** change interactive `getHeaders()` / passkey caching behavior for normal API calls unless product asks for it.
|
||||
|
||||
**Files (likely)**
|
||||
|
||||
- `src/libs/crypto/index.ts` — new function or parameters; keep `accessToken()` default at 60s for existing callers.
|
||||
- `src/services/notifications/nativeFetcherConfig.ts` — obtain background JWT via the new mint path, not `getHeaders()`’s generic path, **or** add a dedicated branch that calls the new mint after resolving `did`.
|
||||
|
||||
**Native**
|
||||
|
||||
- **`TimeSafariNativeFetcher`**: still one `jwtToken` field; no pool yet. Ensure `configure()` is called whenever TS refreshes (startup, resume, Account — already partially covered).
|
||||
|
||||
**Exit criteria**
|
||||
|
||||
- Logcat: prefetch POST returns **200** (or non-expired 4xx) when user has not opened the app for several **minutes** after configure.
|
||||
- Endorser accepts **`exp`** far enough in the future (coordinate TTL policy).
|
||||
|
||||
---
|
||||
|
||||
### Phase B — Token pool (size 100; driven by `expiryDays + buffer`)
|
||||
|
||||
**Why**
|
||||
|
||||
- Endorser may **reject duplicate JWT strings** (same bearer used twice). One long-lived token could fail on **day 2** if the server marks each JWT as consumed.
|
||||
- A **pool** of **N** distinct JWTs (different payload, e.g. unique `jti` per token) gives **N** independent strings with the same long **`exp`**. **N** should follow **§2.1** (`expiryDays + buffer`); **100** is the initial **`BACKGROUND_JWT_POOL_SIZE`** (satisfies e.g. 90 + 10).
|
||||
|
||||
**Scope**
|
||||
|
||||
1. **Constants** (single place, e.g. `src/constants/backgroundJwt.ts` or next to native fetcher config):
|
||||
|
||||
```text
|
||||
BACKGROUND_JWT_EXPIRY_DAYS = 90 // align with Endorser; drives exp
|
||||
BACKGROUND_JWT_EXPIRY_SECONDS = 90 * 24 * 60 * 60 // derived
|
||||
BACKGROUND_JWT_POOL_BUFFER = 10 // retries / headroom; tune with server team
|
||||
BACKGROUND_JWT_POOL_SIZE = 100 // must be >= expiryDays + buffer; adjust if policy changes
|
||||
```
|
||||
|
||||
2. **Mint in TS** (uses `createEndorserJwtForDid`):
|
||||
|
||||
- Loop `i = 0 .. POOL_SIZE - 1`
|
||||
- Payload: `{ iss, iat, exp, jti: `${did}#bg#${i}` or uuid }` — **confirm** `jti` format with Endorser if required.
|
||||
|
||||
3. **Persistence** — native code must read the pool **without JS**:
|
||||
|
||||
- **Option B1 (preferred):** Implement in **`@timesafari/daily-notification-plugin`** (not in the app): extend **`configureNativeFetcher`** to accept an optional JWT pool, persist it for native read. **Handoff spec:** `doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md` — copy or reference that file in the plugin repo PR.
|
||||
- **Option B2 (app-only, no plugin release):** Write JSON to **Capacitor Preferences** or **encrypted storage** from TS; **TimeSafariNativeFetcher** reads the same store on Android (requires knowing Capacitor’s Android `SharedPreferences` name/key convention or a tiny **bridge** in `MainActivity`). Use only if plugin work is deferred.
|
||||
|
||||
4. **Selection policy in `TimeSafariNativeFetcher`** (before each POST):
|
||||
|
||||
- **By calendar day:** `index = (epochDay + offset) % POOL_SIZE` (stable per day).
|
||||
- Or **sequential:** persist `lastUsedIndex` in prefs and increment (wrap). **Decision:** document chosen policy; day-based is easier to reason about for “one token per day.”
|
||||
|
||||
5. **configureNativeFetcherIfReady** (and any “reset notifications on startup” hook):
|
||||
|
||||
- Regenerate full pool when user opens app (per product decision), then call configure with pool + **current** `apiBaseUrl` / `did`.
|
||||
|
||||
6. **iOS:** When iOS native fetcher exists, mirror Android behavior.
|
||||
|
||||
**Exit criteria**
|
||||
|
||||
- Prefetch succeeds on **consecutive days** with duplicate-JWT enforcement enabled on a **staging** Endorser.
|
||||
- Pool **refreshes** on startup without breaking dual schedule.
|
||||
|
||||
---
|
||||
|
||||
## 4. Detailed tasks (checklist)
|
||||
|
||||
### Crypto & TypeScript
|
||||
|
||||
- [ ] Add `BACKGROUND_JWT_EXPIRY_DAYS`, `BACKGROUND_JWT_EXPIRY_SECONDS`, `BACKGROUND_JWT_POOL_BUFFER`, and `BACKGROUND_JWT_POOL_SIZE` (exported constants), with a **comment** that `POOL_SIZE >= expiryDays + buffer` (see §2.1).
|
||||
- [ ] Implement `mintBackgroundJwtPool(did: string): Promise<string[]>` (or split single + pool).
|
||||
- [ ] Ensure each JWT has **unique** `jti` (or equivalent) for duplicate detection.
|
||||
- [ ] **Do not** break existing `accessToken()` 60s behavior for unrelated features.
|
||||
- [ ] Wire `configureNativeFetcherIfReady` to pass **single extended token** (Phase A) then **pool** (Phase B).
|
||||
- [ ] On **logout / identity clear**, clear persisted pool and call plugin clear if needed.
|
||||
|
||||
### Android
|
||||
|
||||
- [ ] **Phase A:** No structural change if `configure()` still receives one string; verify non-null `jwtToken` after configure.
|
||||
- [ ] **Phase B:** Parse pool from persisted JSON; implement `selectTokenForRequest()`; use selected token in `Authorization` header instead of sole `jwtToken` field (keep `configure` for `apiBaseUrl` / `did`).
|
||||
- [ ] Unit or instrumentation tests optional: selection index deterministic.
|
||||
|
||||
### Plugin (Option B1 — **daily-notification-plugin** repo)
|
||||
|
||||
- [ ] Follow **`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`** (API shape, Android/iOS, versioning).
|
||||
- [ ] Release new plugin version; bump dependency in this app.
|
||||
|
||||
### Product & server
|
||||
|
||||
- [ ] Endorser: confirm **max `exp`**, **duplicate JWT** semantics, recommended **`jti`** format.
|
||||
- [ ] Document operational limit: if user never opens app for **longer than `exp` allows** (or longer than **pool × daily use** without refresh), prefetch may fail until next open — align with `doc/endorser-jwt-background-prefetch-options.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Security notes
|
||||
|
||||
- Longer-lived JWTs and **many** tokens increase impact if device is compromised. Mitigations: **encrypted prefs** where possible, **no logging** of full JWTs, **revocation** story with Endorser (key rotation, deny list).
|
||||
- Pool regeneration on **login** should replace old pools.
|
||||
|
||||
---
|
||||
|
||||
## 6. Testing plan
|
||||
|
||||
| Test | Expected |
|
||||
|------|----------|
|
||||
| Configure → wait **> 5 min** → prefetch | **200** from `plansLastUpdatedBetween` (Phase A) |
|
||||
| Two consecutive **days** with duplicate-JWT staging | **200** both days (Phase B) |
|
||||
| Logout | Pool cleared; no stale bearer |
|
||||
| Lower `BACKGROUND_JWT_POOL_SIZE` in dev only (below `expiryDays + buffer`) | Expect possible reuse / server duplicate errors — use to reproduce failures |
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout / staging
|
||||
|
||||
1. Implement Phase A behind feature flag **optional** (or direct if low risk).
|
||||
2. Verify on **test-api.endorser.ch** with server team.
|
||||
3. Phase B behind flag or same release once server duplicate rules are understood.
|
||||
|
||||
---
|
||||
|
||||
## 8. Where plugin documentation lives
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| **`doc/plan-background-jwt-pool-and-expiry.md`** (this file) | End-to-end app plan: crypto, pool sizing, native host, rollout. |
|
||||
| **`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`** | **Plugin-only** handoff: extend `configureNativeFetcher`, persist pool, Android/iOS notes — intended for PRs in **daily-notification-plugin** (or Cursor on that repo). |
|
||||
|
||||
Keeping them **separate** avoids mixing consumer app tasks with plugin API contract; the plan **links** to the plugin feedback doc for Option B1.
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
| Topic | Location |
|
||||
|--------|----------|
|
||||
| Current 60s `accessToken` | `src/libs/crypto/index.ts` |
|
||||
| `createEndorserJwtForDid` | `src/libs/endorserServer.ts` |
|
||||
| Native configure | `src/services/notifications/nativeFetcherConfig.ts` |
|
||||
| Android HTTP | `android/.../TimeSafariNativeFetcher.java` |
|
||||
| Options doc (TTL, refresh, BFF) | `doc/endorser-jwt-background-prefetch-options.md` |
|
||||
| Plugin: `configureNativeFetcher` + JWT pool | `doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md` |
|
||||
|
||||
---
|
||||
|
||||
*Update this plan when Phase A/B ship or when Endorser policy changes.*
|
||||
@@ -3,7 +3,7 @@
|
||||
**Date:** 2026-03-27 PST
|
||||
**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin)
|
||||
**Consuming app:** crowd-funder-for-time-pwa (TimeSafari)
|
||||
**Related app plan:** `doc/plan-background-jwt-pool-and-expiry.md` (Phase B, Option B1)
|
||||
**Related app plan:** `doc/background-jwt-pool.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -85,7 +85,7 @@ When `configureNativeFetcher` exists on iOS, mirror Android: accept optional poo
|
||||
|
||||
| Topic | Location |
|
||||
|--------|----------|
|
||||
| End-to-end plan (Phase A/B, pool sizing) | `doc/plan-background-jwt-pool-and-expiry.md` |
|
||||
| Pool design, slot ordering, lifecycle | `doc/background-jwt-pool.md` |
|
||||
| Android fetcher | `android/.../TimeSafariNativeFetcher.java` |
|
||||
| Current configure call | `src/services/notifications/nativeFetcherConfig.ts` |
|
||||
| JWT options (expired token context) | `doc/endorser-jwt-background-prefetch-options.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”
|
||||
```
|
||||
@@ -38,7 +38,15 @@ final class TimeSafariNativeFetcher: NativeNotificationContentFetcher {
|
||||
try await fetchContentWithRetry(context: context, retryCount: 0)
|
||||
}
|
||||
|
||||
/// One pool entry per UTC day (epoch day mod pool size); else primary `jwtToken` — same as Java.
|
||||
/// Picks the pool entry whose validity window covers today, falling back to the
|
||||
/// primary `jwtToken` when no pool is configured. Same arithmetic as Java.
|
||||
///
|
||||
/// Each pooled JWT carries nbf/exp spanning exactly one UTC day, and the minter
|
||||
/// (`mintBackgroundJwtTokenPool`) files the token for a given day at index
|
||||
/// `epochDay % size`. That is why the index below is the raw epoch day rather than
|
||||
/// a count from when the pool arrived: this side keeps no mint date, and the same
|
||||
/// arithmetic on both ends is what lines the slot up with the day it covers.
|
||||
/// A token read from the wrong slot is outside its window and Endorser rejects it.
|
||||
private func selectBearerTokenForRequest() -> String? {
|
||||
guard let pool = jwtTokenPool, !pool.isEmpty else { return jwtToken }
|
||||
let epochDay = Int64(Date().timeIntervalSince1970 * 1000) / (24 * 60 * 60 * 1000)
|
||||
|
||||
Generated
+113
-1
@@ -153,7 +153,8 @@
|
||||
"ts-jest": "^29.4.0",
|
||||
"tsx": "^4.20.4",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
"vite": "^5.2.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@adraffy/ens-normalize": {
|
||||
@@ -11605,6 +11606,35 @@
|
||||
"vue": "^3.2.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/language-core": {
|
||||
"version": "2.4.28",
|
||||
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
|
||||
"integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/source-map": "2.4.28"
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/source-map": {
|
||||
"version": "2.4.28",
|
||||
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
|
||||
"integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@volar/typescript": {
|
||||
"version": "2.4.28",
|
||||
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
|
||||
"integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/language-core": "2.4.28",
|
||||
"path-browserify": "^1.0.1",
|
||||
"vscode-uri": "^3.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue-leaflet/vue-leaflet": {
|
||||
"version": "0.10.1",
|
||||
"license": "MIT",
|
||||
@@ -11663,6 +11693,17 @@
|
||||
"@vue/shared": "3.5.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-vue2": {
|
||||
"version": "2.7.16",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
|
||||
"integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"de-indent": "^1.0.2",
|
||||
"he": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"license": "MIT"
|
||||
@@ -11890,6 +11931,31 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/language-core": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.1.10.tgz",
|
||||
"integrity": "sha512-DAI289d0K3AB5TUG3xDp9OuQ71CnrujQwJrQnfuZDwo6eGNf0UoRlPuaVNO+Zrn65PC3j0oB2i7mNmVPggeGeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/language-core": "~2.4.8",
|
||||
"@vue/compiler-dom": "^3.5.0",
|
||||
"@vue/compiler-vue2": "^2.7.16",
|
||||
"@vue/shared": "^3.5.0",
|
||||
"alien-signals": "^0.2.0",
|
||||
"minimatch": "^9.0.3",
|
||||
"muggle-string": "^0.4.1",
|
||||
"path-browserify": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.13",
|
||||
"license": "MIT",
|
||||
@@ -12155,6 +12221,13 @@
|
||||
"ajv": "^6.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-0.2.2.tgz",
|
||||
"integrity": "sha512-cZIRkbERILsBOXTQmMrxc9hgpxglstn69zm+F1ARf4aPAzdAFYd6sBq87ErO0Fj3DV94tglcyHG5kQz9nDC/8A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anser": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz",
|
||||
@@ -15004,6 +15077,13 @@
|
||||
"version": "1.11.13",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/de-indent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"license": "MIT",
|
||||
@@ -23436,6 +23516,13 @@
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multibase": {
|
||||
"version": "4.0.6",
|
||||
"license": "MIT",
|
||||
@@ -30292,6 +30379,13 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
|
||||
"integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.13",
|
||||
"license": "MIT",
|
||||
@@ -30433,6 +30527,24 @@
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.1.10.tgz",
|
||||
"integrity": "sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/typescript": "~2.4.8",
|
||||
"@vue/language-core": "2.1.10",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"bin": {
|
||||
"vue-tsc": "bin/vue-tsc.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/walker": {
|
||||
"version": "1.0.8",
|
||||
"devOptional": true,
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@
|
||||
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
||||
"type-safety-check": "./scripts/type-safety-check.sh",
|
||||
"type-check": "tsc --noEmit",
|
||||
"type-check:vue": "vue-tsc --noEmit",
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
|
||||
"test:prerequisites": "node scripts/check-prerequisites.js",
|
||||
"check:dependencies": "./scripts/check-dependencies.sh",
|
||||
@@ -282,6 +283,7 @@
|
||||
"ts-jest": "^29.4.0",
|
||||
"tsx": "^4.20.4",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
"vite": "^5.2.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
+15
-15
@@ -492,21 +492,6 @@ log_info "Build type: $BUILD_TYPE"
|
||||
# Setup environment for Capacitor build
|
||||
setup_build_env "capacitor" "$BUILD_MODE"
|
||||
|
||||
# Override API servers for Android development
|
||||
if [ "$BUILD_MODE" = "development" ]; then
|
||||
if [ -n "$CUSTOM_API_IP" ]; then
|
||||
# Use custom IP for physical device development
|
||||
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
|
||||
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
|
||||
log_info "Android development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
|
||||
else
|
||||
# Use Android emulator IP (10.0.2.2) for Android development
|
||||
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
|
||||
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
|
||||
log_debug "Android development mode: Using 10.0.2.2 for emulator"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Setup application directories
|
||||
setup_app_directories
|
||||
|
||||
@@ -523,6 +508,21 @@ if [ -f ".env" ]; then
|
||||
load_env_file ".env"
|
||||
fi
|
||||
|
||||
# Override API servers for Android development
|
||||
if [ "$BUILD_MODE" = "development" ]; then
|
||||
if [ -n "$CUSTOM_API_IP" ]; then
|
||||
# Use custom IP for physical device development
|
||||
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
|
||||
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
|
||||
log_info "Android development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
|
||||
else
|
||||
# Use Android emulator IP (10.0.2.2) for Android development
|
||||
export VITE_DEFAULT_ENDORSER_API_SERVER="http://10.0.2.2:3000"
|
||||
export VITE_DEFAULT_PARTNER_API_SERVER="http://10.0.2.2:3000"
|
||||
log_debug "Android development mode: Using 10.0.2.2 for emulator"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Handle clean-only mode
|
||||
if [ "$CLEAN_ONLY" = true ]; then
|
||||
log_info "Clean-only mode: cleaning build artifacts"
|
||||
|
||||
@@ -320,14 +320,6 @@ log_info "Build type: $BUILD_TYPE"
|
||||
# Setup environment for Capacitor build
|
||||
setup_build_env "capacitor" "$BUILD_MODE"
|
||||
|
||||
# Override API servers for iOS development when custom IP is specified
|
||||
if [ "$BUILD_MODE" = "development" ] && [ -n "$CUSTOM_API_IP" ]; then
|
||||
# Use custom IP for physical device development
|
||||
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
|
||||
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
|
||||
log_info "iOS development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
|
||||
fi
|
||||
|
||||
# Setup application directories
|
||||
setup_app_directories
|
||||
|
||||
@@ -344,6 +336,14 @@ if [ -f ".env" ]; then
|
||||
load_env_file ".env"
|
||||
fi
|
||||
|
||||
# Override API servers for iOS development when custom IP is specified
|
||||
if [ "$BUILD_MODE" = "development" ] && [ -n "$CUSTOM_API_IP" ]; then
|
||||
# Use custom IP for physical device development
|
||||
export VITE_DEFAULT_ENDORSER_API_SERVER="http://${CUSTOM_API_IP}:3000"
|
||||
export VITE_DEFAULT_PARTNER_API_SERVER="http://${CUSTOM_API_IP}:3000"
|
||||
log_info "iOS development mode: Using custom IP ${CUSTOM_API_IP} for physical device"
|
||||
fi
|
||||
|
||||
# Validate iOS environment
|
||||
validate_ios_environment
|
||||
|
||||
|
||||
@@ -183,6 +183,16 @@ setup_build_env() {
|
||||
export VITE_GIT_HASH="$git_hash"
|
||||
log_debug "Set VITE_GIT_HASH=$git_hash"
|
||||
|
||||
# Vite derives import.meta.env.DEV/PROD from NODE_ENV, not from --mode.
|
||||
# Without this, every native build reports DEV=false and loads the
|
||||
# production .env file in vite.config.common.mts.
|
||||
case "$build_mode" in
|
||||
"production") export NODE_ENV=production ;;
|
||||
"test") export NODE_ENV=test ;;
|
||||
*) export NODE_ENV=development ;;
|
||||
esac
|
||||
log_debug "Set NODE_ENV=$NODE_ENV"
|
||||
|
||||
case $build_type in
|
||||
"capacitor")
|
||||
export VITE_PLATFORM=capacitor
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<p :class="textClasses">{{ text }}</p>
|
||||
|
||||
<button
|
||||
v-if="option1Text"
|
||||
:class="option1ButtonClasses"
|
||||
@click="handleOption1(close)"
|
||||
>
|
||||
@@ -32,6 +33,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="option2Text"
|
||||
:class="option2ButtonClasses"
|
||||
@click="handleOption2(close)"
|
||||
>
|
||||
@@ -39,6 +41,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="option3Text"
|
||||
:class="option3ButtonClasses"
|
||||
@click="handleOption3(close)"
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<!-- Step 1: enter phone number -->
|
||||
<div v-if="step === 'phone'">
|
||||
<h1 class="text-xl font-bold text-center mb-2">Verify Your Phone</h1>
|
||||
<p class="text-sm text-slate-500 mb-4">
|
||||
Enter the mobile number where you want to receive New Activity text
|
||||
messages. We'll send a one-time code to confirm it's yours. Standard
|
||||
message and data rates may apply.
|
||||
</p>
|
||||
|
||||
<label for="sms-phone" class="text-slate-500 text-sm font-bold">
|
||||
Mobile number
|
||||
</label>
|
||||
<input
|
||||
id="sms-phone"
|
||||
v-model="phoneNumber"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
autocomplete="tel"
|
||||
placeholder="+1 555 123 4567"
|
||||
class="block w-full rounded border border-slate-400 mt-1 mb-1 px-3 py-2"
|
||||
:disabled="sending"
|
||||
@keyup.enter="sendCode()"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-sm text-red-600 mb-2">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="primaryButtonClasses"
|
||||
:disabled="sending"
|
||||
@click="sendCode()"
|
||||
>
|
||||
<font-awesome v-if="sending" icon="spinner" spin class="mr-1" />
|
||||
{{ sending ? "Sending…" : "Send Code" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="cancelButtonClasses"
|
||||
:disabled="sending"
|
||||
@click="onClickCancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: enter the code -->
|
||||
<div v-else-if="step === 'code'">
|
||||
<h1 class="text-xl font-bold text-center mb-2">Enter the Code</h1>
|
||||
<p class="text-sm text-slate-500 mb-4">
|
||||
We sent a 6-digit code to
|
||||
<b>{{ sentTo }}</b
|
||||
>. Enter it below to finish verifying your number.
|
||||
</p>
|
||||
|
||||
<label for="sms-code" class="text-slate-500 text-sm font-bold">
|
||||
Verification code
|
||||
</label>
|
||||
<input
|
||||
id="sms-code"
|
||||
v-model="code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
placeholder="123456"
|
||||
class="block w-full tracking-[0.4em] text-center text-lg rounded border border-slate-400 mt-1 mb-1 px-3 py-2"
|
||||
:disabled="verifying"
|
||||
@keyup.enter="verifyCode()"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-sm text-red-600 mb-2">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="text-center mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-blue-500"
|
||||
:disabled="sending || verifying"
|
||||
@click="resendCode()"
|
||||
>
|
||||
{{ sending ? "Resending…" : "Resend code" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="primaryButtonClasses"
|
||||
:disabled="verifying"
|
||||
@click="verifyCode()"
|
||||
>
|
||||
<font-awesome v-if="verifying" icon="spinner" spin class="mr-1" />
|
||||
{{ verifying ? "Verifying…" : "Verify" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="cancelButtonClasses"
|
||||
:disabled="verifying"
|
||||
@click="onClickCancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { logger } from "@/utils/logger";
|
||||
import {
|
||||
normalizePhoneNumber,
|
||||
registerSmsPhone,
|
||||
SmsApiError,
|
||||
smsErrorMessage,
|
||||
verifySmsPhone,
|
||||
} from "@/services/notifications/smsNotificationApi";
|
||||
|
||||
/**
|
||||
* SmsVerificationDialog Component
|
||||
*
|
||||
* A two-step modal that walks the user through verifying a mobile number so
|
||||
* they can opt in to SMS "New Activity" notifications:
|
||||
* 1. Enter phone number -> `POST /notify-sms/phone` texts a one-time code
|
||||
* 2. Enter the code -> `PUT /notify-sms/phone` confirms possession
|
||||
*
|
||||
* On successful verification the callback fires with (true, phoneNumber), the
|
||||
* number in the E.164 form the service stores, so the caller can then let the
|
||||
* user turn the SMS notification on. Cancelling fires the callback with
|
||||
* (false).
|
||||
*
|
||||
* A number this identity has already verified comes back from the POST with
|
||||
* `verified: true` and no text sent, which finishes the dialog in one step.
|
||||
*/
|
||||
@Component({
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
export default class SmsVerificationDialog extends Vue {
|
||||
visible = false;
|
||||
step: "phone" | "code" = "phone";
|
||||
phoneNumber = "";
|
||||
sentTo = "";
|
||||
code = "";
|
||||
sending = false;
|
||||
verifying = false;
|
||||
errorMessage = "";
|
||||
|
||||
/** Identity the registration is scoped to; every route is per-DID. */
|
||||
activeDid = "";
|
||||
|
||||
callback: (success: boolean, phoneNumber?: string) => void = () => {};
|
||||
|
||||
/**
|
||||
* Opens the dialog at the phone-entry step.
|
||||
* @param activeDid - identity the number is registered under
|
||||
* @param aCallback - fired with (true, phoneNumber) on success, (false) on cancel
|
||||
* @param prefillPhone - optional number to pre-populate (e.g. re-verifying)
|
||||
*/
|
||||
open(
|
||||
activeDid: string,
|
||||
aCallback?: (success: boolean, phoneNumber?: string) => void,
|
||||
prefillPhone = "",
|
||||
) {
|
||||
this.activeDid = activeDid;
|
||||
this.callback = aCallback || this.callback;
|
||||
this.step = "phone";
|
||||
this.phoneNumber = prefillPhone;
|
||||
this.sentTo = "";
|
||||
this.code = "";
|
||||
this.sending = false;
|
||||
this.verifying = false;
|
||||
this.errorMessage = "";
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the phone number and ask the service to text a code.
|
||||
*
|
||||
* The masked number the service echoes back is what the user sees; the
|
||||
* normalized one is what later calls send, since the mask is not a number.
|
||||
*/
|
||||
async sendCode(): Promise<void> {
|
||||
this.errorMessage = "";
|
||||
const normalized = normalizePhoneNumber(this.phoneNumber);
|
||||
if (!normalized || normalized.replace(/\D/g, "").length < 7) {
|
||||
this.errorMessage = "Please enter a valid mobile number.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.sending = true;
|
||||
try {
|
||||
const result = await registerSmsPhone(this.activeDid, normalized);
|
||||
this.phoneNumber = normalized;
|
||||
|
||||
if (result.verified) {
|
||||
// Already verified under this identity: nothing was texted and there
|
||||
// is no code to enter.
|
||||
this.visible = false;
|
||||
this.callback(true, normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sentTo = result.phoneNumber || normalized;
|
||||
// Only a dev server with SMS_DEV_ECHO_CODE returns this; it saves a
|
||||
// developer with no carrier coverage from a dead-end flow.
|
||||
this.code = result.devCode || "";
|
||||
this.step = "code";
|
||||
} catch (error) {
|
||||
logger.error("[SmsVerificationDialog] sendCode failed:", error);
|
||||
this.errorMessage = smsErrorMessage(
|
||||
error,
|
||||
"Could not send the code. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the service to send a fresh code to the same number. Sends are
|
||||
* throttled to three per number per hour, so this can be refused.
|
||||
*/
|
||||
async resendCode(): Promise<void> {
|
||||
this.errorMessage = "";
|
||||
this.sending = true;
|
||||
try {
|
||||
const result = await registerSmsPhone(this.activeDid, this.phoneNumber);
|
||||
this.sentTo = result.phoneNumber || this.sentTo;
|
||||
// A number already verified comes back with no code, dev or otherwise.
|
||||
this.code = result.verified ? "" : result.devCode || "";
|
||||
} catch (error) {
|
||||
logger.error("[SmsVerificationDialog] resendCode failed:", error);
|
||||
this.errorMessage = smsErrorMessage(
|
||||
error,
|
||||
"Could not resend the code. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the entered code with the service.
|
||||
*
|
||||
* A wrong code says how many tries are left; running out clears the code
|
||||
* server-side, so the only way forward is a fresh send from step one.
|
||||
*/
|
||||
async verifyCode(): Promise<void> {
|
||||
this.errorMessage = "";
|
||||
if (!/^\d{6}$/.test(this.code.trim())) {
|
||||
this.errorMessage = "Enter the 6-digit code we texted you.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.verifying = true;
|
||||
try {
|
||||
await verifySmsPhone(this.activeDid, this.phoneNumber, this.code);
|
||||
this.visible = false;
|
||||
this.callback(true, this.phoneNumber);
|
||||
} catch (error) {
|
||||
logger.error("[SmsVerificationDialog] verifyCode failed:", error);
|
||||
this.errorMessage = smsErrorMessage(
|
||||
error,
|
||||
"Could not verify the code. Please try again.",
|
||||
);
|
||||
if (
|
||||
error instanceof SmsApiError &&
|
||||
error.code === "SMS_CODE_ATTEMPTS_EXHAUSTED"
|
||||
) {
|
||||
// The stored code is gone; recovery is another send, not another guess.
|
||||
this.step = "phone";
|
||||
this.code = "";
|
||||
}
|
||||
} finally {
|
||||
this.verifying = false;
|
||||
}
|
||||
}
|
||||
|
||||
onClickCancel(): void {
|
||||
this.visible = false;
|
||||
this.callback(false);
|
||||
}
|
||||
|
||||
get primaryButtonClasses(): string {
|
||||
return "block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md disabled:opacity-50";
|
||||
}
|
||||
|
||||
get cancelButtonClasses(): string {
|
||||
return "block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md disabled:opacity-50";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -64,6 +64,30 @@
|
||||
>
|
||||
Register Token Now
|
||||
</button>
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-cyan-500 to-cyan-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
:disabled="busy"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': busy }"
|
||||
@click="onUploadAlertAuthorization"
|
||||
>
|
||||
Upload AlertSearch Authorization
|
||||
</button>
|
||||
<p
|
||||
v-if="alertAuthorizationStatus"
|
||||
class="text-xs rounded px-3 py-2 border"
|
||||
:class="
|
||||
alertAuthorizationStatus.ok
|
||||
? 'text-emerald-900 bg-emerald-50 border-emerald-200'
|
||||
: 'text-rose-900 bg-rose-50 border-rose-200'
|
||||
"
|
||||
role="status"
|
||||
>
|
||||
{{ alertAuthorizationStatus.message }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-slate-500">
|
||||
Manually mints and uploads 100 delegated day JWTs. Requires an active
|
||||
did:ethr identity and JWT authentication; Test Mode is not used.
|
||||
</p>
|
||||
<button
|
||||
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
:disabled="busy"
|
||||
@@ -352,6 +376,10 @@ const fcmToken = ref<string | null>(NotificationDebugService.getFcmToken());
|
||||
|
||||
const activeBackendUrl = ref(NotificationDebugService.getActiveBackendUrl());
|
||||
const realWakeupStatus = ref<{ ok: boolean; message: string } | null>(null);
|
||||
const alertAuthorizationStatus = ref<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
const truncatedFcmToken = computed(() => {
|
||||
const t = fcmToken.value?.trim() ?? "";
|
||||
@@ -457,6 +485,35 @@ async function onRegisterToken(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function onUploadAlertAuthorization(): Promise<void> {
|
||||
alertAuthorizationStatus.value = null;
|
||||
await withBusy(async () => {
|
||||
const result =
|
||||
await NotificationDebugService.uploadAlertSearchAuthorization();
|
||||
alertAuthorizationStatus.value = result.ok
|
||||
? {
|
||||
ok: true,
|
||||
message: [
|
||||
`Uploaded ${result.jwtCount} JWTs (HTTP ${result.status}).`,
|
||||
`Batch ${result.batchId}.`,
|
||||
`${result.timezone}: ${result.firstDay} through ${result.lastDay}.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
message: [
|
||||
`AlertSearch authorization upload failed: ${result.errorMessage}`,
|
||||
result.status != null ? `(HTTP ${result.status})` : undefined,
|
||||
result.errorCode ? `Code: ${result.errorCode}.` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function onBackendRefresh(): Promise<void> {
|
||||
await withBusy(async () => {
|
||||
await NotificationDebugService.triggerBackendRefresh();
|
||||
@@ -477,16 +534,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
|
||||
@@ -57,11 +59,20 @@ export const ACCOUNT_VIEW_CONSTANTS = {
|
||||
// Notification messages
|
||||
NOTIFICATIONS: {
|
||||
NEW_ACTIVITY_INFO: `
|
||||
This will only notify you when there is new relevant activity for you personally.
|
||||
Note that it runs on your device and many factors may affect delivery,
|
||||
so if you want a reliable but simple daily notification then choose a 'Reminder'.
|
||||
This will notify you with an app message when there is new relevant activity for you personally.
|
||||
Do you want more details?
|
||||
`,
|
||||
NEW_ACTIVITY_SMS_INFO: `
|
||||
This is opt-in: by turning it on you agree to receive text messages when there is new relevant activity for you personally.
|
||||
Standard message and data rates may apply, and you can turn it off at any time.
|
||||
Do you want more details?
|
||||
`,
|
||||
SMS_OPT_IN_CHOICE: `
|
||||
This is opt-in: by entering your phone number you agree to receive text messages when there is new relevant activity for you personally.
|
||||
Standard message and data rates may apply, and you can turn it off at any time.
|
||||
`,
|
||||
SMS_FORGET_PHONE_CONFIRM:
|
||||
"This will remove your phone number and turn off all text messages. You will have to verify a number again to re-enable them. Are you sure?",
|
||||
REMINDER_INFO: `
|
||||
This will notify you at a specific time each day.
|
||||
Note that it does not give you personalized notifications,
|
||||
|
||||
@@ -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,15 +1,27 @@
|
||||
/**
|
||||
* JWT lifetime for native New Activity background prefetch (`configureNativeFetcher`).
|
||||
* See doc/plan-background-jwt-pool-and-expiry.md. Confirm max `exp` with Endorser before raising.
|
||||
* JWT lifetime for the single-token native background prefetch path
|
||||
* (`accessTokenForBackgroundNotifications`). Confirm the maximum `exp` Endorser
|
||||
* accepts before raising this.
|
||||
*/
|
||||
export const BACKGROUND_JWT_EXPIRY_DAYS = 90;
|
||||
|
||||
export const BACKGROUND_JWT_EXPIRY_SECONDS =
|
||||
BACKGROUND_JWT_EXPIRY_DAYS * 24 * 60 * 60;
|
||||
|
||||
/** Headroom for retries / tests; pool size should be ≥ expiryDays + buffer. */
|
||||
export const BACKGROUND_JWT_POOL_BUFFER = 10;
|
||||
/** Seconds in a UTC day; the frame every pool slot is cut from. */
|
||||
export const BACKGROUND_JWT_SECONDS_PER_DAY = 24 * 60 * 60;
|
||||
|
||||
/** Distinct JWT strings minted per configure (duplicate-JWT / daily slot). */
|
||||
export const BACKGROUND_JWT_POOL_SIZE =
|
||||
BACKGROUND_JWT_EXPIRY_DAYS + BACKGROUND_JWT_POOL_BUFFER;
|
||||
/**
|
||||
* Consecutive UTC days the native background prefetch pool covers, one JWT per
|
||||
* day. This is the whole forward grant the user authorizes in a single mint:
|
||||
* past the last day the pool carries no credential and prefetch stops until the
|
||||
* app opens again. See `doc/background-jwt-pool.md`.
|
||||
*/
|
||||
export const BACKGROUND_JWT_POOL_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Padding on each end of a day's validity window, for clock skew between the
|
||||
* device and Endorser. Widening a window is safe; narrowing it can leave a
|
||||
* prefetch inside the day with no usable token.
|
||||
*/
|
||||
export const BACKGROUND_JWT_WINDOW_SLACK_SECONDS = 5 * 60;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* alertSearch response item and envelope types.
|
||||
*
|
||||
* These buckets are new to this app. Item shapes are taken from the endorser-ch
|
||||
* SELECT lists for alertSearch, not from similarly named existing report types.
|
||||
*
|
||||
* Existing types that were considered and not reused:
|
||||
* - GenericCredWrapper — claims lack a `claim` body; extra jwt columns differ.
|
||||
* - GiveSummaryRecord / OfferSummaryRecord — those use `jwtId` and give/offer
|
||||
* summary fields; alertSearch jwt rows use `id` and jwt table columns.
|
||||
* - PlanSummaryAndPreviousClaim — `/plansLastUpdatedBetween` wraps `{ plan,
|
||||
* wrappedClaimBefore }`; alertSearch `trackedPlanUpdates` are plan_claim rows.
|
||||
* - PlanSummaryRecord — overlapping plan fields, but the app type is a subset
|
||||
* (missing fulfillsLinkConfirmed, result*, etc.) and required fields differ.
|
||||
* - UserProfile — partner nearby rows include `updatedAt` / `rowId` and omit
|
||||
* embedding flags that UserProfile models.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Server-issued ULID on a stored JWT/plan record, used as alertSearch afterId /
|
||||
* beforeId. Not an authentication JWT and not a delegated notification JWT.
|
||||
*/
|
||||
export type AlertSearchCursorUlid = string;
|
||||
|
||||
/**
|
||||
* JWT row from endorser `jwtsWithDidAfterId` (no claim body).
|
||||
* Cursor field: `id`.
|
||||
*/
|
||||
export interface AlertSearchClaimRecord {
|
||||
id: AlertSearchCursorUlid;
|
||||
issuedAt: string;
|
||||
issuer: string;
|
||||
subject?: string;
|
||||
claimType?: string;
|
||||
handleId?: string;
|
||||
fromEntity?: string;
|
||||
toEntity?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT row from `jwtsForUserPlanContributions` and
|
||||
* `jwtsGiveActionOfferForPlanHandleIds`. `claim` is the jwt table TEXT
|
||||
* (canonical JSON string); alertSearch does not JSON.parse it.
|
||||
* Cursor field: `id`.
|
||||
*/
|
||||
export interface AlertSearchJwtWithClaimRecord extends AlertSearchClaimRecord {
|
||||
claim?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* plan_claim row from `plansLastUpdatedBetween` and `plansByLocationAfterId`.
|
||||
* Cursor field: `jwtId` (not `id`).
|
||||
*/
|
||||
export interface AlertSearchPlanRecord {
|
||||
handleId: string;
|
||||
jwtId: AlertSearchCursorUlid;
|
||||
issuerDid?: string;
|
||||
agentDid?: string;
|
||||
fulfillsLinkConfirmed?: boolean | number;
|
||||
fulfillsPlanClaimId?: string;
|
||||
fulfillsPlanHandleId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
endTime?: string;
|
||||
startTime?: string;
|
||||
locLat?: number;
|
||||
locLon?: number;
|
||||
resultDescription?: string;
|
||||
resultIdentifier?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* user_profile row from partner `profilesByLocationAfterDate`.
|
||||
* Profiles have no JWT `id`; partner paging uses dates decoded from cursor ULIDs.
|
||||
*/
|
||||
export interface AlertSearchProfileRecord {
|
||||
rowId?: number;
|
||||
issuerDid: string;
|
||||
updatedAt?: string;
|
||||
description: string;
|
||||
locLat?: number;
|
||||
locLon?: number;
|
||||
locLat2?: number;
|
||||
locLon2?: number;
|
||||
}
|
||||
|
||||
export interface EndorserAlertSearchData {
|
||||
claims: AlertSearchClaimRecord[];
|
||||
personalPlanContributions: AlertSearchJwtWithClaimRecord[];
|
||||
trackedPlanUpdates: AlertSearchPlanRecord[];
|
||||
trackedPlanClaims: AlertSearchJwtWithClaimRecord[];
|
||||
plansNearby: AlertSearchPlanRecord[];
|
||||
}
|
||||
|
||||
export interface PartnerAlertSearchData {
|
||||
profilesNearby: AlertSearchProfileRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Endorser GET/POST /api/v2/report/alertSearch body.
|
||||
* Per-bucket SQL hitLimit is not currently copied onto this envelope.
|
||||
* Timeouts may set `userMessage` instead.
|
||||
*/
|
||||
export interface EndorserAlertSearchResponse {
|
||||
data: EndorserAlertSearchData;
|
||||
userMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partner GET/POST /api/partner/alertSearch body.
|
||||
*/
|
||||
export interface PartnerAlertSearchResponse {
|
||||
data: PartnerAlertSearchData;
|
||||
userMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of the six alertSearch buckets for a future combined daily run.
|
||||
* Not returned by a single server endpoint today.
|
||||
*/
|
||||
export interface CombinedAlertSearchData
|
||||
extends EndorserAlertSearchData,
|
||||
PartnerAlertSearchData {}
|
||||
@@ -1,7 +1,9 @@
|
||||
export * from "./alertSearch";
|
||||
export * from "./claims";
|
||||
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;
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Typed alertSearch API contract only. No HTTP client yet.
|
||||
*
|
||||
* Hosts: use DEFAULT_ENDORSER_API_SERVER and DEFAULT_PARTNER_API_SERVER from
|
||||
* `@/constants/app`. Do not duplicate those constants here.
|
||||
*
|
||||
* JWT kinds (do not mix these):
|
||||
* - 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: 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
|
||||
* ULID). `afterId` means ids strictly greater than that ULID; `beforeId`
|
||||
* means strictly less. First daily run omits afterId. `beforeId` is for
|
||||
* pagination within a run. These are not auth JWTs and not signed tokens.
|
||||
*
|
||||
* Truncation: each endorser bucket query uses a server hit-limit (typically
|
||||
* 50). That flag is not currently returned on the alertSearch JSON envelope.
|
||||
* Timeouts may add `userMessage`. A later caller must still paginate with
|
||||
* beforeId when a bucket may be incomplete.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AlertSearchCursorUlid,
|
||||
CombinedAlertSearchData,
|
||||
EndorserAlertSearchResponse,
|
||||
PartnerAlertSearchResponse,
|
||||
} from "@/interfaces/alertSearch";
|
||||
|
||||
export const ENDORSER_ALERT_SEARCH_PATH = "/api/v2/report/alertSearch";
|
||||
export const PARTNER_ALERT_SEARCH_PATH = "/api/partner/alertSearch";
|
||||
|
||||
export interface AlertSearchLocationBBox {
|
||||
minLocLat: number;
|
||||
maxLocLat: number;
|
||||
minLocLon: number;
|
||||
maxLocLon: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query/body params accepted by endorser and partner alertSearch (GET or POST).
|
||||
* GET is the planned daily method; the server also accepts POST.
|
||||
*/
|
||||
export interface AlertSearchRequestParams {
|
||||
afterId?: AlertSearchCursorUlid;
|
||||
beforeId?: AlertSearchCursorUlid;
|
||||
afterDate?: string;
|
||||
beforeDate?: string;
|
||||
location?: AlertSearchLocationBBox;
|
||||
minLocLat?: number;
|
||||
maxLocLat?: number;
|
||||
minLocLon?: number;
|
||||
maxLocLon?: number;
|
||||
planHandleIds?: string[];
|
||||
planIds?: string[];
|
||||
handleIds?: string[];
|
||||
}
|
||||
|
||||
export type {
|
||||
AlertSearchCursorUlid,
|
||||
CombinedAlertSearchData,
|
||||
EndorserAlertSearchResponse,
|
||||
PartnerAlertSearchResponse,
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Day-scoped JWT pool for native New Activity background prefetch.
|
||||
*
|
||||
* Kept apart from `@/libs/crypto` so minting depends only on the signer and the
|
||||
* pool constants: the prefetch path is the one place in the app where a
|
||||
* credential is handed to native code and used without any JavaScript running,
|
||||
* and its dependencies should stay small enough to read in one sitting.
|
||||
*/
|
||||
|
||||
import {
|
||||
BACKGROUND_JWT_POOL_SIZE,
|
||||
BACKGROUND_JWT_SECONDS_PER_DAY,
|
||||
BACKGROUND_JWT_WINDOW_SLACK_SECONDS,
|
||||
} from "@/constants/backgroundJwt";
|
||||
import type { KeyMetaWithPrivate } from "@/interfaces/common";
|
||||
import { createEndorserJwtForKey } from "./vc";
|
||||
|
||||
/** Thrown for identities whose keys cannot sign a day-scoped background pool. */
|
||||
export class BackgroundJwtUnsupportedIdentityError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BackgroundJwtUnsupportedIdentityError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint one JWT per UTC day for native background prefetch
|
||||
* (`configureNativeFetcher` `jwtTokens`), covering the
|
||||
* {@link BACKGROUND_JWT_POOL_SIZE} consecutive days starting with the day of
|
||||
* the call.
|
||||
*
|
||||
* Each token is valid only for the single day it covers: `nbf` at that day's
|
||||
* opening midnight and `exp` at its closing one, each widened by
|
||||
* {@link BACKGROUND_JWT_WINDOW_SLACK_SECONDS} for clock skew. A token that
|
||||
* escapes through a log line or a captured header therefore buys one day of
|
||||
* Endorser access rather than the whole grant. The day windows are also what
|
||||
* make the tokens distinct: the ES256K signer is deterministic, so JWTs built
|
||||
* from identical payloads are byte-identical, and a pool of identical strings
|
||||
* defeats any duplicate-token rule the server may apply.
|
||||
*
|
||||
* Slot order is a contract with the native selector, which reads
|
||||
* `pool[epochDay % pool.size()]` and has no record of when the pool was minted:
|
||||
* the token covering a UTC day sits at index `epochDay % POOL_SIZE`. Any
|
||||
* POOL_SIZE consecutive days hit every index exactly once, so the array is
|
||||
* dense whatever day minting starts on.
|
||||
*
|
||||
* The caller supplies the decrypted account rather than a DID: signing
|
||||
* {@link BACKGROUND_JWT_POOL_SIZE} tokens from a DID would decrypt the identity
|
||||
* once per token, which costs seconds on a phone and runs on every app
|
||||
* foreground.
|
||||
*
|
||||
* Passkey (`did:peer`) identities cannot mint this pool -- each signature is a
|
||||
* WebAuthn assertion, and `createJwtNavigator` overrides the day window with its
|
||||
* own one-minute `exp` -- so they raise
|
||||
* {@link BackgroundJwtUnsupportedIdentityError} instead of prompting
|
||||
* {@link BACKGROUND_JWT_POOL_SIZE} times for tokens that expire before the
|
||||
* prefetch they were minted for.
|
||||
*
|
||||
* @param account decrypted identity the pool is issued by and for
|
||||
* @throws BackgroundJwtUnsupportedIdentityError for passkey identities
|
||||
*/
|
||||
export async function mintBackgroundJwtTokenPool(
|
||||
account: KeyMetaWithPrivate,
|
||||
): Promise<string[]> {
|
||||
if (!account?.identity) {
|
||||
throw new BackgroundJwtUnsupportedIdentityError(
|
||||
`No day-scoped signing key for ${account?.did}; background prefetch ` +
|
||||
"requires a seed-phrase identity.",
|
||||
);
|
||||
}
|
||||
const did = account.did;
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
const firstEpochDay = Math.floor(nowEpoch / BACKGROUND_JWT_SECONDS_PER_DAY);
|
||||
const tokens: string[] = new Array(BACKGROUND_JWT_POOL_SIZE);
|
||||
for (let offset = 0; offset < BACKGROUND_JWT_POOL_SIZE; offset++) {
|
||||
const epochDay = firstEpochDay + offset;
|
||||
const dayStartEpoch = epochDay * BACKGROUND_JWT_SECONDS_PER_DAY;
|
||||
const tokenPayload = {
|
||||
nbf: dayStartEpoch - BACKGROUND_JWT_WINDOW_SLACK_SECONDS,
|
||||
exp:
|
||||
dayStartEpoch +
|
||||
BACKGROUND_JWT_SECONDS_PER_DAY +
|
||||
BACKGROUND_JWT_WINDOW_SLACK_SECONDS,
|
||||
iat: nowEpoch,
|
||||
iss: did,
|
||||
};
|
||||
tokens[epochDay % BACKGROUND_JWT_POOL_SIZE] = await createEndorserJwtForKey(
|
||||
account,
|
||||
tokenPayload,
|
||||
);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
@@ -4,10 +4,6 @@ import { entropyToMnemonic } from "ethereum-cryptography/bip39";
|
||||
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
|
||||
import { HDNode } from "@ethersproject/hdnode";
|
||||
|
||||
import {
|
||||
BACKGROUND_JWT_EXPIRY_SECONDS,
|
||||
BACKGROUND_JWT_POOL_SIZE,
|
||||
} from "@/constants/backgroundJwt";
|
||||
import {
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
createEndorserJwtForDid,
|
||||
@@ -108,45 +104,6 @@ export const accessToken = async (did?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* JWT for native New Activity prefetch (`configureNativeFetcher` / WorkManager).
|
||||
* Uses a long `exp` (`BACKGROUND_JWT_EXPIRY_SECONDS`); do not use for ordinary
|
||||
* in-app API calls — use `getHeaders` / `accessToken` instead.
|
||||
*/
|
||||
export const accessTokenForBackgroundNotifications = async (
|
||||
did?: string,
|
||||
): Promise<string> => {
|
||||
if (!did) {
|
||||
return "";
|
||||
}
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
const endEpoch = nowEpoch + BACKGROUND_JWT_EXPIRY_SECONDS;
|
||||
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
|
||||
return createEndorserJwtForDid(did, tokenPayload);
|
||||
};
|
||||
|
||||
/**
|
||||
* Mint {@link BACKGROUND_JWT_POOL_SIZE} distinct JWTs for native background prefetch
|
||||
* (`configureNativeFetcher` `jwtTokens`). Unique `jti` per slot; same `exp` for all.
|
||||
*/
|
||||
export async function mintBackgroundJwtTokenPool(
|
||||
did: string,
|
||||
): Promise<string[]> {
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
const endEpoch = nowEpoch + BACKGROUND_JWT_EXPIRY_SECONDS;
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < BACKGROUND_JWT_POOL_SIZE; i++) {
|
||||
const tokenPayload = {
|
||||
exp: endEpoch,
|
||||
iat: nowEpoch,
|
||||
iss: did,
|
||||
jti: `${did}#bg#${i}`,
|
||||
};
|
||||
tokens.push(await createEndorserJwtForDid(did, tokenPayload));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JWT from various URL formats
|
||||
* @param jwtUrlText The URL containing the JWT
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
jest.mock("@/constants/app", () => ({
|
||||
DEFAULT_NOTIFY_API_SERVER: "https://notify-api.timesafari.app",
|
||||
}));
|
||||
|
||||
import {
|
||||
getNotificationDebugOverrideHeaders,
|
||||
NGROK_SKIP_BROWSER_WARNING_HEADER,
|
||||
NGROK_SKIP_BROWSER_WARNING_VALUE,
|
||||
setBackendBaseUrl,
|
||||
} from "./NotificationDebugConfig";
|
||||
|
||||
const STORAGE_KEY_BACKEND_URL = "notificationDebug.backendBaseUrl";
|
||||
|
||||
describe("getNotificationDebugOverrideHeaders", () => {
|
||||
const memory = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
memory.clear();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => memory.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
memory.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
memory.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the ngrok skip header when the debug backend override is set", () => {
|
||||
setBackendBaseUrl("https://detail-frown-machine.ngrok-free.dev");
|
||||
|
||||
expect(getNotificationDebugOverrideHeaders()).toEqual({
|
||||
[NGROK_SKIP_BROWSER_WARNING_HEADER]: NGROK_SKIP_BROWSER_WARNING_VALUE,
|
||||
});
|
||||
expect(memory.get(STORAGE_KEY_BACKEND_URL)).toBe(
|
||||
"https://detail-frown-machine.ngrok-free.dev",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the ngrok skip header for the default/production notification API", () => {
|
||||
setBackendBaseUrl("");
|
||||
|
||||
expect(getNotificationDebugOverrideHeaders()).toEqual({});
|
||||
expect(memory.get(STORAGE_KEY_BACKEND_URL)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,10 @@ const STORAGE_KEY_BACKEND_URL = "notificationDebug.backendBaseUrl";
|
||||
const STORAGE_KEY_TEST_MODE = "notificationDebug.testMode";
|
||||
const STORAGE_KEY_BYPASS_AUTH = "notificationDebug.bypassAuth";
|
||||
|
||||
/** Free-ngrok interstitial bypass; only sent when the debug backend override is set. */
|
||||
export const NGROK_SKIP_BROWSER_WARNING_HEADER = "ngrok-skip-browser-warning";
|
||||
export const NGROK_SKIP_BROWSER_WARNING_VALUE = "true";
|
||||
|
||||
/** Trim whitespace, drop trailing slash; empty input becomes null. */
|
||||
export function normalizeNotificationBackendUrl(url: string): string | null {
|
||||
const trimmed = url.trim();
|
||||
@@ -45,7 +49,7 @@ function writeStorage(key: string, value: string | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Backend URL override, or null when using the default Notification API. */
|
||||
/** Backend URL override, or null when using the default notify-api server. */
|
||||
export function getBackendBaseUrl(): string | null {
|
||||
const raw = readStorage(STORAGE_KEY_BACKEND_URL);
|
||||
if (raw === null) {
|
||||
@@ -100,8 +104,8 @@ export function setBypassAuth(enabled: boolean): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL for `/notifications/*` API calls.
|
||||
* Uses debug override when set; otherwise DEFAULT_NOTIFY_API_SERVER.
|
||||
* Base URL for notify-api calls (`/notifications/*`, `/notify-sms/*`).
|
||||
* Uses debug override when set; otherwise `DEFAULT_NOTIFY_API_SERVER`.
|
||||
*/
|
||||
export function getNotificationApiBaseUrl(): string {
|
||||
const override = getBackendBaseUrl();
|
||||
@@ -113,3 +117,16 @@ export function getNotificationApiBaseUrl(): string {
|
||||
DEFAULT_NOTIFY_API_SERVER
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra headers for notification API calls when the Debug Panel backend URL
|
||||
* override is set. Production/default hosts do not get this header.
|
||||
*/
|
||||
export function getNotificationDebugOverrideHeaders(): Record<string, string> {
|
||||
if (!getBackendBaseUrl()) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
[NGROK_SKIP_BROWSER_WARNING_HEADER]: NGROK_SKIP_BROWSER_WARNING_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -42,6 +47,10 @@ import {
|
||||
import { truncateFcmTokenForLog } from "./notificationLog";
|
||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||
import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin";
|
||||
import {
|
||||
uploadAlertSearchAuthorization as uploadAlertSearchAuthorizationBatch,
|
||||
type AlertAuthorizationUploadResult,
|
||||
} from "./alertAuthorization";
|
||||
|
||||
type PendingNotificationInfo = {
|
||||
identifier: string;
|
||||
@@ -58,49 +67,30 @@ export type PendingNotificationsResult = {
|
||||
};
|
||||
|
||||
export type SendRealWakeupPingResult =
|
||||
| { ok: true; responseBody?: unknown }
|
||||
| { ok: true; responseBody?: DebugSendWakeupResponse }
|
||||
| {
|
||||
ok: false;
|
||||
errorMessage: string;
|
||||
status?: number;
|
||||
responseBody?: unknown;
|
||||
responseBody?: DebugSendWakeupResponse;
|
||||
};
|
||||
|
||||
function wakeupPingResponseDetail(body: unknown): Record<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 {
|
||||
@@ -165,6 +155,30 @@ export const NotificationDebugService = {
|
||||
await reregisterFcmTokenNow();
|
||||
},
|
||||
|
||||
async uploadAlertSearchAuthorization(): Promise<AlertAuthorizationUploadResult> {
|
||||
logNotification("AlertSearch authorization upload requested");
|
||||
const result = await uploadAlertSearchAuthorizationBatch();
|
||||
if (result.ok) {
|
||||
logNotification("AlertSearch authorization upload succeeded", {
|
||||
batchId: result.batchId,
|
||||
timezone: result.timezone,
|
||||
jwtCount: result.jwtCount,
|
||||
firstDay: result.firstDay,
|
||||
lastDay: result.lastDay,
|
||||
status: result.status,
|
||||
});
|
||||
} else {
|
||||
logNotification(
|
||||
`AlertSearch authorization upload failed: ${result.errorMessage}`,
|
||||
{
|
||||
...(result.status != null ? { status: result.status } : {}),
|
||||
...(result.errorCode ? { errorCode: result.errorCode } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async triggerBackendRefresh(): Promise<void> {
|
||||
await refreshNotificationsWithDiagnostics({ source: "debug panel" });
|
||||
},
|
||||
@@ -197,26 +211,30 @@ export const NotificationDebugService = {
|
||||
|
||||
const deviceId = await getOrCreateDeviceId();
|
||||
const baseUrl = getNotificationApiBaseUrl();
|
||||
const body: DebugSendWakeupRequest = {
|
||||
deviceId,
|
||||
fcmToken,
|
||||
platform: Capacitor.getPlatform(),
|
||||
testMode: getTestMode(),
|
||||
};
|
||||
const res = await fetch(`${baseUrl}/debug/send-wakeup`, {
|
||||
method: "POST",
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify({
|
||||
deviceId,
|
||||
fcmToken,
|
||||
platform: Capacitor.getPlatform(),
|
||||
testMode: getTestMode(),
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
let responseBody: unknown;
|
||||
try {
|
||||
responseBody = await res.json();
|
||||
} catch {
|
||||
responseBody = undefined;
|
||||
}
|
||||
const parsed = await readNotificationApiBody(res);
|
||||
const responseBody =
|
||||
typeof parsed === "object" && parsed !== null
|
||||
? (parsed as DebugSendWakeupResponse)
|
||||
: undefined;
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = wakeupPingFailureMessage(res.status, responseBody);
|
||||
// A 200 still reports a skipped or failed push as `success: false`.
|
||||
if (!res.ok || responseBody?.success === false) {
|
||||
const errorMessage = notificationApiFailureMessage(
|
||||
res.status,
|
||||
responseBody,
|
||||
);
|
||||
logNotification(`Real WAKEUP_PING failed: ${errorMessage}`, {
|
||||
status: res.status,
|
||||
token: truncateFcmTokenForLog(fcmToken),
|
||||
|
||||
@@ -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,240 @@
|
||||
import type { AlertAuthorizationBatch } from "./alertAuthorizationBatch";
|
||||
import {
|
||||
type AlertAuthorizationDependencies,
|
||||
uploadAlertSearchAuthorization,
|
||||
} from "./alertAuthorization";
|
||||
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
|
||||
|
||||
jest.mock("@/libs/crypto/vc", () => ({
|
||||
ETHR_DID_PREFIX: "did:ethr:",
|
||||
}));
|
||||
jest.mock("./alertAuthorizationBatch", () => ({
|
||||
ALERT_AUTHORIZATION_BATCH_DAYS: 100,
|
||||
mintAlertAuthorizationBatch: jest.fn(),
|
||||
}));
|
||||
jest.mock("./NotificationDebugConfig", () => ({
|
||||
getNotificationApiBaseUrl: jest.fn(),
|
||||
}));
|
||||
jest.mock("./notificationApiAuth", () => ({
|
||||
getActiveNotificationDid: jest.fn(),
|
||||
getNotificationApiHeaders: jest.fn(),
|
||||
httpAuthErrorMessage: (status: number) => `HTTP ${status}`,
|
||||
}));
|
||||
jest.mock("./notificationApiDebugMode", () => ({
|
||||
shouldBypassNotificationAuth: jest.fn(),
|
||||
}));
|
||||
|
||||
const BATCH_DAYS = 100;
|
||||
const ACTIVE_DID = `did:ethr:0x${"0".repeat(40)}`;
|
||||
const TIME_ZONE = "America/Denver";
|
||||
const BATCH_ID = "batch-fixture";
|
||||
|
||||
function createMintedBatch(): AlertAuthorizationBatch {
|
||||
return {
|
||||
batchId: BATCH_ID,
|
||||
jwts: Array.from({ length: BATCH_DAYS }, (_, index) => ({
|
||||
sequence: index,
|
||||
day: new Date(Date.UTC(2026, 8, index + 1)).toISOString().slice(0, 10),
|
||||
nbf: 1_788_220_800 + index * 86_400,
|
||||
exp: 1_788_307_200 + index * 86_400,
|
||||
jwt: `delegated-token-${index}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createResponse(
|
||||
status: number,
|
||||
body: Record<string, unknown>,
|
||||
): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: jest.fn().mockResolvedValue(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function createDependencies(overrides?: {
|
||||
did?: string | null;
|
||||
bypassAuth?: boolean;
|
||||
timeZone?: string;
|
||||
response?: Response;
|
||||
}): {
|
||||
dependencies: AlertAuthorizationDependencies;
|
||||
fetchMock: jest.Mock;
|
||||
mintMock: jest.Mock;
|
||||
authMock: jest.Mock;
|
||||
} {
|
||||
const fetchMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue(overrides?.response ?? createResponse(200, {}));
|
||||
const mintMock = jest.fn().mockResolvedValue(createMintedBatch());
|
||||
const authMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
authenticated: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer access-token-fixture",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
dependencies: {
|
||||
getActiveDid: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
overrides && "did" in overrides ? overrides.did : ACTIVE_DID,
|
||||
),
|
||||
isAuthBypassEnabled: jest
|
||||
.fn()
|
||||
.mockReturnValue(overrides?.bypassAuth ?? false),
|
||||
mintBatch: mintMock,
|
||||
getTimeZone: () => overrides?.timeZone ?? TIME_ZONE,
|
||||
getAuthHeaders: authMock,
|
||||
getBaseUrl: () => "https://notification-backend.invalid",
|
||||
fetch: fetchMock,
|
||||
},
|
||||
fetchMock,
|
||||
mintMock,
|
||||
authMock,
|
||||
};
|
||||
}
|
||||
|
||||
describe("uploadAlertSearchAuthorization", () => {
|
||||
it("uploads the minted batch with authenticated PUT semantics", async () => {
|
||||
const { dependencies, fetchMock, mintMock, authMock } =
|
||||
createDependencies();
|
||||
const minted = createMintedBatch();
|
||||
|
||||
const result = await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
batchId: BATCH_ID,
|
||||
timezone: TIME_ZONE,
|
||||
jwtCount: BATCH_DAYS,
|
||||
firstDay: minted.jwts[0]!.day,
|
||||
lastDay: minted.jwts[BATCH_DAYS - 1]!.day,
|
||||
});
|
||||
expect(mintMock).toHaveBeenCalledWith(ACTIVE_DID);
|
||||
expect(authMock).toHaveBeenCalledWith(undefined, ACTIVE_DID);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://notification-backend.invalid/notifications/alert-authorization",
|
||||
);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(init.headers).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer access-token-fixture",
|
||||
});
|
||||
|
||||
const expectedNotify = alertSearchNotifyTimeUtcFromLocalNineAm(TIME_ZONE);
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
batchId: BATCH_ID,
|
||||
notifyHourUtc: expectedNotify.notifyHourUtc,
|
||||
notifyMinuteUtc: expectedNotify.notifyMinuteUtc,
|
||||
timezone: TIME_ZONE,
|
||||
jwts: minted.jwts,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not log delegated JWT contents", async () => {
|
||||
const logSpy = jest.spyOn(console, "log").mockImplementation();
|
||||
const warnSpy = jest.spyOn(console, "warn").mockImplementation();
|
||||
const errorSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
const { dependencies } = createDependencies();
|
||||
|
||||
await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
logSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("rejects unsupported identities before minting or network submission", async () => {
|
||||
const { dependencies, fetchMock, mintMock, authMock } = createDependencies({
|
||||
did: "did:peer:unsupported",
|
||||
});
|
||||
|
||||
const result = await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errorMessage:
|
||||
"AlertSearch authorization requires an active did:ethr identity.",
|
||||
});
|
||||
expect(mintMock).not.toHaveBeenCalled();
|
||||
expect(authMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects notification auth bypass before minting or submission", async () => {
|
||||
const { dependencies, fetchMock, mintMock } = createDependencies({
|
||||
bypassAuth: true,
|
||||
});
|
||||
|
||||
const result = await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(mintMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not submit when the device zone cannot be resolved", async () => {
|
||||
const { dependencies, fetchMock } = createDependencies({
|
||||
timeZone: "Not/AZone",
|
||||
});
|
||||
|
||||
const result = await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.ok ? "" : result.errorMessage).toContain("Not/AZone");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a coded refusal using only its code and message", async () => {
|
||||
const { dependencies } = createDependencies({
|
||||
response: createResponse(400, {
|
||||
success: false,
|
||||
error: "ALERT_AUTHORIZATION_INVALID_BATCH",
|
||||
message:
|
||||
"Delegated notification-JWT batch was rejected. Nothing was stored.",
|
||||
details: ["jwts[0].jwt failed verification"],
|
||||
jwt: "must-not-be-returned",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
status: 400,
|
||||
errorCode: "ALERT_AUTHORIZATION_INVALID_BATCH",
|
||||
errorMessage:
|
||||
"Delegated notification-JWT batch was rejected. Nothing was stored.",
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("must-not-be-returned");
|
||||
});
|
||||
|
||||
it("surfaces an uncoded auth refusal without an error code", async () => {
|
||||
const { dependencies } = createDependencies({
|
||||
response: createResponse(401, {
|
||||
success: false,
|
||||
message: "Unauthorized. See server logs at 2026-09-14T08:00:00.000Z",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await uploadAlertSearchAuthorization(dependencies);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
status: 401,
|
||||
errorMessage: "Unauthorized. See server logs at 2026-09-14T08:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Manual upload of a push-channel AlertSearch authorization, for the
|
||||
* notification debug panel: mint a delegated JWT batch for the active identity
|
||||
* and `PUT /notifications/alert-authorization`, with the send time fixed at
|
||||
* 09:00 in the device's zone.
|
||||
*
|
||||
* The SMS channel uploads the same body through `authorizeSmsAlertSearch`, and
|
||||
* both mint with `mintAlertAuthorizationBatch`. The wire shapes are in
|
||||
* `@/interfaces/notifyApi`.
|
||||
*/
|
||||
|
||||
import type { AlertAuthorizationRequestBody } from "@/interfaces/notifyApi";
|
||||
import { ETHR_DID_PREFIX } from "@/libs/crypto/vc";
|
||||
import {
|
||||
ALERT_AUTHORIZATION_BATCH_DAYS,
|
||||
type AlertAuthorizationBatch,
|
||||
mintAlertAuthorizationBatch,
|
||||
} from "./alertAuthorizationBatch";
|
||||
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
|
||||
import { getNotificationApiBaseUrl } from "./NotificationDebugConfig";
|
||||
import {
|
||||
getActiveNotificationDid,
|
||||
getNotificationApiHeaders,
|
||||
httpAuthErrorMessage,
|
||||
} from "./notificationApiAuth";
|
||||
import { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
|
||||
|
||||
export type AlertAuthorizationUploadResult =
|
||||
| {
|
||||
ok: true;
|
||||
status: number;
|
||||
batchId: string;
|
||||
timezone: string;
|
||||
jwtCount: number;
|
||||
firstDay: string;
|
||||
lastDay: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
errorMessage: string;
|
||||
status?: number;
|
||||
errorCode?: string;
|
||||
};
|
||||
|
||||
type FetchNotificationApi = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export interface AlertAuthorizationDependencies {
|
||||
getActiveDid: () => Promise<string | null>;
|
||||
isAuthBypassEnabled: () => boolean;
|
||||
mintBatch: (did: string) => Promise<AlertAuthorizationBatch>;
|
||||
/** IANA zone whose 09:00 becomes the batch's UTC send time. */
|
||||
getTimeZone: () => string;
|
||||
getAuthHeaders: typeof getNotificationApiHeaders;
|
||||
getBaseUrl: () => string;
|
||||
fetch: FetchNotificationApi;
|
||||
}
|
||||
|
||||
const defaultDependencies: AlertAuthorizationDependencies = {
|
||||
getActiveDid: getActiveNotificationDid,
|
||||
isAuthBypassEnabled: shouldBypassNotificationAuth,
|
||||
mintBatch: (did) => mintAlertAuthorizationBatch(did),
|
||||
getTimeZone: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
getAuthHeaders: getNotificationApiHeaders,
|
||||
getBaseUrl: getNotificationApiBaseUrl,
|
||||
fetch: (input, init) => globalThis.fetch(input, init),
|
||||
};
|
||||
|
||||
function buildRequestBody(
|
||||
batch: AlertAuthorizationBatch,
|
||||
timeZone: string,
|
||||
): AlertAuthorizationRequestBody {
|
||||
if (batch.jwts.length !== ALERT_AUTHORIZATION_BATCH_DAYS) {
|
||||
throw new Error(
|
||||
`Expected ${ALERT_AUTHORIZATION_BATCH_DAYS} delegated JWTs, received ${batch.jwts.length}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const notifyTime = alertSearchNotifyTimeUtcFromLocalNineAm(timeZone);
|
||||
|
||||
return {
|
||||
batchId: batch.batchId,
|
||||
notifyHourUtc: notifyTime.notifyHourUtc,
|
||||
notifyMinuteUtc: notifyTime.notifyMinuteUtc,
|
||||
timezone: timeZone,
|
||||
jwts: batch.jwts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The code and message of a refusal, and nothing else from its body. The
|
||||
* service sends an `AlertAuthorizationFailure`: the code, when there is one,
|
||||
* under `error`, and the sentence under `message`.
|
||||
*/
|
||||
async function readRefusal(
|
||||
response: Response,
|
||||
): Promise<{ message?: string; errorCode?: string }> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (typeof body !== "object" || body === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { error, message } = body as { error?: unknown; message?: unknown };
|
||||
return {
|
||||
...(typeof message === "string" && message.trim()
|
||||
? { message: message.trim() }
|
||||
: {}),
|
||||
...(typeof error === "string" && error.trim()
|
||||
? { errorCode: error.trim() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint and manually upload a delegated AlertSearch authorization batch.
|
||||
* This function never retries and refuses notification auth bypass mode.
|
||||
*/
|
||||
export async function uploadAlertSearchAuthorization(
|
||||
dependencies: AlertAuthorizationDependencies = defaultDependencies,
|
||||
): Promise<AlertAuthorizationUploadResult> {
|
||||
try {
|
||||
const did = await dependencies.getActiveDid();
|
||||
if (!did) {
|
||||
return { ok: false, errorMessage: "No active identity is available." };
|
||||
}
|
||||
if (!did.startsWith(ETHR_DID_PREFIX)) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage:
|
||||
"AlertSearch authorization requires an active did:ethr identity.",
|
||||
};
|
||||
}
|
||||
if (dependencies.isAuthBypassEnabled()) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage:
|
||||
"AlertSearch authorization cannot be uploaded while JWT authentication is skipped.",
|
||||
};
|
||||
}
|
||||
|
||||
const batch = await dependencies.mintBatch(did);
|
||||
const timeZone = dependencies.getTimeZone();
|
||||
const body = buildRequestBody(batch, timeZone);
|
||||
|
||||
const auth = await dependencies.getAuthHeaders(undefined, did);
|
||||
if (!auth.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: `Authentication unavailable: ${auth.message}`,
|
||||
};
|
||||
}
|
||||
if (!auth.authenticated) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage:
|
||||
"AlertSearch authorization requires authenticated notification API headers.",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await dependencies.fetch(
|
||||
`${dependencies.getBaseUrl()}/notifications/alert-authorization`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: auth.headers,
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const refusal = await readRefusal(response);
|
||||
const fallback =
|
||||
response.status === 401 || response.status === 403
|
||||
? httpAuthErrorMessage(response.status)
|
||||
: `HTTP ${response.status}`;
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
errorMessage: refusal.message || fallback,
|
||||
...(refusal.errorCode ? { errorCode: refusal.errorCode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
batchId: body.batchId,
|
||||
timezone: timeZone,
|
||||
jwtCount: body.jwts.length,
|
||||
firstDay: body.jwts[0]!.day,
|
||||
lastDay: body.jwts[body.jwts.length - 1]!.day,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: error instanceof Error ? error.message : "Upload failed.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 `alertAuthorization.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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
|
||||
|
||||
describe("alertSearchNotifyTimeUtcFromLocalNineAm", () => {
|
||||
it("converts 09:00 Asia/Manila to 01:00 UTC", () => {
|
||||
const result = alertSearchNotifyTimeUtcFromLocalNineAm(
|
||||
"Asia/Manila",
|
||||
new Date("2026-09-10T16:00:00.000Z"),
|
||||
);
|
||||
expect(result).toEqual({ notifyHourUtc: 1, notifyMinuteUtc: 0 });
|
||||
});
|
||||
|
||||
it("returns integers in the UTC hour/minute ranges", () => {
|
||||
const result = alertSearchNotifyTimeUtcFromLocalNineAm(
|
||||
"America/Denver",
|
||||
new Date("2026-01-15T12:00:00.000Z"),
|
||||
);
|
||||
expect(Number.isInteger(result.notifyHourUtc)).toBe(true);
|
||||
expect(Number.isInteger(result.notifyMinuteUtc)).toBe(true);
|
||||
expect(result.notifyHourUtc).toBeGreaterThanOrEqual(0);
|
||||
expect(result.notifyHourUtc).toBeLessThanOrEqual(23);
|
||||
expect(result.notifyMinuteUtc).toBeGreaterThanOrEqual(0);
|
||||
expect(result.notifyMinuteUtc).toBeLessThanOrEqual(59);
|
||||
expect(result).toEqual({ notifyHourUtc: 16, notifyMinuteUtc: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Debug/E2E AlertSearch notify-time conversion only.
|
||||
* Not a user-facing setting; does not read Daily Reminder or New Activity times.
|
||||
*/
|
||||
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
/** Temporary default wall-clock time for AlertSearch authorization uploads. */
|
||||
export const ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_HOUR = 9;
|
||||
export const ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_MINUTE = 0;
|
||||
|
||||
function requireIanaTimeZone(timeZone: string): string {
|
||||
const probe = DateTime.now().setZone(timeZone);
|
||||
if (!timeZone || !probe.isValid) {
|
||||
throw new Error(
|
||||
"Invalid IANA timezone for AlertSearch notify time: " + timeZone,
|
||||
);
|
||||
}
|
||||
return timeZone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert 09:00 in `timeZone` (IANA) on the calendar day of `now` to UTC hour/minute.
|
||||
*/
|
||||
export function alertSearchNotifyTimeUtcFromLocalNineAm(
|
||||
timeZone: string,
|
||||
now: Date = new Date(),
|
||||
): { notifyHourUtc: number; notifyMinuteUtc: number } {
|
||||
const zone = requireIanaTimeZone(timeZone);
|
||||
const localNine = DateTime.fromJSDate(now, { zone }).set({
|
||||
hour: ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_HOUR,
|
||||
minute: ALERT_SEARCH_DEBUG_LOCAL_NOTIFY_MINUTE,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
if (!localNine.isValid) {
|
||||
throw new Error(
|
||||
"Could not convert AlertSearch 09:00 local notify time to UTC for zone " +
|
||||
zone,
|
||||
);
|
||||
}
|
||||
const utc = localNine.toUTC();
|
||||
return {
|
||||
notifyHourUtc: utc.hour,
|
||||
notifyMinuteUtc: utc.minute,
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,10 @@ export {
|
||||
getBackendBaseUrl,
|
||||
getBypassAuth,
|
||||
getNotificationApiBaseUrl,
|
||||
getNotificationDebugOverrideHeaders,
|
||||
getTestMode,
|
||||
NGROK_SKIP_BROWSER_WARNING_HEADER,
|
||||
NGROK_SKIP_BROWSER_WARNING_VALUE,
|
||||
normalizeNotificationBackendUrl,
|
||||
setBackendBaseUrl,
|
||||
setBypassAuth,
|
||||
@@ -35,6 +38,8 @@ export {
|
||||
export { NotificationService, registerToken } from "./NotificationService";
|
||||
export { NativeNotificationService } from "./NativeNotificationService";
|
||||
export { WebPushNotificationService } from "./WebPushNotificationService";
|
||||
export { uploadAlertSearchAuthorization } from "./alertAuthorization";
|
||||
export type { AlertAuthorizationUploadResult } from "./alertAuthorization";
|
||||
|
||||
export { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
|
||||
export {
|
||||
@@ -52,6 +57,26 @@ export {
|
||||
timeToCron,
|
||||
timeToCronFiveMinutesBefore,
|
||||
} from "./dualScheduleConfig";
|
||||
export {
|
||||
ALERT_AUTHORIZATION_BATCH_DAYS,
|
||||
buildAlertAuthorizationBody,
|
||||
localTimeTextToUtc,
|
||||
mintAlertAuthorizationBatch,
|
||||
UnsupportedIdentityError,
|
||||
} from "./alertAuthorizationBatch";
|
||||
export type { AlertAuthorizationBatch } from "./alertAuthorizationBatch";
|
||||
|
||||
export {
|
||||
authorizeSmsAlertSearch,
|
||||
deleteSmsPhone,
|
||||
listSmsPhones,
|
||||
normalizePhoneNumber,
|
||||
registerSmsPhone,
|
||||
revokeSmsAlertSearch,
|
||||
SmsApiError,
|
||||
smsErrorMessage,
|
||||
verifySmsPhone,
|
||||
} from "./smsNotificationApi";
|
||||
export type { DualScheduleConfigInput } from "./dualScheduleConfig";
|
||||
|
||||
export {
|
||||
|
||||
@@ -8,10 +8,16 @@
|
||||
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
|
||||
import { mintBackgroundJwtTokenPool } from "@/libs/crypto";
|
||||
import {
|
||||
BackgroundJwtUnsupportedIdentityError,
|
||||
mintBackgroundJwtTokenPool,
|
||||
} from "@/libs/crypto/backgroundJwtPool";
|
||||
import { retrieveFullyDecryptedAccount } from "@/libs/util";
|
||||
import type { KeyMetaWithPrivate } from "@/interfaces/common";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||
import { BACKGROUND_JWT_SECONDS_PER_DAY } from "@/constants/backgroundJwt";
|
||||
import { onNotificationAuthMayBeReady } from "./notificationAuthLifecycle";
|
||||
|
||||
/**
|
||||
@@ -64,8 +70,39 @@ export async function configureNativeFetcherIfReady(
|
||||
: DEFAULT_ENDORSER_API_SERVER;
|
||||
}
|
||||
|
||||
const jwtTokens = await mintBackgroundJwtTokenPool(did);
|
||||
const jwtToken = jwtTokens[0] ?? "";
|
||||
// Decrypt once here rather than once per token inside the minter.
|
||||
const account = await retrieveFullyDecryptedAccount(did);
|
||||
if (!account) {
|
||||
logger.debug(
|
||||
"[nativeFetcherConfig] No account for active DID; skipping native fetcher config",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let jwtTokens: string[];
|
||||
try {
|
||||
jwtTokens = await mintBackgroundJwtTokenPool(
|
||||
account as KeyMetaWithPrivate,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof BackgroundJwtUnsupportedIdentityError) {
|
||||
// Passkey identities: minting would raise one WebAuthn prompt per token
|
||||
// and yield one-minute tokens. Leave prefetch unconfigured instead.
|
||||
logger.debug(
|
||||
"[nativeFetcherConfig] Identity cannot mint a background pool; " +
|
||||
"skipping native fetcher config",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// Pool slots are keyed by `epochDay % size`, not by distance from the mint,
|
||||
// so the standalone `jwtToken` a plugin without pool support falls back to
|
||||
// has to be read at today's slot rather than at index 0.
|
||||
const todayEpochDay = Math.floor(
|
||||
Date.now() / 1000 / BACKGROUND_JWT_SECONDS_PER_DAY,
|
||||
);
|
||||
const jwtToken = jwtTokens[todayEpochDay % jwtTokens.length] ?? "";
|
||||
if (!jwtToken) {
|
||||
logger.warn(
|
||||
"[nativeFetcherConfig] No JWT for native fetcher; API-driven notifications may fail",
|
||||
@@ -98,3 +135,51 @@ export async function configureNativeFetcherIfReady(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the JWT pool the native fetcher holds, so a signed-out or switched-away
|
||||
* identity leaves no usable credential on the device.
|
||||
*
|
||||
* This is custody, not revocation: the tokens stay cryptographically valid
|
||||
* until their windows close, and Endorser offers nothing that would invalidate
|
||||
* a copy taken before this call. What it bounds is the ordinary case -- account
|
||||
* switch, sign-out, a shared or lost handset -- where no copy was taken and the
|
||||
* only remaining risk is the device's own store. A cleared pool leaves each
|
||||
* selector with an empty bearer, which both refuse to send.
|
||||
*
|
||||
* Call before the active identity changes; `configureNativeFetcherIfReady`
|
||||
* mints a fresh pool for whichever identity takes over.
|
||||
*
|
||||
* @returns true when the pool was cleared or there was nothing to clear
|
||||
*/
|
||||
export async function clearNativeFetcherPool(): Promise<boolean> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return false;
|
||||
}
|
||||
if (!DailyNotification?.configureNativeFetcher) {
|
||||
logger.warn(
|
||||
"[nativeFetcherConfig] Plugin configureNativeFetcher not available; " +
|
||||
"cannot clear the native JWT pool",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await DailyNotification.configureNativeFetcher({
|
||||
apiBaseUrl: DEFAULT_ENDORSER_API_SERVER,
|
||||
activeDid: "",
|
||||
jwtToken: "",
|
||||
jwtTokens: [],
|
||||
});
|
||||
logger.info("[nativeFetcherConfig] Native fetcher JWT pool cleared");
|
||||
return true;
|
||||
} catch (error) {
|
||||
// A plugin build that rejects empty credentials leaves the old pool in
|
||||
// place; say so rather than reporting a clear that did not happen.
|
||||
logger.error(
|
||||
"[nativeFetcherConfig] Could not clear the native JWT pool; " +
|
||||
"the previous identity's tokens remain on the device:",
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { getHeaders } from "@/libs/endorserServer";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { getNotificationDebugOverrideHeaders } from "./NotificationDebugConfig";
|
||||
import { shouldBypassNotificationAuth } from "./notificationApiDebugMode";
|
||||
import { logNotification } from "./NotificationDebugEvents";
|
||||
|
||||
@@ -20,7 +21,7 @@ export type NotificationApiHeadersResult =
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "no_active_did" | "missing_token";
|
||||
reason: "no_active_did" | "missing_token" | "identity_changed";
|
||||
message: string;
|
||||
};
|
||||
|
||||
@@ -28,7 +29,7 @@ const DEBUG_HEADERS: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
async function resolveActiveDid(): Promise<string | null> {
|
||||
export async function getActiveNotificationDid(): Promise<string | null> {
|
||||
try {
|
||||
const service = PlatformServiceFactory.getInstance();
|
||||
const row = await service.dbGetOneRow(
|
||||
@@ -71,19 +72,28 @@ function logDebugUnauthenticatedNotificationRequest(): void {
|
||||
/**
|
||||
* Resolve headers for notification API requests.
|
||||
* @param kind Optional request kind for structured logs.
|
||||
* @param expectedDid Reject if the active identity changed before token minting.
|
||||
*/
|
||||
export async function getNotificationApiHeaders(
|
||||
kind?: NotificationRequestKind,
|
||||
expectedDid?: string,
|
||||
): Promise<NotificationApiHeadersResult> {
|
||||
if (shouldBypassNotificationAuth()) {
|
||||
logAuthBypassEnabled();
|
||||
if (kind) {
|
||||
logDebugUnauthenticatedNotificationRequest();
|
||||
}
|
||||
return { ok: true, authenticated: false, headers: { ...DEBUG_HEADERS } };
|
||||
return {
|
||||
ok: true,
|
||||
authenticated: false,
|
||||
headers: {
|
||||
...DEBUG_HEADERS,
|
||||
...getNotificationDebugOverrideHeaders(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const did = await resolveActiveDid();
|
||||
const did = await getActiveNotificationDid();
|
||||
if (!did) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -91,6 +101,13 @@ export async function getNotificationApiHeaders(
|
||||
message: "no active identity (cannot authenticate)",
|
||||
};
|
||||
}
|
||||
if (expectedDid && did !== expectedDid) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "identity_changed",
|
||||
message: "active identity changed before authentication",
|
||||
};
|
||||
}
|
||||
|
||||
const headers = await getHeaders(did);
|
||||
if (!hasBearerToken(headers)) {
|
||||
@@ -111,6 +128,7 @@ export async function getNotificationApiHeaders(
|
||||
headers: {
|
||||
"Content-Type": headers["Content-Type"],
|
||||
Authorization: headers.Authorization,
|
||||
...getNotificationDebugOverrideHeaders(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -150,3 +168,39 @@ export function httpAuthErrorMessage(status: number): string {
|
||||
}
|
||||
return `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/** A response body parsed as JSON, or undefined when it is empty or not JSON. */
|
||||
export async function readNotificationApiBody(res: Response): Promise<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,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,144 @@
|
||||
import {
|
||||
BACKGROUND_JWT_POOL_SIZE,
|
||||
BACKGROUND_JWT_SECONDS_PER_DAY,
|
||||
} from "@/constants/backgroundJwt";
|
||||
import {
|
||||
BackgroundJwtUnsupportedIdentityError,
|
||||
mintBackgroundJwtTokenPool,
|
||||
} from "@/libs/crypto/backgroundJwtPool";
|
||||
import { createEndorserJwtForKey } from "@/libs/crypto/vc";
|
||||
import type { KeyMetaWithPrivate } from "@/interfaces/common";
|
||||
|
||||
jest.mock("@/libs/crypto/vc", () => ({ createEndorserJwtForKey: jest.fn() }));
|
||||
|
||||
const mockedSign = createEndorserJwtForKey as unknown as jest.Mock;
|
||||
|
||||
const DID = "did:ethr:0x0000000000000000000000000000000000000001";
|
||||
|
||||
const seedAccount = {
|
||||
did: DID,
|
||||
identity: JSON.stringify({ keys: [{ privateKeyHex: "ab".repeat(32) }] }),
|
||||
} as unknown as KeyMetaWithPrivate;
|
||||
|
||||
const passkeyAccount = {
|
||||
did: "did:peer:0zAbC",
|
||||
passkeyCredIdHex: "beef",
|
||||
} as unknown as KeyMetaWithPrivate;
|
||||
|
||||
interface DayWindow {
|
||||
nbf: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signer is mocked, so each slot carries back the window it was minted with
|
||||
* instead of a JWT. Distinctness of the real strings rests on these windows
|
||||
* differing: ES256K signing is deterministic, so equal payloads sign to equal
|
||||
* bytes.
|
||||
*/
|
||||
function windowsFromMint(tokens: string[]): DayWindow[] {
|
||||
return tokens.map((t) => JSON.parse(t) as DayWindow);
|
||||
}
|
||||
|
||||
/** The selector both native fetchers run, in Java and Swift alike. */
|
||||
function selectBearerTokenForRequest(
|
||||
pool: string[],
|
||||
atEpochMs: number,
|
||||
): string {
|
||||
const epochDay = Math.floor(atEpochMs / (24 * 60 * 60 * 1000));
|
||||
return pool[epochDay % pool.length];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedSign.mockReset();
|
||||
mockedSign.mockImplementation(async (_account, payload) =>
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
});
|
||||
|
||||
describe("mintBackgroundJwtTokenPool", () => {
|
||||
// 23:47 UTC: a mint late in the day must still file today's token in today's
|
||||
// slot, and the pool must not run a day short at the far end.
|
||||
const MINTED_AT = Date.UTC(2026, 8, 14, 23, 47, 3);
|
||||
|
||||
function mintAt(epochMs: number): Promise<string[]> {
|
||||
jest.spyOn(Date, "now").mockReturnValue(epochMs);
|
||||
return mintBackgroundJwtTokenPool(seedAccount);
|
||||
}
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it("fills every slot exactly once", async () => {
|
||||
const pool = await mintAt(MINTED_AT);
|
||||
expect(pool).toHaveLength(BACKGROUND_JWT_POOL_SIZE);
|
||||
expect(pool.filter(Boolean)).toHaveLength(BACKGROUND_JWT_POOL_SIZE);
|
||||
});
|
||||
|
||||
it("gives every slot a distinct day window", async () => {
|
||||
const pool = await mintAt(MINTED_AT);
|
||||
expect(new Set(pool).size).toBe(BACKGROUND_JWT_POOL_SIZE);
|
||||
});
|
||||
|
||||
it("scopes each token to one day rather than the whole grant", async () => {
|
||||
const windows = windowsFromMint(await mintAt(MINTED_AT));
|
||||
for (const w of windows) {
|
||||
const span = w.exp - w.nbf;
|
||||
expect(span).toBeLessThan(2 * BACKGROUND_JWT_SECONDS_PER_DAY);
|
||||
expect(span).toBeGreaterThan(BACKGROUND_JWT_SECONDS_PER_DAY);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The invariant the native selectors depend on. Both read
|
||||
* `pool[epochDay % size]` and hold no mint date, so the token filed in a slot
|
||||
* has to be the one whose window covers the day that lands on it. A pool
|
||||
* ordered by distance-from-mint passes every other test here and fails this
|
||||
* one -- silently, in production, as rejected prefetches.
|
||||
*/
|
||||
it("files each day's token where the native selector looks for it", async () => {
|
||||
const pool = await mintAt(MINTED_AT);
|
||||
const firstDayStart =
|
||||
Math.floor(MINTED_AT / 1000 / BACKGROUND_JWT_SECONDS_PER_DAY) *
|
||||
BACKGROUND_JWT_SECONDS_PER_DAY;
|
||||
|
||||
for (let offset = 0; offset < BACKGROUND_JWT_POOL_SIZE; offset++) {
|
||||
// midday on each covered day, well inside any clock-skew slack
|
||||
const atSec = firstDayStart + offset * BACKGROUND_JWT_SECONDS_PER_DAY;
|
||||
for (const probe of [atSec, atSec + BACKGROUND_JWT_SECONDS_PER_DAY - 1]) {
|
||||
const picked = selectBearerTokenForRequest(pool, probe * 1000);
|
||||
const w = JSON.parse(picked) as DayWindow;
|
||||
expect(probe).toBeGreaterThanOrEqual(w.nbf);
|
||||
expect(probe).toBeLessThanOrEqual(w.exp);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("holds the slot mapping whatever day minting starts on", async () => {
|
||||
// Every residue mod the pool size, so no start day is a lucky one.
|
||||
for (const dayOffset of [0, 1, 37, BACKGROUND_JWT_POOL_SIZE - 1]) {
|
||||
const at = MINTED_AT + dayOffset * 24 * 60 * 60 * 1000;
|
||||
const pool = await mintAt(at);
|
||||
const picked = selectBearerTokenForRequest(pool, at);
|
||||
const w = JSON.parse(picked) as DayWindow;
|
||||
const nowSec = Math.floor(at / 1000);
|
||||
expect(nowSec).toBeGreaterThanOrEqual(w.nbf);
|
||||
expect(nowSec).toBeLessThanOrEqual(w.exp);
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses passkey identities instead of prompting once per token", async () => {
|
||||
await expect(mintBackgroundJwtTokenPool(passkeyAccount)).rejects.toThrow(
|
||||
BackgroundJwtUnsupportedIdentityError,
|
||||
);
|
||||
expect(mockedSign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("decrypts the identity once, not once per token", async () => {
|
||||
const pool = await mintAt(MINTED_AT);
|
||||
expect(mockedSign).toHaveBeenCalledTimes(pool.length);
|
||||
for (const call of mockedSign.mock.calls) {
|
||||
expect(call[0]).toBe(seedAccount);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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(),
|
||||
}));
|
||||
// The real module reads `@/constants/app`, whose `import.meta.env` Jest cannot parse.
|
||||
jest.mock("@/services/notifications/NotificationDebugConfig", () => ({
|
||||
getNotificationDebugOverrideHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
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" ||
|
||||
@@ -781,6 +782,23 @@ export const PlatformServiceMixin = {
|
||||
* Required for smart deletion pattern
|
||||
*/
|
||||
async $setActiveDid(did: string | null): Promise<void> {
|
||||
// The outgoing identity's background-prefetch JWTs cover days ahead and
|
||||
// stay valid on their own; drop them here so they do not outlive the
|
||||
// identity that authorized them. Imported lazily to keep the native
|
||||
// notification stack out of the bundle for every component using this
|
||||
// mixin. A failure to clear is logged inside and must not block the
|
||||
// switch, which is why this is not awaited into the caller's failure path.
|
||||
try {
|
||||
const { clearNativeFetcherPool } = await import(
|
||||
"@/services/notifications/nativeFetcherConfig"
|
||||
);
|
||||
await clearNativeFetcherPool();
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
"[PlatformServiceMixin] Native JWT pool clear failed",
|
||||
error,
|
||||
);
|
||||
}
|
||||
await this.$dbExec(
|
||||
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
|
||||
[did],
|
||||
|
||||
+780
-167
File diff suppressed because it is too large
Load Diff
@@ -39,31 +39,76 @@
|
||||
something, so you can record thanks in here.
|
||||
</p>
|
||||
<p>
|
||||
This is a reliable message, but it doesn't contain any details about
|
||||
This is your own message activated in the app, but it doesn't contain any details about
|
||||
activity that might be especially interesting to you.
|
||||
Note that the timing is not precise: the device may deliver it a bit later than scheduled.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">New Activity Notifications</h2>
|
||||
<h2 class="text-xl font-semibold mt-4">New Activity Notifications -- In App</h2>
|
||||
<div>
|
||||
<p>
|
||||
The New Activity Notification will be sent to you when there is new, relevant activity
|
||||
for you.
|
||||
The New Activity Notification will be activated in your app when there is new, relevant
|
||||
activity for you.
|
||||
It will only trigger if something involves you or a project of interest; it will not
|
||||
bug you for other, general activity.
|
||||
</p>
|
||||
<p>
|
||||
This type is not as reliable as a Reminder Notification because mobile devices often
|
||||
suppress such notifications to save battery. (If you want to quickly check for relevant
|
||||
activity daily, use the Reminder Notification and open the app and look for a large green
|
||||
button that points out new activity that is personal to you. We are working on other
|
||||
ways to notify you more reliably.
|
||||
<router-link class="text-blue-500" to="/help">
|
||||
go here to follow us or contact us
|
||||
<font-awesome icon="chevron-right" class="fa-fw"></font-awesome>
|
||||
</router-link>.)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">New Activity Notifications -- Texts From A Server</h2>
|
||||
<div>
|
||||
<p>
|
||||
This New Activity Notification SMS will be sent to you when there is new, relevant
|
||||
activity for you.
|
||||
It will only trigger if something involves you or a project of interest; it will not
|
||||
bug you for other, general activity.
|
||||
</p>
|
||||
<p class="mt-2">Notes and caveats for text messages:</p>
|
||||
<ul class="list-disc list-outside ml-5 mt-1">
|
||||
<li>
|
||||
Text messages are opt-in: they are only sent after you enter your
|
||||
phone number and confirm it with a verification code.
|
||||
</li>
|
||||
<li>
|
||||
Standard message and data rates from your carrier may apply.
|
||||
</li>
|
||||
<li>
|
||||
Message frequency is at most one New Activity text per day, at or
|
||||
after the time you choose. The time is stored in UTC, so it shifts
|
||||
by an hour when daylight saving starts or ends; set it again to put
|
||||
it back where you want it.
|
||||
</li>
|
||||
<li>
|
||||
Identities that sign with a passkey cannot turn this on, because
|
||||
the server needs credentials it can use while your phone is asleep.
|
||||
</li>
|
||||
<li>
|
||||
You can stop at any time: turn off the toggle in your settings, use
|
||||
"Forget Phone Number" to remove your number entirely, or reply STOP
|
||||
to any message. Reply HELP for help.
|
||||
</li>
|
||||
<li>
|
||||
Delivery is not guaranteed or precisely timed; carriers may delay
|
||||
or drop messages, and neither we nor the carriers are liable for
|
||||
late or undelivered texts.
|
||||
</li>
|
||||
<li>
|
||||
Only US phone numbers are currently supported.
|
||||
</li>
|
||||
<li>
|
||||
Your phone number is used only to send you these notifications; it
|
||||
is stored on the notification server and is deleted when you choose
|
||||
"Forget Phone Number".
|
||||
</li>
|
||||
<li>
|
||||
See our
|
||||
<a href="https://timesafari.app/help-terms" target="_blank" class="text-blue-500">
|
||||
Terms & Conditions and Privacy Policy docs here.
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- eslint-enable -->
|
||||
</section>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -85,7 +85,8 @@ test('Record an offer', async ({ page }) => {
|
||||
|
||||
// click on the number of new offers to go to the list page
|
||||
await offerNumElem.click();
|
||||
await expect(page.getByText('New Offers To Your Projects', { exact: true })).toBeVisible();
|
||||
// "Offer" for exactly one new offer, "Offers" otherwise
|
||||
await expect(page.getByText(/^New Offers? To Your Projects$/)).toBeVisible();
|
||||
// get the icon child of the showOffersToUserProjects
|
||||
await page.getByTestId('showOffersToUserProjects').locator('div > svg.fa-chevron-right').click();
|
||||
await expect(page.getByText(description)).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user