Files
README-first/progress/tech/PROJECT-sms-notifications.md

25 KiB

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:

    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:

    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:
    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:
    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 <JWT> 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).

  • "Enable Text Messages" master toggle on the Account screen that runs the phone-entry -> verification-code dialogs (SmsVerificationDialog)
  • "New Activity Text" toggle, available only while text messages are enabled, that opens the existing daily-time modal
  • "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: <shared 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:

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