# SMS Notification Service Problem: Users have no way to receive daily SMS notifications for new activity from the endorser-ch system. The daily-notification-plugin handles on-device notifications via Capacitor, but there is no server-side SMS channel for users who want text messages. ## Goal & Value Proposition - Allow users to register and verify a cell phone number. - Allow a daily digest of new activity from the `alertSearch` endpoint. - Allow users to choose their preferred time of day. - Run a periodic background service that wakes at the appropriate times and sends SMS messages. This gives users a reliable, device-independent notification channel that works even when the app is not installed or active, complementing the on-device daily-notification-plugin. ## Architecture Decision: Separate Service This should be a **separate service** rather than code inside endorser-ch, for several reasons: 1. **Separation of concerns** -- endorser-ch is a claim/attestation server; SMS delivery is operationally distinct (external API keys, retry queues, delivery tracking). 2. **Independent scaling and deployment** -- SMS delivery has different failure modes (carrier throttling, vendor outages) and should not block or destabilize the claim server. 3. **Security boundary** -- the SMS service stores phone numbers (PII); keeping them in a separate database limits blast radius. ### Access to Endorser-ch Partner Endpoints The SMS service needs to call `alertSearch` and potentially other partner endpoints on behalf of users. **User-issued long-lived JWT** When a user registers for SMS notifications, the Time Safari app generates a long-lived JWT and sends it to the SMS service, which stores it alongside the user's registration. The SMS service then uses this stored JWT directly when calling endorser-ch partner endpoints (e.g., `alertSearch`) on the user's behalf. This requires no new authentication mechanism on endorser-ch and gives each user control over their own scope. The tradeoff is that JWTs eventually expire, so the app must refresh them periodically (e.g., on app open, the app checks the SMS service for token expiry and re-issues if needed). Options considered: | Approach | Pros | Cons | |----------|------|------| | **User-issued long-lived JWT** | No new auth mechanism; user controls scope | JWTs expire; user must re-authorize periodically | | **Shared secret header** | Simple to implement; endorser-ch adds one middleware check | Another secret to rotate; coarse-grained access | | **Service DID with delegation** | Fits existing DID auth model; fine-grained | More complex; requires DID key management in the SMS service | | **Internal network + API key** | Simple if co-located | Assumes network topology; not portable | ## Components ### 1. Phone Number Storage & Verification - New table in the SMS service database (not in partner.db). The phone number is the natural key -- a given phone number has at most one registration, and the `issuerDid` tracks who currently owns it: ```sql CREATE TABLE sms_user ( id INTEGER PRIMARY KEY AUTOINCREMENT, phoneNumber TEXT UNIQUE NOT NULL, -- E.164 format; one registration per phone issuerDid TEXT NOT NULL, -- current owner's DID verified BOOLEAN DEFAULT FALSE, verificationCode TEXT, -- 6-digit code, hashed verificationExpiry DATETIME, createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ); ``` - Verification flow: 1. User submits phone number via the Time Safari app (which calls `POST /api/sms/register` with the user's session JWT for authentication). 2. SMS service validates the number is US-only (`+1` prefix) for free-tier users. International numbers are rejected unless the user has a paid tier (see Phase 5). 3. SMS service sends a 6-digit code via SMS. The code is valid for **15 minutes**. 4. User enters the code in the app; the app sends it to `POST /api/sms/verify`. 5. On match, `verified` is set to TRUE. The user's phone is now registered but no notifications are active yet. 6. Requesting a new code while one is still valid **overrides** the previous code (the old code becomes invalid). 7. Rate limits: max 3 verification codes per hour, max 5 attempts per code. After exceeding the code request limit, a **1-hour cooldown** before new codes can be requested. - **JWT lifecycle** (separate from registration): The long-lived JWT is **not** sent at registration time. It is only needed when notifications are enabled: 1. When the user enables any notification type, the app generates a long-lived JWT and sends it to `PUT /api/sms/jwt`. The SMS service stores it and records its expiry. 2. The SMS service will not activate notifications without a valid stored JWT. 3. The app is responsible for refreshing the stored JWT before it expires (e.g., every 90 days). On app open, the app checks `GET /api/sms/preferences` which includes JWT expiry status, and calls `PUT /api/sms/jwt` with a fresh token if needed. 4. If the stored JWT expires without being refreshed, the scheduler skips that user's `alert_search` notifications (which require the JWT for endorser-ch calls). - **DID reassignment (lost key scenario)**: A user who loses their DID and creates a new one can re-register the same phone number: 1. User calls `POST /api/sms/register` with their new DID (from the new JWT) and their existing phone number. 2. The SMS service sees the phone number already exists with a different DID. It does **not** change the DID yet -- it sets `verified = FALSE`, stores the new DID in a `pendingDid` column, and sends a new verification code (subject to the same 15-minute expiry and rate limits). 3. The user completes verification via `POST /api/sms/verify`. 4. On successful verification, the service updates `issuerDid` to the new DID, clears `pendingDid`, and sets `verified = TRUE`. All existing notification preferences are preserved and now operate under the new DID. 5. The old DID can no longer access or modify this registration. 6. The old stored JWT (if any) is cleared. The user must send a new long-lived JWT via `PUT /api/sms/jwt` before `alert_search` notifications will resume. This ensures that phone ownership is always proven before a DID change takes effect, preventing someone from hijacking another user's notifications by claiming their number. Schema to support this: ```sql CREATE TABLE sms_user ( id INTEGER PRIMARY KEY AUTOINCREMENT, phoneNumber TEXT UNIQUE NOT NULL, -- E.164 format; one registration per phone issuerDid TEXT NOT NULL, -- current verified owner's DID pendingDid TEXT, -- new DID awaiting verification (NULL if none) storedJwt TEXT, -- long-lived JWT for calling endorser-ch on user's behalf jwtExpiresAt DATETIME, -- when the stored JWT expires; app refreshes before this verified BOOLEAN DEFAULT FALSE, verificationCode TEXT, -- 6-digit code, hashed verificationExpiry DATETIME, -- 15 minutes from code generation verificationAttempts INTEGER DEFAULT 0, -- attempts against current code (max 5) codesRequestedThisHour INTEGER DEFAULT 0, -- codes sent in current hour window (max 3) cooldownUntil DATETIME, -- if set, no new codes until this time (1-hour cooldown) createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ); ``` ### 2. Notification Preferences - **Phone verification is required before any notification can be enabled.** The `PUT /api/sms/preferences` endpoint rejects requests to set `enabled = TRUE` if the user's phone is not verified. Additionally, enabling any notification that requires endorser-ch data (i.e., `alert_search`) requires a valid stored JWT; the endpoint rejects the request if no JWT is stored or if it has expired. - Table for user notification choices: ```sql CREATE TABLE sms_notification_pref ( id INTEGER PRIMARY KEY AUTOINCREMENT, userId INTEGER NOT NULL REFERENCES sms_user(id), notificationType TEXT NOT NULL, -- 'alert_search' enabled BOOLEAN DEFAULT TRUE, sendTimeUtc TEXT NOT NULL, -- HH:MM in UTC timezoneOffset INTEGER, -- minutes from UTC, for display alertSearchParams TEXT, -- JSON: location bounds, plan IDs, etc. createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(userId, notificationType) ); ``` ### 3. SMS Sending - Use **Twilio** as the SMS provider (well-documented, good international support, reasonable pricing). - Alternative: Amazon SNS (cheaper for high volume) or Vonage. - Wrap the provider behind an interface so it can be swapped later. - Store send history for debugging and rate-limiting: ```sql CREATE TABLE sms_send_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, userId INTEGER NOT NULL REFERENCES sms_user(id), notificationType TEXT NOT NULL, sentAt DATETIME DEFAULT CURRENT_TIMESTAMP, status TEXT NOT NULL, -- 'sent', 'failed', 'delivered' providerMessageId TEXT, errorMessage TEXT ); ``` ### 4. Periodic Scheduler - A cron-like scheduler (e.g., `node-cron` or system cron) that runs **every 15 minutes**. - Each run: 1. Query all enabled preferences where `sendTimeUtc` falls within the current 15-minute window. 2. For `alert_search` type: a. Call `GET /api/partner/alertSearch` on the endorser-ch server using the user's stored JWT, passing the user's stored `alertSearchParams` and an `afterDate` of the last successful check. b. If new activity is found, compose a summary SMS and send it. c. Update the last-checked timestamp. 3. Log each send attempt to `sms_send_log`. - Idempotency: track the last send time per preference to avoid duplicate sends on scheduler restarts. ### 5. Authentication: JWT Validation via Endorser-ch The SMS service does **not** implement its own JWT verification. Instead, every incoming request is authenticated by forwarding the caller's JWT to the endorser-ch server for validation: 1. Client sends request with `Authorization: Bearer ` header. 2. SMS service forwards the JWT to the endorser-ch `GET /api/v2/report/rateLimits` endpoint, which validates the JWT and returns the caller's DID along with rate-limit data. 3. SMS service uses the returned DID as the authenticated identity for the request. 4. If endorser-ch returns an error or 401, the SMS service rejects the request. This keeps DID/JWT verification logic in one place and avoids duplicating the DID resolver stack (ethr-did-resolver, did-peer, etc.) in the SMS service. The endorser-ch call result can be cached briefly (e.g., 60 seconds keyed by JWT hash) to avoid hammering the endpoint on rapid sequential requests. ### 6. API Endpoints (SMS Service) All endpoints require a signed JWT validated against endorser-ch (see section 5 above). | Method | Path | Description | |--------|------|-------------| | POST | `/api/sms/register` | Submit phone number, triggers verification SMS | | POST | `/api/sms/verify` | Submit verification code; reassigns DID if phone was previously registered | | DELETE | `/api/sms/register` | Remove phone number and all preferences | | PUT | `/api/sms/jwt` | Refresh the stored JWT used for endorser-ch calls | | GET | `/api/sms/preferences` | Get current notification preferences | | PUT | `/api/sms/preferences` | Update notification preferences | | GET | `/api/sms/history` | Get recent send history | ### 7. Time Safari App Integration The SMS settings live in the Account screen's notification section, next to the existing on-device New Activity notification. The SMS channel is gated behind an explicit enablement step, so the UI has three stacked controls: 1. **Enable Text Messages** (master toggle). While no phone number is registered, the SMS row is labeled "Enable Text Messages". Turning it on prompts for a phone number, then advances to a screen for the verification code texted to that number. Only after the code is confirmed does the toggle turn on and the per-notification controls below become available. Once a number is registered the row is labeled "SMS Text Message" and shows the number. 2. **New Activity Text** (per-notification toggle). Available only while text messages are enabled. Turning it on opens the daily-time modal (the same time picker as the on-device channel) so the user chooses when their new-activity text arrives each day. Turning it off just removes that one notification; the phone number and SMS enablement are untouched. 3. **Forget Phone Number** (trash link, shown with the registered number). Removes the number entirely: Enable Text Messages turns off and New Activity Text becomes unavailable until a number is verified again. This maps to `DELETE /api/sms/register` on the service. ``` New Activity Notification ┌──────────────────────────────────────────────┐ │ 📱 In-App Notification [ON] │ │ Time: 6:00 PM │ ├──────────────────────────────────────────────┤ │ 💬 Enable Text Messages [OFF] │ <- phone + code prompts on enable └──────────────────────────────────────────────┘ ...and once a number is verified: ┌──────────────────────────────────────────────┐ │ 💬 SMS Text Message [ON] │ │ Texting: +1 555 123 4567 │ │ 🗑 Forget Phone Number │ │ ├─ New Activity Text [OFF] │ <- opens the daily-time modal └──────────────────────────────────────────────┘ ``` This layout makes it immediately clear that: - In-app and SMS are two **delivery channels** for the same logical notification, each configured independently. - The phone number is a channel-level credential, verified once and reused by every SMS notification type (New Activity Text now; event-triggered types later). - Removing the number is an explicit, separate act -- not a side effect of toggling a notification off. - **Master toggle off vs. Forget**: turning Enable Text Messages off pauses the SMS channel but keeps the verified number, so re-enabling skips re-verification. Forget Phone Number is the destructive path that requires verifying again. - **Shared defaults**: When a user enables SMS for a notification type they already have configured on-device, pre-fill the SMS time and message from the on-device settings as a convenience (user can change them independently). - **JWT refresh**: Handled transparently. When the app opens the notification settings screen, it checks the SMS service for JWT expiry status and silently refreshes if needed. No user-facing UI for this. ## Endorser-ch Changes Required Minimal changes to the endorser-ch server: 1. **No new endpoints required.** The SMS service uses the existing `GET /api/v2/report/rateLimits` endpoint to validate incoming JWTs and extract the caller's DID. For scheduled `alertSearch` calls, the SMS service uses the user's stored JWT directly, so no shared-secret middleware or service-to-service auth is needed on endorser-ch. ## Deployment This may be folded into the notification-wakeup-service. - **Runtime**: Node.js (consistent with endorser-ch ecosystem). - **Database**: SQLite (consistent with endorser-ch; simple deployment). - **Deployment**: Same server as endorser-ch or a small adjacent container. - **Environment variables**: - `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE_NUMBER` - `ENDORSER_API_URL` (e.g., `https://partner-api.endorser.ch`) - `SMS_DB_PATH` ## Security Considerations - Phone numbers are PII: encrypt at rest if the deployment environment supports it. - Verification codes are hashed (bcrypt) before storage. - Rate-limit SMS sends per user (max 2 per day per notification type; max 5 verification codes per day). - Stored user JWTs must be encrypted at rest; they grant access to endorser-ch on the user's behalf. - The app must refresh stored JWTs before they expire; the SMS service should flag users whose JWTs are nearing expiry so the app can prompt a refresh. - Consider allowing users to pause/resume without deleting their number. ## Cost Estimate - Twilio SMS: ~$0.0079/message (US), higher internationally. - At 100 users x 2 messages/day = 200 messages/day = ~$1.58/day = ~$47/month. - Verification messages add marginal cost (one-time per user). ## Implementation Phases ### Phase 0: UI Mock-up in Time Safari (no back end) Work lives on the `notify-api-sms` branch of crowd-funder-for-time-pwa. All state is component-local fake data (deliberately not persisted -- it only needs to survive a walk-through on an emulator) and every back-end round-trip is stubbed, so each modal can be demoed without notify-api calls (any 6-digit code passes verification). - [x] "Enable Text Messages" master toggle on the Account screen that runs the phone-entry -> verification-code dialogs (`SmsVerificationDialog`) - [x] "New Activity Text" toggle, available only while text messages are enabled, that opens the existing daily-time modal - [x] "Forget Phone Number" control that clears the number, turns off the master toggle, and hides New Activity Text ### Phase 1: Core Infrastructure - [ ] Set up new Node.js service with SQLite database - [ ] Implement JWT-forwarding auth middleware (validate JWTs via endorser-ch `rateLimits` endpoint) - [ ] Implement phone registration and verification endpoints (including DID reassignment flow) - [ ] Implement stored JWT management (store user-issued JWTs, track expiry, refresh endpoint) - [ ] Integrate Twilio SDK for sending SMS ### Phase 2: Notification Preferences & Scheduler - [ ] Implement preference storage and API endpoints - [ ] Build the periodic scheduler (15-minute interval) - [ ] Implement `alert_search` notification type (fetch + summarize + send) ### Phase 3: App Integration - [ ] Wire the Phase 0 mock-up UI to the real endpoints (register, verify, preferences, delete/forget), replacing the component-local fake data - [ ] Add send history view ### Phase 4: Hardening - [ ] Delivery status webhooks from Twilio (track delivered vs. failed) - [ ] Retry logic for failed sends - [ ] Admin dashboard for monitoring send rates and failures - [ ] Opt-out via SMS reply (STOP keyword handling) - [ ] Upgrade to service DID delegation auth (replacing stored user JWTs) ### Phase 5: Paid Tier -- Higher Limits & International SMS - [ ] Integrate a payment mechanism (e.g., Stripe, or crypto/gift-economy credit) - [ ] Add `sms_user` columns for payment status and tier (`free` / `paid`) - [ ] Enforce US-only phone numbers for free tier; allow international numbers for paid tier - [ ] Raise daily rate limits for paid users (e.g., free: 2 messages/day, paid: 10 messages/day) - [ ] Add payment status checks to the send path and preference validation - [ ] Time Safari UI for payment/upgrade flow ### Phase 6: Event-Triggered SMS from Partner Functions - [ ] Add `POST /api/sms/send` endpoint to SMS service (service-to-service auth) - [ ] Add per-event-type opt-in preferences (profile messages, meeting invites, etc.) - [ ] Integrate first trigger in endorser-ch (e.g., profile messaging) - [ ] Add event-type rate limiting per recipient - [ ] Extend Time Safari settings UI for event-type opt-in/opt-out ## Future Requirement: Event-Triggered SMS from Partner Functions Beyond the scheduled daily notifications, there are future needs for **on-demand SMS messages triggered by partner functions inside endorser-ch**. These are not periodic -- they are fired in response to specific events. ### Example Scenarios - **Profile messaging**: A user views a profile and wants to send a message to the profile owner. If the profile owner has opted in to SMS messages, the system sends them an SMS with the message content (or a link to view it). - **Meeting invitations**: An organizer creates a group onboarding meeting and wants to notify invited participants via SMS. - **Matching results**: After `groupOnboardMatch` runs, notify matched pairs via SMS. - **Claim confirmations**: Notify a user when someone confirms one of their claims. ### Implementation Options | Approach | How it works | Pros | Cons | |----------|-------------|------|------| | **A. SMS service exposes a send API** | Endorser-ch calls `POST /api/sms/send` on the SMS service with the shared secret, target DID, and message body. The SMS service looks up the DID's verified phone number and sends the message. | Clean separation; endorser-ch never touches Twilio or phone numbers; SMS service owns all delivery logic, rate-limiting, and opt-out checks. | Adds a network hop; SMS service must be available for real-time sends. | | Others below | | | | **Recommended: Option A (SMS send API)** for initial implementation, with an eye toward **Option D (webhooks)** as the system matures. Option A is straightforward -- a single `POST /api/sms/send` endpoint on the SMS service with a service-to-service auth mechanism (e.g., a shared secret between the two services for this internal endpoint). The SMS service remains the sole owner of phone numbers, delivery logic, rate limits, and opt-out enforcement. ### Send API Design (Option A) ``` POST /api/sms/send Authorization: X-Service-Secret: { "recipientDid": "did:ethr:0x...", "message": "Someone wants to connect with you on Time Safari. Open the app to respond.", "senderDid": "did:ethr:0x...", // optional, for audit/rate-limiting "eventType": "profile_message" // for categorized opt-in/opt-out } ``` - The SMS service checks that the recipient has a verified phone number and has opted in to messages of this `eventType`. - Rate limits apply per recipient (e.g., max 5 event-triggered messages per day). - The `sms_send_log` table records these sends alongside scheduled ones. - If the recipient has not registered or has opted out, the service returns a `404` or `204` (no message sent) so endorser-ch can fall back to other channels. ### Opt-in Granularity This requires extending the notification preferences to support event-triggered message types: ```sql -- Additional rows in sms_notification_pref with eventType-based types: -- notificationType: 'event_profile_message', 'event_meeting_invite', -- 'event_match_result', 'event_claim_confirmation', etc. -- sendTimeUtc is NULL for event-triggered types. ``` Users should be able to opt in or out of each event type independently in the Time Safari settings. ## Decided Questions 1. ~~**International SMS**~~: **Decided: US-only for free tier.** International numbers will be supported in Phase 5 as part of the paid tier. 2. **Message length**: **Decided: truncate to single SMS.** AlertSearch results could be lengthy. Should we truncate to 160 chars (single SMS segment) or allow multi-segment messages? 3. **Opt-out compliance**: US carriers require STOP/HELP keyword handling (TCPA compliance). Twilio handles this automatically, but we need to be aware of it. 4. **User cap**: **Decided: no user limit.** Should we limit the number of users who can register for SMS during an initial rollout period? 5. **Alternative to Twilio**: **Decided: start with the simplest SMS service.** Would a self-hosted solution (e.g., connecting to a GSM modem or using Matrix/Signal bridges) be preferable for cost or privacy reasons? ### Rejected Options #### Implementation | Approach | How it works | Pros | Cons | |----------|-------------|------|------| | **B. Message queue between services** | Endorser-ch publishes a message to a queue (e.g., Redis, SQLite-based job queue, or a simple filesystem queue). The SMS service polls or subscribes and sends. | Decoupled; endorser-ch doesn't block on SMS delivery; resilient to SMS service downtime. | More infrastructure; adds latency; queue must be monitored. | | **C. Shared Twilio credentials** | Endorser-ch calls Twilio directly using the same credentials, and queries the SMS service database (or a shared table) for phone number lookup. | No inter-service call needed; lowest latency. | Defeats the purpose of separation; Twilio credentials and phone PII spread across services; harder to enforce rate limits and opt-out centrally. | | **D. Webhook/callback registration** | The SMS service registers a webhook URL with endorser-ch for specific event types. Endorser-ch fires the webhook when events occur, and the SMS service handles delivery. | Event-driven; endorser-ch doesn't need to know about SMS at all, just fires webhooks. | Endorser-ch needs a generic webhook/event system; more complex to build initially. |