diff --git a/.env.example b/.env.example
index 1b705ed..cb112c1 100644
--- a/.env.example
+++ b/.env.example
@@ -65,22 +65,19 @@ SENDGRID_FROM_EMAIL=noreply@scholarmancy.com
SENDGRID_FROM_NAME=Scholaracle
SENDGRID_REPLY_TO=rvegajr@yolovibecodebootcamp.com
-# --- Twilio SMS (optional) ---
-# Obtain from https://www.twilio.com/console
-TWILIO_ACCOUNT_SID=
-TWILIO_API_KEY_SID=
-TWILIO_API_KEY_SECRET=
-# Auth token — required in production for webhook signature validation
-TWILIO_AUTH_TOKEN=
-TWILIO_FROM_NUMBER=
-# Messaging Service SID — enables status callbacks + opt-out handling at the service level
-TWILIO_MESSAGING_SERVICE_SID=
+# --- Scholarmancy SMS (Noctusoft relay) ---
+# Outbound: POST https://api.twilio.noctusoft.com/sms/send (Bearer token below).
+NOCTUSOFT_API_KEY=
+# Inbound webhook HMAC: x-relay-signature = base64(HMAC-SHA256(secret, publicUrl + rawBody))
+RELAY_INBOUND_SECRET=
+# Optional overrides (defaults to production api.scholarmancy.com webhook URLs)
+# RELAY_TWILIO_SMS_WEBHOOK_URL=https://api.scholarmancy.com/api/webhooks/twilio/sms
+# RELAY_TWILIO_STATUS_WEBHOOK_URL=https://api.scholarmancy.com/api/webhooks/twilio/status
# --- Noctusoft API Relay (optional) ---
# Route vendor API calls through the Noctusoft unified relay gateway instead of
-# hitting SendGrid/Twilio directly. Leave unset for direct vendor access.
+# hitting SendGrid directly. Leave unset for direct vendor access.
# SENDGRID_BASE_URL=https://api.sendgrid.noctusoft.com
-# TWILIO_API_BASE_URL=https://api.twilio.noctusoft.com
# --- AI Personalization (optional) ---
# Anthropic API key for LLM-powered notification personalization
diff --git a/packages/agents/package.json b/packages/agents/package.json
index 774141f..2fce216 100644
--- a/packages/agents/package.json
+++ b/packages/agents/package.json
@@ -23,7 +23,7 @@
"@scholaracle/studio-core": "workspace:*",
"@scholaracle/logger": "workspace:*",
"@sendgrid/mail": "^8.1.0",
- "twilio": "^5.0.0",
+ "libphonenumber-js": "^1.12.9",
"mongodb": "^6.3.0",
"nodemailer": "^6.9.0"
},
diff --git a/packages/agents/src/delivery/SMSDelivery/SMSDelivery.test.ts b/packages/agents/src/delivery/SMSDelivery/SMSDelivery.test.ts
index 9bbe0b9..53bb1bf 100644
--- a/packages/agents/src/delivery/SMSDelivery/SMSDelivery.test.ts
+++ b/packages/agents/src/delivery/SMSDelivery/SMSDelivery.test.ts
@@ -5,246 +5,43 @@ import {
NotificationPriority,
AgentType,
} from '@scholaracle/contracts';
-import { DeliveryError } from '@scholaracle/contracts';
-import type { Twilio } from 'twilio';
+import type { GuardedSmsSender } from '../../sms/GuardedSmsSender';
describe('SMSDelivery', () => {
let smsDelivery: SMSDelivery;
- let mockTwilio: {
- messages: {
- create: jest.Mock;
- };
- };
-
- const testConfig = {
- accountSid: 'test-account-sid',
- authToken: 'test-auth-token',
- fromNumber: '+15551234567',
- };
+ let mockGuarded: jest.Mocked>;
beforeEach(() => {
- mockTwilio = {
- messages: {
- create: jest.fn(),
- },
+ mockGuarded = {
+ sendTransactional: jest.fn().mockResolvedValue({ messageId: 'SM123' }),
};
-
- smsDelivery = new SMSDelivery(testConfig, mockTwilio as unknown as Twilio);
- });
-
- afterEach(() => {
- jest.clearAllMocks();
+ smsDelivery = new SMSDelivery(mockGuarded as unknown as GuardedSmsSender);
});
- describe('supports', () => {
- it('should return true for SMS channel', () => {
- // Act
- const result = smsDelivery.supports(NotificationChannel.SMS);
-
- // Assert
- expect(result).toBe(true);
- });
-
- it('should return false for non-SMS channels', () => {
- // Act & Assert
- expect(smsDelivery.supports(NotificationChannel.EMAIL)).toBe(false);
- expect(smsDelivery.supports(NotificationChannel.PUSH)).toBe(false);
- expect(smsDelivery.supports(NotificationChannel.IN_APP)).toBe(false);
- });
+ it('supports SMS channel only', () => {
+ expect(smsDelivery.supports(NotificationChannel.SMS)).toBe(true);
+ expect(smsDelivery.supports(NotificationChannel.EMAIL)).toBe(false);
});
- describe('deliver', () => {
- it('should deliver SMS notification successfully', async () => {
- // Arrange
- const notification = new Notification({
- agentType: AgentType.STUDENT,
- studentId: 'student-123',
- userId: '+15559876543',
- subject: 'MISSING ASSIGNMENT',
- body: 'Math: Homework 5\nDue: 2 days ago\nValue: 25 points\n\nSubmit immediately.',
- priority: NotificationPriority.HIGH,
- triggerType: 'missing_assignment',
- });
-
- const messageSid = 'SM1234567890abcdef';
- mockTwilio.messages.create.mockResolvedValue({
- sid: messageSid,
- status: 'queued',
- to: notification.userId,
- from: testConfig.fromNumber,
- body: notification.body,
- } as unknown as Awaited>);
-
- // Act
- const result = await smsDelivery.deliver(notification);
-
- // Assert
- expect(result.success).toBe(true);
- expect(result.channel).toBe(NotificationChannel.SMS);
- expect(result.messageId).toBe(messageSid);
- expect(mockTwilio.messages.create).toHaveBeenCalledTimes(1);
- const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as {
- to?: string;
- from?: string;
- body?: string;
- };
- expect(callArgs?.to).toBe(notification.userId);
- expect(callArgs?.from).toBe(testConfig.fromNumber);
- expect(callArgs?.body).toBeDefined();
- });
-
- it('should format SMS body with subject prefix', async () => {
- // Arrange
- const notification = new Notification({
- agentType: AgentType.PARENT,
- studentId: 'student-123',
- userId: '+15559876543',
- subject: 'John Doe - Grade Drop Alert',
- body: 'Math grade dropped from 92% to 85%',
- priority: NotificationPriority.HIGH,
- triggerType: 'grade_drop',
- });
-
- mockTwilio.messages.create.mockResolvedValue({
- sid: 'SM123',
- status: 'queued',
- } as unknown as Awaited>);
-
- // Act
- await smsDelivery.deliver(notification);
-
- // Assert
- const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as {
- body?: string;
- };
- if (callArgs?.body) {
- expect(callArgs.body).toContain(notification.subject);
- expect(callArgs.body).toContain(notification.body);
- }
- });
-
- it('should truncate body if exceeds SMS length limit', async () => {
- // Arrange
- const longBody = 'A'.repeat(2000);
- const notification = new Notification({
- agentType: AgentType.STUDENT,
- studentId: 'student-123',
- userId: '+15559876543',
- subject: 'Test',
- body: longBody,
- priority: NotificationPriority.MEDIUM,
- triggerType: 'test',
- });
-
- mockTwilio.messages.create.mockResolvedValue({
- sid: 'SM123',
- status: 'queued',
- } as unknown as Awaited>);
-
- // Act
- await smsDelivery.deliver(notification);
-
- // Assert
- const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as {
- body?: string;
- };
- expect(callArgs?.body?.length).toBeLessThanOrEqual(1600);
- });
-
- it('should use userId as recipient phone number', async () => {
- // Arrange
- const notification = new Notification({
- agentType: AgentType.STUDENT,
- studentId: 'student-123',
- userId: '+15551234567',
- subject: 'Test',
- body: 'Test body',
- priority: NotificationPriority.MEDIUM,
- triggerType: 'test',
- });
-
- mockTwilio.messages.create.mockResolvedValue({
- sid: 'SM123',
- status: 'queued',
- } as unknown as Awaited>);
-
- // Act
- await smsDelivery.deliver(notification);
-
- // Assert
- const callArgs = mockTwilio.messages.create.mock.calls[0]?.[0] as {
- to?: string;
- };
- expect(callArgs?.to).toBe('+15551234567');
- });
-
- it('should throw DeliveryError when Twilio API fails', async () => {
- // Arrange
- const notification = new Notification({
- agentType: AgentType.STUDENT,
- studentId: 'student-123',
- userId: '+15551234567',
- subject: 'Test',
- body: 'Test body',
- priority: NotificationPriority.HIGH,
- triggerType: 'test',
- });
-
- const twilioError = new Error('Invalid phone number');
- mockTwilio.messages.create.mockRejectedValue(twilioError);
-
- // Act & Assert
- await expect(smsDelivery.deliver(notification)).rejects.toThrow(DeliveryError);
- await expect(smsDelivery.deliver(notification)).rejects.toThrow(
- expect.objectContaining({
- channel: NotificationChannel.SMS,
- })
- );
- });
-
- it('should handle Twilio error response format', async () => {
- // Arrange
- const notification = new Notification({
- agentType: AgentType.STUDENT,
- studentId: 'student-123',
- userId: '+15551234567',
- subject: 'Test',
- body: 'Test body',
- priority: NotificationPriority.HIGH,
- triggerType: 'test',
- });
-
- const twilioError = {
- code: 21211,
- message: "Invalid 'To' Phone Number",
- status: 400,
- };
- mockTwilio.messages.create.mockRejectedValue(twilioError);
-
- // Act & Assert
- await expect(smsDelivery.deliver(notification)).rejects.toThrow(DeliveryError);
- });
-
- it('should handle error without message property', async () => {
- // Arrange
- const notification = new Notification({
- agentType: AgentType.STUDENT,
- studentId: 'student-123',
- userId: '+15551234567',
- subject: 'Test',
- body: 'Test body',
- priority: NotificationPriority.HIGH,
- triggerType: 'test',
- });
-
- const errorWithoutMessage = { code: 500 };
- mockTwilio.messages.create.mockRejectedValue(errorWithoutMessage);
-
- // Act & Assert
- await expect(smsDelivery.deliver(notification)).rejects.toThrow(DeliveryError);
- await expect(smsDelivery.deliver(notification)).rejects.toThrow(
- 'Unknown error occurred during SMS delivery'
- );
- });
+ it('delivers via guarded sender with formatted body', async () => {
+ const notification = new Notification({
+ id: 'n1',
+ agentType: AgentType.PARENT,
+ studentId: 'stu-1',
+ userId: '+15125550100',
+ subject: 'Due tomorrow',
+ body: 'Math homework',
+ priority: NotificationPriority.MEDIUM,
+ triggerType: 'deadline',
+ channels: [NotificationChannel.SMS],
+ });
+ const result = await smsDelivery.deliver(notification);
+ expect(result.success).toBe(true);
+ expect(result.messageId).toBe('SM123');
+ expect(mockGuarded.sendTransactional).toHaveBeenCalledWith(
+ '+15125550100',
+ 'Due tomorrow\n\nMath homework',
+ expect.objectContaining({ subject: 'Due tomorrow' })
+ );
});
});
diff --git a/packages/agents/src/delivery/SMSDelivery/SMSDelivery.ts b/packages/agents/src/delivery/SMSDelivery/SMSDelivery.ts
index 3104ace..7bf6878 100644
--- a/packages/agents/src/delivery/SMSDelivery/SMSDelivery.ts
+++ b/packages/agents/src/delivery/SMSDelivery/SMSDelivery.ts
@@ -5,141 +5,49 @@ import {
NotificationChannel,
DeliveryError,
} from '@scholaracle/contracts';
-import type { Twilio } from 'twilio';
-
-export interface ISMSDeliveryConfig {
- readonly accountSid: string;
- readonly authToken: string;
- readonly fromNumber: string;
- readonly messagingServiceSid?: string;
-}
-
-const MAX_SMS_LENGTH = 1600;
+import type { GuardedSmsSender } from '../../sms/GuardedSmsSender';
/**
- * SMS delivery service using Twilio.
- * Implements INotificationDelivery for SMS channel.
+ * SMS delivery via GuardedSmsSender (Noctusoft relay, consent-gated).
*/
export class SMSDelivery implements INotificationDelivery {
- private readonly _config: ISMSDeliveryConfig;
- private readonly _twilio: Twilio;
+ constructor(private readonly _guarded: GuardedSmsSender) {}
- constructor(config: ISMSDeliveryConfig, twilio: Twilio) {
- this._config = config;
- this._twilio = twilio;
- }
-
- /**
- * Check if this delivery service supports the given channel.
- *
- * @param channel - The notification channel to check
- * @returns True if this service can deliver via the channel
- */
public supports(channel: NotificationChannel): boolean {
return channel === NotificationChannel.SMS;
}
- /**
- * Deliver a notification via SMS.
- *
- * @param notification - The notification to deliver
- * @returns Delivery result with success status and message ID
- * @throws {DeliveryError} If delivery fails
- */
public async deliver(notification: Notification): Promise {
try {
const smsBody = this._formatSmsBody(notification.subject, notification.body);
-
- const message = await this._twilio.messages.create({
- to: notification.userId,
- ...(this._config.messagingServiceSid
- ? { messagingServiceSid: this._config.messagingServiceSid }
- : { from: this._config.fromNumber }),
- body: smsBody,
+ const result = await this._guarded.sendTransactional(notification.userId, smsBody, {
+ userId: notification.userId,
+ subject: notification.subject,
+ templateName: 'notification',
+ triggeredBy: 'system',
});
-
return {
success: true,
channel: NotificationChannel.SMS,
- messageId: message.sid,
+ messageId: result.messageId,
deliveredAt: new Date(),
};
} catch (error) {
- throw this._createDeliveryError(error, notification.id);
- }
- }
-
- /**
- * Create DeliveryError from unknown error.
- *
- * @param error - Unknown error
- * @param notificationId - Notification ID for error context
- * @returns DeliveryError instance
- */
- private _createDeliveryError(error: unknown, notificationId: string): DeliveryError {
- const errorMessage = this._extractErrorMessage(error);
- const errorCode = this._extractErrorCode(error);
-
- return new DeliveryError(`Failed to deliver SMS: ${errorMessage}`, NotificationChannel.SMS, {
- notificationId,
- errorCode,
- errorMessage,
- });
- }
-
- /**
- * Extract error message from unknown error.
- *
- * @param error - Unknown error
- * @returns Error message string
- */
- private _extractErrorMessage(error: unknown): string {
- if (error instanceof Error) {
- return error.message;
- }
-
- if (
- error &&
- typeof error === 'object' &&
- 'message' in error &&
- typeof error.message === 'string'
- ) {
- return error.message;
+ if (error instanceof DeliveryError) {
+ throw error;
+ }
+ throw new DeliveryError(
+ error instanceof Error ? error.message : 'Unknown SMS error',
+ NotificationChannel.SMS,
+ { notificationId: notification.id }
+ );
}
-
- return 'Unknown error occurred during SMS delivery';
}
- /**
- * Extract error code from unknown error.
- *
- * @param error - Unknown error
- * @returns Error code or undefined
- */
- private _extractErrorCode(error: unknown): number | undefined {
- if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'number') {
- return error.code;
- }
-
- return undefined;
- }
-
- /**
- * Format SMS body from notification subject and body.
- * Truncates if exceeds SMS length limit.
- *
- * @param subject - Notification subject
- * @param body - Notification body
- * @returns Formatted SMS body
- */
private _formatSmsBody(subject: string, body: string): string {
- const fullBody = `${subject}\n\n${body}`;
-
- if (fullBody.length <= MAX_SMS_LENGTH) {
- return fullBody;
+ if (!subject.trim()) {
+ return body;
}
-
- const truncatedBody = body.substring(0, MAX_SMS_LENGTH - subject.length - 10);
- return `${subject}\n\n${truncatedBody}...`;
+ return `${subject}\n\n${body}`;
}
}
diff --git a/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.test.ts b/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.test.ts
deleted file mode 100644
index 4bf3500..0000000
--- a/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { applyTwilioApiBaseUrl } from './applyTwilioApiBaseUrl';
-import type { Twilio } from 'twilio';
-
-function makeFakeClient(): Twilio {
- return { api: { baseUrl: 'https://api.twilio.com' } } as unknown as Twilio;
-}
-
-describe('applyTwilioApiBaseUrl', () => {
- it('overrides the api domain base URL when provided', () => {
- const client = makeFakeClient();
-
- const result = applyTwilioApiBaseUrl(client, 'https://api.twilio.noctusoft.com');
-
- expect((result as unknown as { api: { baseUrl: string } }).api.baseUrl).toBe(
- 'https://api.twilio.noctusoft.com'
- );
- });
-
- it('strips a trailing slash from the override', () => {
- const client = makeFakeClient();
-
- applyTwilioApiBaseUrl(client, 'https://api.twilio.noctusoft.com/');
-
- expect((client as unknown as { api: { baseUrl: string } }).api.baseUrl).toBe(
- 'https://api.twilio.noctusoft.com'
- );
- });
-
- it('leaves the client untouched when no override is provided', () => {
- const client = makeFakeClient();
-
- applyTwilioApiBaseUrl(client, undefined);
-
- expect((client as unknown as { api: { baseUrl: string } }).api.baseUrl).toBe(
- 'https://api.twilio.com'
- );
- });
-
- it('returns the same client instance for chaining', () => {
- const client = makeFakeClient();
-
- expect(applyTwilioApiBaseUrl(client, 'https://relay.example.com')).toBe(client);
- });
-});
diff --git a/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.ts b/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.ts
deleted file mode 100644
index 2cad911..0000000
--- a/packages/agents/src/delivery/SMSDelivery/applyTwilioApiBaseUrl.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type { Twilio } from 'twilio';
-
-/**
- * Point a Twilio client's REST API domain at an alternative base URL, e.g. a
- * Twilio-compatible relay such as the Noctusoft gateway. No-op when baseUrl is
- * unset, so callers can pass the raw env value. Returns the client for chaining.
- */
-export function applyTwilioApiBaseUrl(client: Twilio, baseUrl?: string): Twilio {
- if (baseUrl) {
- (client as unknown as { api: { baseUrl: string } }).api.baseUrl = baseUrl.replace(/\/+$/, '');
- }
- return client;
-}
diff --git a/packages/agents/src/delivery/SMSDelivery/index.ts b/packages/agents/src/delivery/SMSDelivery/index.ts
index e055e5a..1584fc6 100644
--- a/packages/agents/src/delivery/SMSDelivery/index.ts
+++ b/packages/agents/src/delivery/SMSDelivery/index.ts
@@ -1,3 +1 @@
export { SMSDelivery } from './SMSDelivery';
-export type { ISMSDeliveryConfig } from './SMSDelivery';
-export { applyTwilioApiBaseUrl } from './applyTwilioApiBaseUrl';
diff --git a/packages/agents/src/delivery/index.ts b/packages/agents/src/delivery/index.ts
index 86be4a0..4536488 100644
--- a/packages/agents/src/delivery/index.ts
+++ b/packages/agents/src/delivery/index.ts
@@ -27,7 +27,7 @@ export type {
IExpoPushTokenStore,
ExpoPushSender,
} from './ExpoPushDelivery';
-export { SMSDelivery, applyTwilioApiBaseUrl } from './SMSDelivery';
-export type { ISMSDeliveryConfig } from './SMSDelivery';
+export { SMSDelivery } from './SMSDelivery';
+export * from '../sms';
export { InAppDelivery } from './InAppDelivery';
export { DeliveryRouter } from './DeliveryRouter';
diff --git a/packages/agents/src/guidance/ReminderNotificationSink.ts b/packages/agents/src/guidance/ReminderNotificationSink.ts
index 3be5170..f265ea8 100644
--- a/packages/agents/src/guidance/ReminderNotificationSink.ts
+++ b/packages/agents/src/guidance/ReminderNotificationSink.ts
@@ -24,7 +24,7 @@ export class ReminderNotificationSink implements INotificationSink {
}): Promise {
const email = await this._deps.resolveEmail(input.audience, input.studentId);
if (email === null || email === '') return;
- const subject = input.audience === 'parent' ? 'Scholaracle' : 'A next step';
+ const subject = input.audience === 'parent' ? 'Scholarmancy' : 'A next step';
await this._deps.notificationService.sendReminder(
email,
'email',
diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts
index 4fd44a2..a0fff34 100644
--- a/packages/agents/src/index.ts
+++ b/packages/agents/src/index.ts
@@ -10,4 +10,5 @@ export * from './scheduler';
export * from './queue';
export * from './worker';
export * from './recipient-resolver';
+export * from './sms';
export * from './ai';
diff --git a/packages/agents/src/integration/NotificationFlow.integration.test.ts b/packages/agents/src/integration/NotificationFlow.integration.test.ts
index f44cf5d..4272bb5 100644
--- a/packages/agents/src/integration/NotificationFlow.integration.test.ts
+++ b/packages/agents/src/integration/NotificationFlow.integration.test.ts
@@ -19,7 +19,7 @@ import {
} from '@scholaracle/contracts';
import type { Db } from 'mongodb';
import { MongoClient } from 'mongodb';
-import type { Twilio } from 'twilio';
+import type { GuardedSmsSender } from '../sms/GuardedSmsSender';
describe('NotificationFlow Integration', () => {
jest.setTimeout(30_000);
@@ -38,7 +38,7 @@ describe('NotificationFlow Integration', () => {
let inAppDelivery: InAppDelivery;
let pushDelivery: PushDelivery;
let mockEmailTransport: jest.Mocked;
- let mockTwilio: jest.Mocked;
+ let mockGuardedSms: jest.Mocked>;
async function waitForCount(params: {
readonly collection: string;
@@ -84,25 +84,16 @@ describe('NotificationFlow Integration', () => {
} as unknown as jest.Mocked;
(mockEmailTransport.send as jest.Mock).mockResolvedValue({});
- mockTwilio = {
- messages: {
- create: jest.fn(),
- },
- } as unknown as jest.Mocked;
+ mockGuardedSms = {
+ sendTransactional: jest.fn().mockResolvedValue({ messageId: 'sms-123' }),
+ };
emailDelivery = new EmailDelivery(
{ fromEmail: 'test@example.com', fromName: 'Test' },
mockEmailTransport
);
- smsDelivery = new SMSDelivery(
- {
- accountSid: 'test-account-sid',
- authToken: 'test-auth-token',
- fromNumber: '+1234567890',
- },
- mockTwilio
- );
+ smsDelivery = new SMSDelivery(mockGuardedSms as unknown as GuardedSmsSender);
inAppDelivery = new InAppDelivery();
pushDelivery = new PushDelivery({ projectId: 'test' });
@@ -175,11 +166,6 @@ describe('NotificationFlow Integration', () => {
// Mock SendGrid and Twilio responses
(mockEmailTransport.send as jest.Mock).mockResolvedValue({ messageId: 'email-123' });
- (mockTwilio.messages.create as jest.Mock).mockResolvedValue({
- sid: 'sms-123',
- status: 'queued',
- });
-
// Act Step 1: Generate notifications from alert
const studentNotification = studentGenerator.generate(alert);
const parentNotification = parentGenerator.generate(alert);
diff --git a/packages/agents/src/sms/GuardedSmsSender.test.ts b/packages/agents/src/sms/GuardedSmsSender.test.ts
new file mode 100644
index 0000000..b50dbfb
--- /dev/null
+++ b/packages/agents/src/sms/GuardedSmsSender.test.ts
@@ -0,0 +1,33 @@
+import { TWILIO_OPT_OUT_ERROR_CODE, SCHOLARMANCY_SMS_PURPOSE } from '@scholaracle/contracts';
+import { GuardedSmsSender } from './GuardedSmsSender';
+import type { ISmsConsentRepository } from '@scholaracle/interfaces';
+import type { CommunicationLogRepository } from '@scholaracle/database';
+import type { NoctusoftSmsRelayClient } from './NoctusoftSmsRelayClient';
+
+describe('GuardedSmsSender', () => {
+ it('records opt-out when relay returns 21610', async () => {
+ const consent: jest.Mocked = {
+ hasActiveConsent: jest.fn().mockResolvedValue(true),
+ findByPhoneAndPurpose: jest.fn(),
+ recordOptIn: jest.fn(),
+ recordOptOut: jest.fn().mockResolvedValue(undefined),
+ clearOptOut: jest.fn(),
+ markConfirmationSent: jest.fn(),
+ completeReplyYes: jest.fn(),
+ };
+ const commLogs = {
+ create: jest.fn().mockResolvedValue({}),
+ } as unknown as CommunicationLogRepository;
+ const relay = {
+ send: jest
+ .fn()
+ .mockRejectedValue(
+ Object.assign(new Error('opt out'), { code: TWILIO_OPT_OUT_ERROR_CODE })
+ ),
+ } as unknown as NoctusoftSmsRelayClient;
+
+ const sender = new GuardedSmsSender(relay, consent, commLogs);
+ await expect(sender.sendTransactional('+15125550100', 'Hello')).rejects.toThrow();
+ expect(consent.recordOptOut).toHaveBeenCalledWith('+15125550100', SCHOLARMANCY_SMS_PURPOSE);
+ });
+});
diff --git a/packages/agents/src/sms/GuardedSmsSender.ts b/packages/agents/src/sms/GuardedSmsSender.ts
new file mode 100644
index 0000000..108ee74
--- /dev/null
+++ b/packages/agents/src/sms/GuardedSmsSender.ts
@@ -0,0 +1,127 @@
+import {
+ DeliveryError,
+ NotificationChannel,
+ SCHOLARMANCY_SMS_PURPOSE,
+ TWILIO_OPT_OUT_ERROR_CODE,
+} from '@scholaracle/contracts';
+import type { ISmsConsentRepository } from '@scholaracle/interfaces';
+import type { CommunicationLogRepository, CommunicationTrigger } from '@scholaracle/database';
+import { createLogger } from '@scholaracle/logger';
+
+const log = createLogger('guarded-sms');
+import { ensureBrandSmsBody } from './ensureBrandPrefix';
+import { normalizePhoneE164 } from './normalizePhoneE164';
+import { NoctusoftSmsRelayClient } from './NoctusoftSmsRelayClient';
+
+export interface IGuardedSmsSendMeta {
+ readonly userId?: string;
+ readonly subject?: string;
+ readonly templateName?: string;
+ readonly triggeredBy?: CommunicationTrigger;
+}
+
+/**
+ * Single outbound SMS gate: consent, brand prefix, relay send, comm log, opt-out on 21610.
+ */
+export class GuardedSmsSender {
+ constructor(
+ private readonly _relay: NoctusoftSmsRelayClient,
+ private readonly _consent: ISmsConsentRepository,
+ private readonly _commLogs: CommunicationLogRepository,
+ private readonly _purpose: string = SCHOLARMANCY_SMS_PURPOSE
+ ) {}
+
+ public async sendTransactional(
+ to: string,
+ body: string,
+ meta: IGuardedSmsSendMeta = {}
+ ): Promise<{ messageId: string }> {
+ const phoneE164 = normalizePhoneE164(to);
+ if (!phoneE164) {
+ throw new DeliveryError('Invalid phone number', NotificationChannel.SMS, {
+ errorMessage: 'invalid_phone',
+ });
+ }
+ const hasConsent = await this._consent.hasActiveConsent(phoneE164, this._purpose);
+ if (!hasConsent) {
+ throw new DeliveryError(
+ 'SMS refused: no active consent for this number',
+ NotificationChannel.SMS,
+ {
+ errorMessage: 'no_consent',
+ }
+ );
+ }
+ return this._sendRelay(phoneE164, body, meta);
+ }
+
+ public async sendDoubleOptInConfirmation(
+ to: string,
+ inviterName: string
+ ): Promise<{ messageId: string }> {
+ const phoneE164 = normalizePhoneE164(to);
+ if (!phoneE164) {
+ throw new DeliveryError('Invalid phone number', NotificationChannel.SMS, {
+ errorMessage: 'invalid_phone',
+ });
+ }
+ const who = inviterName.trim() || 'Someone';
+ const body = ensureBrandSmsBody(
+ `${who} added this number for ${this._purpose}. Reply YES to receive these texts. Reply STOP to opt out.`
+ );
+ const result = await this._sendRelay(phoneE164, body, {
+ templateName: 'sms_double_opt_in',
+ triggeredBy: 'system',
+ });
+ await this._consent.markConfirmationSent(phoneE164, this._purpose);
+ return result;
+ }
+
+ private async _sendRelay(
+ phoneE164: string,
+ body: string,
+ meta: IGuardedSmsSendMeta
+ ): Promise<{ messageId: string }> {
+ const brandedBody = ensureBrandSmsBody(body);
+ try {
+ const relayResult = await this._relay.send(phoneE164, brandedBody);
+ await this._commLogs.create({
+ userId: meta.userId ?? phoneE164,
+ channel: 'sms',
+ type: 'notification',
+ subject: meta.subject ?? 'SMS',
+ content: brandedBody,
+ recipientPhone: phoneE164,
+ status: 'sent',
+ sentAt: new Date(),
+ triggeredBy: meta.triggeredBy ?? 'system',
+ templateName: meta.templateName,
+ providerId: relayResult.messageSid,
+ });
+ return { messageId: relayResult.messageSid };
+ } catch (err: unknown) {
+ const code =
+ err &&
+ typeof err === 'object' &&
+ 'code' in err &&
+ typeof (err as { code: unknown }).code === 'number'
+ ? (err as { code: number }).code
+ : undefined;
+ if (code === TWILIO_OPT_OUT_ERROR_CODE) {
+ await this._consent.recordOptOut(phoneE164, this._purpose);
+ log.info({ phoneE164 }, 'SMS opt-out recorded from relay 21610');
+ }
+ const message =
+ err &&
+ typeof err === 'object' &&
+ 'message' in err &&
+ typeof (err as { message: unknown }).message === 'string'
+ ? (err as { message: string }).message
+ : 'SMS relay error';
+ throw new DeliveryError(`Failed to deliver SMS: ${message}`, NotificationChannel.SMS, {
+ errorCode: code,
+ errorMessage: message,
+ });
+ }
+ }
+}
diff --git a/packages/agents/src/sms/NoctusoftSmsRelayClient.ts b/packages/agents/src/sms/NoctusoftSmsRelayClient.ts
new file mode 100644
index 0000000..113f3ad
--- /dev/null
+++ b/packages/agents/src/sms/NoctusoftSmsRelayClient.ts
@@ -0,0 +1,54 @@
+import { SMS_RELAY_SEND_URL, TWILIO_OPT_OUT_ERROR_CODE } from '@scholaracle/contracts';
+
+export interface INoctusoftSmsRelayResult {
+ readonly messageSid: string;
+}
+
+export interface INoctusoftSmsRelayError {
+ readonly code: number;
+ readonly message: string;
+}
+
+/**
+ * POST /sms/send on the Noctusoft Twilio relay (no Twilio SDK).
+ */
+export class NoctusoftSmsRelayClient {
+ constructor(
+ private readonly _apiKey: string,
+ private readonly _fetchFn: typeof fetch = fetch
+ ) {}
+
+ public async send(to: string, body: string): Promise {
+ const res = await this._fetchFn(SMS_RELAY_SEND_URL, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${this._apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ to, body }),
+ });
+ const text = await res.text();
+ let payload: { error?: boolean; code?: number; message?: string; sid?: string } = {};
+ try {
+ payload = JSON.parse(text) as typeof payload;
+ } catch {
+ payload = {};
+ }
+ if (!res.ok) {
+ const code = typeof payload.code === 'number' ? payload.code : res.status;
+ const message =
+ typeof payload.message === 'string' ? payload.message : `SMS relay failed (${res.status})`;
+ throw Object.assign(new Error(message), {
+ code,
+ isOptOut: code === TWILIO_OPT_OUT_ERROR_CODE,
+ });
+ }
+ const sid =
+ typeof payload.sid === 'string'
+ ? payload.sid
+ : typeof (payload as { messageSid?: string }).messageSid === 'string'
+ ? (payload as { messageSid: string }).messageSid
+ : '';
+ return { messageSid: sid || 'unknown' };
+ }
+}
diff --git a/packages/agents/src/sms/createSmsStack.ts b/packages/agents/src/sms/createSmsStack.ts
new file mode 100644
index 0000000..2958049
--- /dev/null
+++ b/packages/agents/src/sms/createSmsStack.ts
@@ -0,0 +1,25 @@
+import type { Db } from 'mongodb';
+import { CommunicationLogRepository, SmsConsentRepository } from '@scholaracle/database';
+import { SMSDelivery } from '../delivery/SMSDelivery/SMSDelivery';
+import { GuardedSmsSender } from './GuardedSmsSender';
+import { NoctusoftSmsRelayClient } from './NoctusoftSmsRelayClient';
+
+export interface ISmsStack {
+ readonly guardedSender: GuardedSmsSender;
+ readonly smsDelivery: SMSDelivery;
+ readonly consentRepository: SmsConsentRepository;
+}
+
+/** Builds relay + consent + guarded SMS delivery when NOCTUSOFT_API_KEY is set. */
+export function createSmsStack(database: Db, apiKey?: string): ISmsStack | null {
+ const key = apiKey ?? process.env['NOCTUSOFT_API_KEY'] ?? '';
+ if (!key) {
+ return null;
+ }
+ const consentRepository = new SmsConsentRepository(database);
+ const commLogs = new CommunicationLogRepository(database);
+ const relay = new NoctusoftSmsRelayClient(key);
+ const guardedSender = new GuardedSmsSender(relay, consentRepository, commLogs);
+ const smsDelivery = new SMSDelivery(guardedSender);
+ return { guardedSender, smsDelivery, consentRepository };
+}
diff --git a/packages/agents/src/sms/ensureBrandPrefix.ts b/packages/agents/src/sms/ensureBrandPrefix.ts
new file mode 100644
index 0000000..6883ec2
--- /dev/null
+++ b/packages/agents/src/sms/ensureBrandPrefix.ts
@@ -0,0 +1,14 @@
+import { SCHOLARMANCY_SMS_BRAND } from '@scholaracle/contracts';
+
+const GSM_SINGLE_SEGMENT = 160;
+
+/** Prefix brand when missing; trim to one segment when possible. */
+export function ensureBrandSmsBody(body: string, brand = SCHOLARMANCY_SMS_BRAND): string {
+ const trimmed = body.trim();
+ const prefix = `${brand}: `;
+ const withBrand = trimmed.startsWith(`${brand}:`) ? trimmed : `${prefix}${trimmed}`;
+ if (withBrand.length <= GSM_SINGLE_SEGMENT) {
+ return withBrand;
+ }
+ return `${withBrand.slice(0, GSM_SINGLE_SEGMENT - 3)}...`;
+}
diff --git a/packages/agents/src/sms/index.ts b/packages/agents/src/sms/index.ts
new file mode 100644
index 0000000..c9c7d97
--- /dev/null
+++ b/packages/agents/src/sms/index.ts
@@ -0,0 +1,7 @@
+export { normalizePhoneE164 } from './normalizePhoneE164';
+export { ensureBrandSmsBody } from './ensureBrandPrefix';
+export { NoctusoftSmsRelayClient } from './NoctusoftSmsRelayClient';
+export { GuardedSmsSender } from './GuardedSmsSender';
+export type { IGuardedSmsSendMeta } from './GuardedSmsSender';
+export { createSmsStack } from './createSmsStack';
+export type { ISmsStack } from './createSmsStack';
diff --git a/packages/agents/src/sms/normalizePhoneE164.test.ts b/packages/agents/src/sms/normalizePhoneE164.test.ts
new file mode 100644
index 0000000..451e83b
--- /dev/null
+++ b/packages/agents/src/sms/normalizePhoneE164.test.ts
@@ -0,0 +1,24 @@
+import { normalizePhoneE164 } from './normalizePhoneE164';
+
+describe('normalizePhoneE164', () => {
+ it('normalizes US 10-digit to E.164', () => {
+ expect(normalizePhoneE164('5125550100')).toBe('+15125550100');
+ });
+
+ it('accepts already E.164', () => {
+ expect(normalizePhoneE164('+15125550100')).toBe('+15125550100');
+ });
+
+ it('normalizes UK numbers when given international format', () => {
+ expect(normalizePhoneE164('+447911123456')).toBe('+447911123456');
+ });
+
+ it('returns null for invalid numbers', () => {
+ expect(normalizePhoneE164('abc')).toBeNull();
+ });
+
+ it('requires non-empty input', () => {
+ expect(() => normalizePhoneE164('')).toThrow(/required/);
+ expect(() => normalizePhoneE164(' ')).toThrow(/required/);
+ });
+});
diff --git a/packages/agents/src/sms/normalizePhoneE164.ts b/packages/agents/src/sms/normalizePhoneE164.ts
new file mode 100644
index 0000000..7864cd9
--- /dev/null
+++ b/packages/agents/src/sms/normalizePhoneE164.ts
@@ -0,0 +1,16 @@
+import { parsePhoneNumberFromString } from 'libphonenumber-js';
+
+/**
+ * Normalizes a phone string to E.164 using libphonenumber-js (default region US).
+ */
+export function normalizePhoneE164(input: string, defaultRegion: 'US' = 'US'): string | null {
+ const trimmed = input.trim();
+ if (!trimmed) {
+ throw new Error('Phone number is required');
+ }
+ const parsed = parsePhoneNumberFromString(trimmed, defaultRegion);
+ if (!parsed?.isValid()) {
+ return null;
+ }
+ return parsed.number;
+}
diff --git a/packages/agents/src/sms/smsSendSurface.test.ts b/packages/agents/src/sms/smsSendSurface.test.ts
new file mode 100644
index 0000000..1104c34
--- /dev/null
+++ b/packages/agents/src/sms/smsSendSurface.test.ts
@@ -0,0 +1,37 @@
+import { readFileSync, readdirSync, statSync } from 'fs';
+import { join } from 'path';
+
+const ROOT = join(__dirname, '..', '..', '..', '..');
+const SCAN_DIRS = ['packages/api/src', 'packages/agents/src', 'packages/workers/src'] as const;
+const ALLOWED_RELAY_FILE = 'packages/agents/src/sms/NoctusoftSmsRelayClient.ts';
+
+function listTsFiles(dir: string): string[] {
+ const abs = join(ROOT, dir);
+ const out: string[] = [];
+ for (const name of readdirSync(abs)) {
+ const p = join(abs, name);
+ const st = statSync(p);
+ if (st.isDirectory()) {
+ out.push(...listTsFiles(join(dir, name)));
+ } else if (name.endsWith('.ts') && !name.endsWith('.test.ts')) {
+ out.push(join(dir, name));
+ }
+ }
+ return out;
+}
+
+describe('SMS send surface', () => {
+ it('only NoctusoftSmsRelayClient calls /sms/send or messages.create', () => {
+ const violations: string[] = [];
+ for (const rel of SCAN_DIRS.flatMap((d) => listTsFiles(d))) {
+ if (rel === ALLOWED_RELAY_FILE) {
+ continue;
+ }
+ const src = readFileSync(join(ROOT, rel), 'utf8');
+ if (src.includes('/sms/send') || src.includes('messages.create')) {
+ violations.push(rel);
+ }
+ }
+ expect(violations).toEqual([]);
+ });
+});
diff --git a/packages/api/package.json b/packages/api/package.json
index 14ef666..302bfeb 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -46,7 +46,6 @@
"nodemailer": "^6.9.0",
"playwright": "^1.58.2",
"rrule": "^2.7.2",
- "twilio": "^5.0.0",
"ua-parser-js": "^2.0.9",
"zod": "^4.1.13"
},
diff --git a/packages/api/src/integration/ApiNotificationFlow.integration.test.ts b/packages/api/src/integration/ApiNotificationFlow.integration.test.ts
index 0b7d96d..c5562c9 100644
--- a/packages/api/src/integration/ApiNotificationFlow.integration.test.ts
+++ b/packages/api/src/integration/ApiNotificationFlow.integration.test.ts
@@ -22,23 +22,6 @@ jest.mock('@sendgrid/mail', () => {
};
});
-// Mock Twilio before importing server
-jest.mock('twilio', () => {
- const mockCreate = jest.fn().mockResolvedValue({
- sid: 'sms-123',
- status: 'queued',
- });
-
- return {
- __esModule: true,
- default: jest.fn(() => ({
- messages: {
- create: mockCreate,
- },
- })),
- };
-});
-
// Mock Firebase Admin before importing server
jest.mock('firebase-admin', () => {
const mockSend = jest.fn().mockResolvedValue('fcm-message-id');
@@ -101,9 +84,6 @@ describe('API Notification Flow Integration', () => {
sendGridApiKey: 'SG.test-key',
sendGridFromEmail: 'test@example.com',
sendGridFromName: 'Test',
- twilioAccountSid: 'TEST_ACCOUNT_SID_PLACEHOLDER_NOT_REAL',
- twilioAuthToken: 'test-token',
- twilioFromNumber: '+1234567890',
},
database
);
diff --git a/packages/api/src/routes/agenda/agenda.ts b/packages/api/src/routes/agenda/agenda.ts
index ba68b0a..a952008 100644
--- a/packages/api/src/routes/agenda/agenda.ts
+++ b/packages/api/src/routes/agenda/agenda.ts
@@ -463,7 +463,7 @@ export function agendaRouter(config: IAgendaRouterConfig): Router {
const timeStr = timeAt ? new Date(timeAt).toLocaleString() : '';
if (timeStr) parts.push(`Due ${timeStr}`);
const body = `Reminder: ${parts.join(' — ')}.`;
- const subject = `Scholaracle: ${displayTitle}`;
+ const subject = `Scholarmancy: ${displayTitle}`;
try {
const result = await config.notificationService.sendReminder(
diff --git a/packages/api/src/routes/auth/auth.ts b/packages/api/src/routes/auth/auth.ts
index b5f16de..3abfd69 100644
--- a/packages/api/src/routes/auth/auth.ts
+++ b/packages/api/src/routes/auth/auth.ts
@@ -15,7 +15,9 @@ import type {
IRefreshTokenStore,
IOAuthAccountRepository,
} from '@scholaracle/database';
-import { StudentRepository, UserRepository } from '@scholaracle/database';
+import { StudentRepository, UserRepository, SmsConsentRepository } from '@scholaracle/database';
+import { normalizePhoneE164 } from '@scholaracle/agents';
+import { recordSmsOptInFromRequest } from '../../services/sms/recordSmsOptIn';
import type { ISessionRepository } from '@scholaracle/database';
import { parseUserAgent } from '../../utils/parseUserAgent';
import { StudentMagicLink } from '../../services/provision/StudentMagicLink';
@@ -146,7 +148,8 @@ async function handleRegister(
req: Request,
res: Response,
authService: AuthService,
- sessionRepository?: ISessionRepository
+ sessionRepository?: ISessionRepository,
+ consentRepo?: SmsConsentRepository
): Promise {
const { email, password, name, phone, smsConsent, rememberMe } = req.body as {
email?: string;
@@ -161,12 +164,29 @@ async function handleRegister(
throw new ValidationError('Missing required fields: email, password, name');
}
+ let normalizedPhone: string | undefined;
+ if (phone?.trim()) {
+ const parsed = normalizePhoneE164(phone);
+ if (!parsed) {
+ throw new ValidationError('Invalid phone number');
+ }
+ normalizedPhone = parsed;
+ }
+ const hasSmsConsent = smsConsent === true;
+ if (hasSmsConsent && !normalizedPhone) {
+ throw new ValidationError('Phone number is required when opting in to SMS');
+ }
+
const result = await authService.register(email, password, name, {
- phone: phone || undefined,
- smsConsent: smsConsent === true,
+ phone: normalizedPhone,
+ smsConsent: hasSmsConsent,
rememberMe: rememberMe !== false,
});
+ if (result.success && hasSmsConsent && normalizedPhone && consentRepo) {
+ await recordSmsOptInFromRequest(consentRepo, req, normalizedPhone, '/register');
+ }
+
if (result.success && result.user?.id && result.familyId) {
await upsertSession(sessionRepository, result.user.id, result.familyId, req);
}
@@ -562,6 +582,7 @@ async function handleLogout(
*/
export function authRouter(config: IAuthRouterConfig): Router {
const router = Router();
+ const consentRepo = new SmsConsentRepository(config.database);
const authService =
config.authService ??
new AuthService(
@@ -584,7 +605,7 @@ export function authRouter(config: IAuthRouterConfig): Router {
router.post(
'/register',
asyncHandler((req: Request, res: Response) =>
- handleRegister(req, res, authService, config.sessionRepository)
+ handleRegister(req, res, authService, config.sessionRepository, consentRepo)
)
);
diff --git a/packages/api/src/routes/settings/settings.ts b/packages/api/src/routes/settings/settings.ts
index 7aebed1..0413f75 100644
--- a/packages/api/src/routes/settings/settings.ts
+++ b/packages/api/src/routes/settings/settings.ts
@@ -1,7 +1,13 @@
import { Router, type Request, type Response } from 'express';
import type { Db } from 'mongodb';
import { AuthenticationError, NotFoundError, ValidationError } from '@scholaracle/contracts';
-import { UserRepository, CommunicationLogRepository } from '@scholaracle/database';
+import {
+ UserRepository,
+ CommunicationLogRepository,
+ SmsConsentRepository,
+} from '@scholaracle/database';
+import { normalizePhoneE164 } from '@scholaracle/agents';
+import { recordSmsOptInFromRequest } from '../../services/sms/recordSmsOptIn';
import type { IAuthService } from '@scholaracle/auth';
import type { IAuthenticatedRequest } from '../../middleware/auth';
import { asyncHandler } from '../../middleware/asyncHandler';
@@ -340,6 +346,8 @@ async function handleGetSettings(
profile: {
name: user.name,
email: user.email,
+ phone: user.phone ?? '',
+ smsConsent: user.smsConsent ?? false,
oauthProviders: user.oauthProviders ?? [],
},
});
@@ -355,7 +363,8 @@ async function handleGetSettings(
async function handleUpdateSettings(
req: Request,
res: Response,
- userRepository: UserRepository
+ userRepository: UserRepository,
+ consentRepo: SmsConsentRepository
): Promise {
const authReq = req as IAuthenticatedRequest;
const userId = authReq.userId;
@@ -369,13 +378,28 @@ async function handleUpdateSettings(
alerts,
dashboard: dashboardBody,
timezone,
+ profile,
} = req.body as {
notifications?: INotificationSettings;
alerts?: IAlertThresholds;
dashboard?: { gradeDisplay?: 'letter' | 'score' };
timezone?: string;
+ profile?: { phone?: string; smsConsent?: boolean };
};
+ let normalizedPhone: string | undefined;
+ if (profile?.phone !== undefined && profile.phone.trim()) {
+ const parsed = normalizePhoneE164(profile.phone);
+ if (!parsed) {
+ throw new ValidationError('Invalid phone number');
+ }
+ normalizedPhone = parsed;
+ }
+ const profileSmsConsent = profile?.smsConsent === true;
+ if (profileSmsConsent && !normalizedPhone && profile?.phone !== undefined) {
+ throw new ValidationError('Phone number is required when opting in to SMS');
+ }
+
if (alerts) {
const validationError = validateAlertThresholds(alerts);
if (validationError) {
@@ -504,11 +528,25 @@ async function handleUpdateSettings(
},
};
- const userUpdate: { preferences: IUserPreferences; timezone?: string } = {
+ const userUpdate: {
+ preferences: IUserPreferences;
+ timezone?: string;
+ phone?: string;
+ smsConsent?: boolean;
+ } = {
preferences: updatedPreferences,
};
if (timezone !== undefined) userUpdate.timezone = timezone;
+ if (profile?.phone !== undefined) {
+ userUpdate.phone = normalizedPhone ?? undefined;
+ }
+ if (profile?.smsConsent !== undefined) {
+ userUpdate.smsConsent = profileSmsConsent;
+ }
await userRepository.update(userId, userUpdate);
+ if (profileSmsConsent && normalizedPhone) {
+ await recordSmsOptInFromRequest(consentRepo, req, normalizedPhone, '/dashboard/settings');
+ }
const responseBody = buildSettingsResponse(updatedPreferences);
res.status(200).json({
@@ -774,6 +812,7 @@ export function settingsRouter(config: ISettingsRouterConfig): Router {
const router = Router();
const userRepository = new UserRepository(config.database);
const commLogRepo = new CommunicationLogRepository(config.database);
+ const consentRepo = new SmsConsentRepository(config.database);
/**
* GET /api/settings
@@ -810,7 +849,9 @@ export function settingsRouter(config: ISettingsRouterConfig): Router {
*/
router.put(
'/',
- asyncHandler((req: Request, res: Response) => handleUpdateSettings(req, res, userRepository))
+ asyncHandler((req: Request, res: Response) =>
+ handleUpdateSettings(req, res, userRepository, consentRepo)
+ )
);
/**
diff --git a/packages/api/src/routes/students/students.ts b/packages/api/src/routes/students/students.ts
index f71a191..a8b6394 100644
--- a/packages/api/src/routes/students/students.ts
+++ b/packages/api/src/routes/students/students.ts
@@ -56,6 +56,8 @@ import { StudentMagicLink } from '../../services/provision/StudentMagicLink';
import { MagicLoginLink } from '../../services/provision/MagicLoginLink';
import { registerStudentLoginRoutes } from './studentLogin';
import type { IMagicLinkSender } from '../../services/provision/MagicLinkSender';
+import type { GuardedSmsSender } from '@scholaracle/agents';
+import { normalizePhoneE164 } from '@scholaracle/agents';
import { noopSink, registerNudgeRoutes } from './nudge';
export interface IStudentsRouterConfig {
@@ -71,6 +73,27 @@ export interface IStudentsRouterConfig {
readonly nudgeSink?: import('@scholaracle/interfaces').INotificationSink;
/** Optional sender for magic login links (email + SMS). */
readonly magicLinkSender?: IMagicLinkSender;
+ readonly guardedSmsSender?: GuardedSmsSender | null;
+}
+
+async function sendContactSmsConfirmationIfNeeded(
+ config: IStudentsRouterConfig,
+ inviterName: string,
+ phone?: string,
+ alertChannels?: readonly ('email' | 'sms')[]
+): Promise {
+ if (!phone?.trim() || !alertChannels?.includes('sms') || !config.guardedSmsSender) {
+ return;
+ }
+ const e164 = normalizePhoneE164(phone);
+ if (!e164) {
+ return;
+ }
+ try {
+ await config.guardedSmsSender.sendDoubleOptInConfirmation(e164, inviterName);
+ } catch {
+ // Relay or delivery failure — skip surfacing to client
+ }
}
// Action-board wire types live in @scholaracle/contracts (types/api/actionBoard.ts).
@@ -516,6 +539,13 @@ export function studentsRouter(config: IStudentsRouterConfig): Router {
};
const newShared: readonly ISharedParent[] = [...student.sharedWith, newContact];
await studentRepository.update(student._id!, { sharedWith: newShared });
+ const inviter = await new UserRepository(config.database).findById(userId);
+ await sendContactSmsConfirmationIfNeeded(
+ config,
+ inviter?.name ?? 'A parent',
+ newContact.phone,
+ newContact.alertChannels
+ );
const baseUrl = config.baseUrl ?? process.env['BASE_URL'] ?? 'http://localhost:2800';
try {
await config.sendInviteEmail?.sendInvite({
@@ -677,6 +707,21 @@ export function studentsRouter(config: IStudentsRouterConfig): Router {
throw new ForbiddenError('You can only edit your own contact prefs');
}
await studentRepository.update(student._id!, { sharedWith: updatedShared });
+ const nextContact = updatedShared[idx]!;
+ const includesSms = nextContact.alertChannels?.includes('sms') ?? false;
+ if (
+ isOwnerOrAdmin &&
+ includesSms &&
+ (body.phone !== undefined || body.alertChannels !== undefined)
+ ) {
+ const inviter = await new UserRepository(config.database).findById(userId);
+ await sendContactSmsConfirmationIfNeeded(
+ config,
+ inviter?.name ?? 'A parent',
+ nextContact.phone,
+ nextContact.alertChannels
+ );
+ }
res.status(200).json({ success: true });
})
);
diff --git a/packages/api/src/routes/webhooks/twilio/relay-signature.middleware.ts b/packages/api/src/routes/webhooks/twilio/relay-signature.middleware.ts
new file mode 100644
index 0000000..e9eaf8e
--- /dev/null
+++ b/packages/api/src/routes/webhooks/twilio/relay-signature.middleware.ts
@@ -0,0 +1,38 @@
+import type { Request, Response, NextFunction } from 'express';
+import { verifyRelayInboundSignature } from '../../../services/sms/verifyRelayInboundSignature';
+
+export interface IRelaySignatureOptions {
+ readonly publicUrl: string;
+ readonly secret?: string;
+}
+
+/**
+ * Validates x-relay-signature on Twilio webhook forwards from the Noctusoft relay.
+ */
+export function requireRelayInboundSignature(options: IRelaySignatureOptions) {
+ return (req: Request, res: Response, next: NextFunction): void => {
+ const nodeEnv = process.env['NODE_ENV'] ?? 'development';
+ const secret = options.secret ?? process.env['RELAY_INBOUND_SECRET'] ?? '';
+ if (!secret) {
+ if (nodeEnv === 'production') {
+ res.status(503).json({ error: 'Relay inbound secret not configured' });
+ return;
+ }
+ next();
+ return;
+ }
+ const signature = req.headers['x-relay-signature'] as string | undefined;
+ if (!signature) {
+ res.status(401).json({ error: 'Missing relay signature' });
+ return;
+ }
+ const captured = (req as Request & { rawBody?: unknown }).rawBody;
+ const rawBody = typeof captured === 'string' ? captured : '';
+ const isValid = verifyRelayInboundSignature(options.publicUrl, rawBody, signature, secret);
+ if (!isValid) {
+ res.status(401).json({ error: 'Invalid relay signature' });
+ return;
+ }
+ next();
+ };
+}
diff --git a/packages/api/src/routes/webhooks/twilio/twilio-signature.middleware.ts b/packages/api/src/routes/webhooks/twilio/twilio-signature.middleware.ts
deleted file mode 100644
index 44ffba2..0000000
--- a/packages/api/src/routes/webhooks/twilio/twilio-signature.middleware.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { Request, Response, NextFunction } from 'express';
-import twilio from 'twilio';
-
-/**
- * Express middleware that validates Twilio request signatures.
- * Rejects requests that were not signed by Twilio (prevents spoofed webhooks).
- * Only enabled in production — skipped in dev/test so ngrok tunnels work without auth tokens.
- */
-export function requireTwilioSignature(authToken: string) {
- return (req: Request, res: Response, next: NextFunction): void => {
- const signature = req.headers['x-twilio-signature'] as string | undefined;
- if (!signature) {
- res.status(403).json({ error: 'Missing Twilio signature' });
- return;
- }
-
- const protocol = req.headers['x-forwarded-proto'] ?? req.protocol;
- const host = req.headers['host'] ?? '';
- const url = `${protocol}://${host}${req.originalUrl}`;
-
- const isValid = twilio.validateRequest(authToken, signature, url, req.body);
- if (!isValid) {
- res.status(403).json({ error: 'Invalid Twilio signature' });
- return;
- }
-
- next();
- };
-}
diff --git a/packages/api/src/routes/webhooks/twilio/twilio-test.router.ts b/packages/api/src/routes/webhooks/twilio/twilio-test.router.ts
index 1102bd1..56507aa 100644
--- a/packages/api/src/routes/webhooks/twilio/twilio-test.router.ts
+++ b/packages/api/src/routes/webhooks/twilio/twilio-test.router.ts
@@ -2,8 +2,7 @@ import { Router, type Request, type Response } from 'express';
import type { Db } from 'mongodb';
import { CommunicationLogRepository } from '@scholaracle/database';
import { InternalError, ValidationError } from '@scholaracle/contracts';
-import twilio from 'twilio';
-import { applyTwilioApiBaseUrl } from '@scholaracle/agents';
+import { createSmsStack } from '../../../services/sms/createSmsStack';
import { asyncHandler } from '../../../middleware/asyncHandler';
export interface ITwilioTestRouterConfig {
@@ -11,15 +10,12 @@ export interface ITwilioTestRouterConfig {
}
/**
- * Test endpoint for Twilio integration debugging.
- * Simulates inbound SMS and status callbacks without Twilio signature validation.
- * ONLY mount in development/staging environments.
+ * Test endpoints for SMS relay debugging (dev/staging only).
*/
export function twilioTestRouter(config: ITwilioTestRouterConfig): Router {
const router = Router();
const commLogRepo = new CommunicationLogRepository(config.database);
- // GET /test/simulate-inbound-sms?from=+1234567890&body=STOP
router.get(
'/simulate-inbound-sms',
asyncHandler(async (req: Request, res: Response): Promise => {
@@ -35,13 +31,11 @@ export function twilioTestRouter(config: ITwilioTestRouterConfig): Router {
Body: body,
MessageSid: `SM_TEST_${Date.now()}`,
},
- note: 'Send this payload as POST to /api/webhooks/twilio/sms to test inbound handler',
- curl: `curl -X POST https://api.scholarmancy.com/api/webhooks/twilio/sms -H "Content-Type: application/x-www-form-urlencoded" -d "From=${encodeURIComponent(from)}&To=${encodeURIComponent(to)}&Body=${encodeURIComponent(body)}&MessageSid=SM_TEST_${Date.now()}"`,
+ note: 'POST to /api/webhooks/twilio/sms with relay signature',
});
})
);
- // GET /test/simulate-status-callback?messageSid=SM123&status=delivered
router.get(
'/simulate-status-callback',
asyncHandler(async (req: Request, res: Response): Promise => {
@@ -50,111 +44,49 @@ export function twilioTestRouter(config: ITwilioTestRouterConfig): Router {
res.json({
success: true,
- simulated: {
- MessageSid: messageSid,
- MessageStatus: status,
- },
- note: 'Send this payload as POST to /api/webhooks/twilio/status to test status callback handler',
- curl: `curl -X POST https://api.scholarmancy.com/api/webhooks/twilio/status -H "Content-Type: application/x-www-form-urlencoded" -d "MessageSid=${messageSid}&MessageStatus=${status}"`,
+ simulated: { MessageSid: messageSid, MessageStatus: status },
});
})
);
- // GET /test/comm-logs?limit=10
router.get(
'/comm-logs',
asyncHandler(async (req: Request, res: Response): Promise => {
const limit = parseInt((req.query['limit'] as string) ?? '10', 10);
const logs = await commLogRepo.filterByChannel('sms');
const recent = logs.slice(0, limit);
-
- res.json({
- success: true,
- count: recent.length,
- logs: recent.map((log) => ({
- _id: log._id?.toString(),
- userId: log.userId,
- status: log.status,
- providerId: log.providerId,
- recipientPhone: log.recipientPhone,
- subject: log.subject,
- sentAt: log.sentAt,
- deliveredAt: log.deliveredAt,
- failedAt: log.failedAt,
- createdAt: log.createdAt,
- })),
- });
+ res.json({ success: true, count: recent.length, logs: recent });
})
);
- // POST /test/send-sms (requires Twilio credentials in env)
router.post(
'/send-sms',
asyncHandler(async (req: Request, res: Response): Promise => {
const { to, body } = req.body as { to?: string; body?: string };
-
if (!to || !body) {
throw new ValidationError('to and body are required');
}
-
- const twilioAccountSid = process.env['TWILIO_ACCOUNT_SID'];
- const twilioApiKeySid = process.env['TWILIO_API_KEY_SID'];
- const twilioApiKeySecret = process.env['TWILIO_API_KEY_SECRET'];
- const messagingServiceSid = process.env['TWILIO_MESSAGING_SERVICE_SID'];
-
- if (!twilioAccountSid || !twilioApiKeySid || !twilioApiKeySecret || !messagingServiceSid) {
- throw new InternalError('Twilio credentials not configured');
+ const stack = createSmsStack(config.database);
+ if (!stack) {
+ throw new InternalError('NOCTUSOFT_API_KEY not configured');
}
-
- const client = applyTwilioApiBaseUrl(
- twilio(twilioApiKeySid, twilioApiKeySecret, {
- accountSid: twilioAccountSid,
- }),
- process.env['TWILIO_API_BASE_URL']
- );
-
- const message = await client.messages.create({
- messagingServiceSid,
- to,
- body,
- });
-
- res.json({
- success: true,
- messageSid: message.sid,
- status: message.status,
- from: message.from,
- to: message.to,
+ const result = await stack.guardedSender.sendTransactional(to, body, {
+ templateName: 'dev_test_send',
+ triggeredBy: 'system',
});
+ res.json({ success: true, messageSid: result.messageId });
})
);
- // GET /test/twilio-config
router.get('/twilio-config', (_req: Request, res: Response): void => {
- const hasAccountSid = Boolean(process.env['TWILIO_ACCOUNT_SID']);
- const hasApiKey = Boolean(process.env['TWILIO_API_KEY_SID']);
- const hasApiSecret = Boolean(process.env['TWILIO_API_KEY_SECRET']);
- const hasAuthToken = Boolean(process.env['TWILIO_AUTH_TOKEN']);
- const hasFromNumber = Boolean(process.env['TWILIO_FROM_NUMBER']);
- const hasMessagingService = Boolean(process.env['TWILIO_MESSAGING_SERVICE_SID']);
-
+ const hasKey = Boolean(process.env['NOCTUSOFT_API_KEY']);
+ const hasInbound = Boolean(process.env['RELAY_INBOUND_SECRET']);
res.json({
configured: {
- TWILIO_ACCOUNT_SID: hasAccountSid,
- TWILIO_API_KEY_SID: hasApiKey,
- TWILIO_API_KEY_SECRET: hasApiSecret,
- TWILIO_AUTH_TOKEN: hasAuthToken,
- TWILIO_FROM_NUMBER: hasFromNumber,
- TWILIO_MESSAGING_SERVICE_SID: hasMessagingService,
- },
- values: {
- TWILIO_ACCOUNT_SID: hasAccountSid ? process.env['TWILIO_ACCOUNT_SID'] : null,
- TWILIO_FROM_NUMBER: hasFromNumber ? process.env['TWILIO_FROM_NUMBER'] : null,
- TWILIO_MESSAGING_SERVICE_SID: hasMessagingService
- ? process.env['TWILIO_MESSAGING_SERVICE_SID']
- : null,
+ NOCTUSOFT_API_KEY: hasKey,
+ RELAY_INBOUND_SECRET: hasInbound,
},
- ready: hasAccountSid && hasApiKey && hasApiSecret && hasMessagingService,
+ ready: hasKey,
});
});
diff --git a/packages/api/src/routes/webhooks/twilio/twilio-webhook.handlers.ts b/packages/api/src/routes/webhooks/twilio/twilio-webhook.handlers.ts
index 9e7a2c3..3b541ca 100644
--- a/packages/api/src/routes/webhooks/twilio/twilio-webhook.handlers.ts
+++ b/packages/api/src/routes/webhooks/twilio/twilio-webhook.handlers.ts
@@ -2,19 +2,35 @@ import type { Request, Response } from 'express';
import type { Db } from 'mongodb';
import {
CommunicationLogRepository,
- AuditLogRepository,
+ SmsConsentRepository,
type CommunicationStatus,
} from '@scholaracle/database';
+import {
+ SCHOLARMANCY_SMS_HELP_LINE,
+ SCHOLARMANCY_SMS_PURPOSE,
+ SCHOLARMANCY_SMS_SUPPORT_EMAIL,
+} from '@scholaracle/contracts';
+import { normalizePhoneE164 } from '@scholaracle/agents';
-const OPT_OUT_KEYWORDS = new Set(['stop', 'stopall', 'unsubscribe', 'cancel', 'end', 'quit']);
-const OPT_IN_KEYWORDS = new Set(['start', 'yes', 'unstop']);
+const OPT_OUT_KEYWORDS = new Set([
+ 'stop',
+ 'stopall',
+ 'unsubscribe',
+ 'cancel',
+ 'end',
+ 'quit',
+ 'revoke',
+ 'optout',
+]);
+const OPT_IN_KEYWORDS = new Set(['start', 'unstop']);
+const HELP_KEYWORDS = new Set(['help', 'info']);
interface ITwilioSmsBody {
readonly MessageSid?: string;
readonly From?: string;
readonly To?: string;
readonly Body?: string;
- readonly NumMedia?: string;
+ readonly OptOutType?: string;
}
interface ITwilioStatusBody {
@@ -34,77 +50,79 @@ const TWILIO_STATUS_MAP: Record = {
failed: 'failed',
};
+function normalizeFromPhone(from: string): string {
+ try {
+ const e164 = normalizePhoneE164(from);
+ return e164 ?? from.trim();
+ } catch {
+ return from.trim();
+ }
+}
+
+function buildHelpTwiml(): string {
+ const text =
+ `${SCHOLARMANCY_SMS_HELP_LINE} Help: ${SCHOLARMANCY_SMS_SUPPORT_EMAIL}. ` +
+ 'Msg frequency varies. Msg & data rates may apply. Reply STOP to opt out.';
+ const escaped = text.replace(/&/g, '&').replace(/${escaped} `;
+}
+
/**
- * Handle inbound SMS from Twilio.
- * Processes opt-out/opt-in keywords (STOP/START) and logs inbound messages.
+ * Handle inbound SMS from the relay (STOP/START/HELP/YES).
*/
export function handleInboundSms(database: Db): (req: Request, res: Response) => Promise {
- const auditRepo = new AuditLogRepository(database);
+ const consentRepo = new SmsConsentRepository(database);
return async (req: Request, res: Response): Promise => {
try {
const body = req.body as ITwilioSmsBody;
- const from = body.From ?? '';
+ const fromRaw = body.From ?? '';
+ const phoneE164 = normalizeFromPhone(fromRaw);
const messageBody = (body.Body ?? '').trim();
const keyword = messageBody.toLowerCase();
+ const optOutType = (body.OptOutType ?? '').toUpperCase();
+
+ const isStop = OPT_OUT_KEYWORDS.has(keyword) || optOutType === 'STOP';
+ const isStart = OPT_IN_KEYWORDS.has(keyword) || optOutType === 'START';
+ const isHelp = HELP_KEYWORDS.has(keyword) || optOutType === 'HELP';
+ const isYes = keyword === 'yes';
+
+ if (isStop) {
+ await consentRepo.recordOptOut(phoneE164, SCHOLARMANCY_SMS_PURPOSE);
+ res.type('text/xml').send(' ');
+ return;
+ }
- if (OPT_OUT_KEYWORDS.has(keyword)) {
- await auditRepo.create({
- adminUserId: 'system',
- adminEmail: 'system@twilio-webhook',
- action: 'system:config_change',
- entityType: 'sms_opt_out',
- entityId: from,
- reason: `Opt-out received: "${messageBody}"`,
- metadata: { phone: from, keyword, messageSid: body.MessageSid },
- ipAddress: req.ip ?? 'unknown',
- userAgent: 'twilio-webhook',
- });
- res
- .type('text/xml')
- .send(
- 'You have been unsubscribed from Scholaracle notifications. Reply START to re-subscribe. '
- );
+ if (isStart) {
+ await consentRepo.clearOptOut(phoneE164, SCHOLARMANCY_SMS_PURPOSE);
+ res.type('text/xml').send(' ');
return;
}
- if (OPT_IN_KEYWORDS.has(keyword)) {
- await auditRepo.create({
- adminUserId: 'system',
- adminEmail: 'system@twilio-webhook',
- action: 'system:config_change',
- entityType: 'sms_opt_in',
- entityId: from,
- reason: `Opt-in received: "${messageBody}"`,
- metadata: { phone: from, keyword, messageSid: body.MessageSid },
- ipAddress: req.ip ?? 'unknown',
- userAgent: 'twilio-webhook',
- });
- res
- .type('text/xml')
- .send(
- 'You have been re-subscribed to Scholaracle notifications. '
- );
+ if (isHelp) {
+ res.type('text/xml').send(buildHelpTwiml());
+ return;
+ }
+
+ if (isYes) {
+ await consentRepo.completeReplyYes(phoneE164, SCHOLARMANCY_SMS_PURPOSE);
+ res.type('text/xml').send(' ');
return;
}
- // Non-keyword inbound SMS — acknowledge without reply
res.type('text/xml').send(' ');
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error('[TwilioWebhook] Inbound SMS error:', error);
+ } catch {
res.type('text/xml').send(' ');
}
};
}
/**
- * Handle delivery status callbacks from Twilio.
- * Maps Twilio statuses (queued/sent/delivered/failed) to internal CommunicationStatus
- * and updates the matching communication log entry.
+ * Handle delivery status callbacks; map status and record 21610 opt-outs.
*/
export function handleStatusCallback(database: Db): (req: Request, res: Response) => Promise {
const commLogRepo = new CommunicationLogRepository(database);
+ const consentRepo = new SmsConsentRepository(database);
return async (req: Request, res: Response): Promise => {
try {
@@ -122,10 +140,13 @@ export function handleStatusCallback(database: Db): (req: Request, res: Response
await commLogRepo.updateDeliveryStatusByProviderId(messageSid, internalStatus);
}
+ if (body.ErrorCode === '21610' && body.To) {
+ const phoneE164 = normalizeFromPhone(body.To);
+ await consentRepo.recordOptOut(phoneE164, SCHOLARMANCY_SMS_PURPOSE);
+ }
+
res.status(200).json({ success: true });
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error('[TwilioWebhook] Status callback error:', error);
+ } catch {
res.status(200).json({ success: true });
}
};
diff --git a/packages/api/src/routes/webhooks/twilio/twilio-webhook.router.ts b/packages/api/src/routes/webhooks/twilio/twilio-webhook.router.ts
index 90dda42..74f60d1 100644
--- a/packages/api/src/routes/webhooks/twilio/twilio-webhook.router.ts
+++ b/packages/api/src/routes/webhooks/twilio/twilio-webhook.router.ts
@@ -1,29 +1,47 @@
import { Router } from 'express';
+import express from 'express';
import type { Db } from 'mongodb';
-import { handleInboundSms } from './twilio-webhook.handlers';
-import { handleStatusCallback } from './twilio-webhook.handlers';
-import { requireTwilioSignature } from './twilio-signature.middleware';
+import { SMS_INBOUND_WEBHOOK_URL, SMS_STATUS_WEBHOOK_URL } from '@scholaracle/contracts';
+import { handleInboundSms, handleStatusCallback } from './twilio-webhook.handlers';
+import { requireRelayInboundSignature } from './relay-signature.middleware';
export interface ITwilioWebhookRouterConfig {
readonly database: Db;
- readonly twilioAuthToken?: string;
+ readonly relayInboundSecret?: string;
+ readonly smsWebhookPublicUrl?: string;
+ readonly statusWebhookPublicUrl?: string;
+}
+
+function captureRawUrlencoded(): express.RequestHandler {
+ return express.urlencoded({
+ extended: false,
+ verify: (req, _res, buf) => {
+ (req as unknown as { rawBody: string }).rawBody = buf.toString('utf8');
+ },
+ });
}
/**
- * Twilio webhook router.
- * Mounts at /api/webhooks/twilio — receives inbound SMS and delivery status callbacks.
+ * Twilio webhook router (relay-forwarded inbound SMS + status).
*/
export function twilioWebhookRouter(config: ITwilioWebhookRouterConfig): Router {
const router = Router();
- const authToken = config.twilioAuthToken ?? process.env['TWILIO_AUTH_TOKEN'] ?? '';
- const nodeEnv = process.env['NODE_ENV'] ?? 'development';
-
- if (nodeEnv === 'production' && authToken) {
- router.use(requireTwilioSignature(authToken));
- }
+ const secret = config.relayInboundSecret ?? process.env['RELAY_INBOUND_SECRET'] ?? '';
+ const smsUrl = config.smsWebhookPublicUrl ?? SMS_INBOUND_WEBHOOK_URL;
+ const statusUrl = config.statusWebhookPublicUrl ?? SMS_STATUS_WEBHOOK_URL;
- router.post('/sms', handleInboundSms(config.database));
- router.post('/status', handleStatusCallback(config.database));
+ router.post(
+ '/sms',
+ captureRawUrlencoded(),
+ requireRelayInboundSignature({ publicUrl: smsUrl, secret }),
+ handleInboundSms(config.database)
+ );
+ router.post(
+ '/status',
+ captureRawUrlencoded(),
+ requireRelayInboundSignature({ publicUrl: statusUrl, secret }),
+ handleStatusCallback(config.database)
+ );
return router;
}
diff --git a/packages/api/src/routes/webhooks/twilio/twilio-webhook.test.ts b/packages/api/src/routes/webhooks/twilio/twilio-webhook.test.ts
index a89cddb..93c381c 100644
--- a/packages/api/src/routes/webhooks/twilio/twilio-webhook.test.ts
+++ b/packages/api/src/routes/webhooks/twilio/twilio-webhook.test.ts
@@ -1,157 +1,218 @@
import request from 'supertest';
import express, { type Express } from 'express';
import { MongoClient, type Db } from 'mongodb';
+import { MongoMemoryServer } from 'mongodb-memory-server';
+import querystring from 'node:querystring';
+import {
+ SCHOLARMANCY_SMS_PURPOSE,
+ SMS_INBOUND_WEBHOOK_URL,
+ SMS_STATUS_WEBHOOK_URL,
+} from '@scholaracle/contracts';
import { twilioWebhookRouter } from './twilio-webhook.router';
-
-describe('Twilio Webhooks', () => {
+import { signRelayInboundBody } from '../../../services/sms/verifyRelayInboundSignature';
+import { SmsConsentRepository } from '@scholaracle/database';
+
+const RELAY_SECRET = 'test-relay-inbound-secret';
+
+function signedPost(
+ app: Express,
+ path: string,
+ publicUrl: string,
+ fields: Record
+): request.Test {
+ const rawBody = querystring.stringify(fields);
+ const sig = signRelayInboundBody(publicUrl, rawBody, RELAY_SECRET);
+ return request(app)
+ .post(path)
+ .set('Content-Type', 'application/x-www-form-urlencoded')
+ .set('x-relay-signature', sig)
+ .send(rawBody);
+}
+
+describe('Twilio Webhooks (relay)', () => {
let app: Express;
let client: MongoClient;
let database: Db;
+ let mongoServer: MongoMemoryServer;
beforeAll(async () => {
- const uri = process.env['MONGODB_URI'] ?? 'mongodb://localhost:27017';
- client = new MongoClient(uri);
+ mongoServer = await MongoMemoryServer.create();
+ client = new MongoClient(mongoServer.getUri());
await client.connect();
database = client.db('scholaracle_test');
+ process.env['RELAY_INBOUND_SECRET'] = RELAY_SECRET;
+
app = express();
- app.use(express.json());
- app.use(express.urlencoded({ extended: true }));
- app.use('/api/webhooks/twilio', twilioWebhookRouter({ database, twilioAuthToken: '' }));
+ app.use(
+ '/api/webhooks/twilio',
+ twilioWebhookRouter({
+ database,
+ relayInboundSecret: RELAY_SECRET,
+ })
+ );
});
afterAll(async () => {
+ delete process.env['RELAY_INBOUND_SECRET'];
await client.close();
+ await mongoServer.stop();
});
beforeEach(async () => {
await database.collection('communication_logs').deleteMany({});
- await database.collection('audit_logs').deleteMany({});
+ await database.collection('sms_consents').deleteMany({});
});
- describe('POST /status', () => {
- it('returns 200 with valid status payload', async () => {
- await database.collection('communication_logs').insertOne({
- userId: 'u1',
- channel: 'sms',
- type: 'notification',
- subject: 'Test',
- content: 'Hello',
- recipientPhone: '+15005550001',
- status: 'sent',
- providerId: 'SM_TEST_123',
- triggeredBy: 'system',
- createdAt: new Date(),
- });
-
+ describe('signature', () => {
+ it('rejects missing signature with 401', async () => {
const res = await request(app)
- .post('/api/webhooks/twilio/status')
- .send({ MessageSid: 'SM_TEST_123', MessageStatus: 'delivered' });
-
- expect(res.status).toBe(200);
- expect(res.body.success).toBe(true);
+ .post('/api/webhooks/twilio/sms')
+ .send({ Body: 'STOP', From: '+15005550006' });
+ expect(res.status).toBe(401);
});
- it('returns 400 when MessageSid is missing', async () => {
+ it('rejects invalid signature with 401', async () => {
+ const rawBody = querystring.stringify({ From: '+15005550006', Body: 'STOP' });
const res = await request(app)
- .post('/api/webhooks/twilio/status')
- .send({ MessageStatus: 'delivered' });
-
- expect(res.status).toBe(400);
- expect(res.body.error).toBe('MessageSid and MessageStatus are required');
+ .post('/api/webhooks/twilio/sms')
+ .set('Content-Type', 'application/x-www-form-urlencoded')
+ .set('x-relay-signature', 'not-valid-base64-sig')
+ .send(rawBody);
+ expect(res.status).toBe(401);
});
- it('returns 400 when MessageStatus is missing', async () => {
+ it('rejects tampered body with 401', async () => {
+ const rawBody = querystring.stringify({ From: '+15005550006', Body: 'STOP' });
+ const sig = signRelayInboundBody(SMS_INBOUND_WEBHOOK_URL, rawBody, RELAY_SECRET);
const res = await request(app)
- .post('/api/webhooks/twilio/status')
- .send({ MessageSid: 'SM_TEST_123' });
-
- expect(res.status).toBe(400);
- expect(res.body.error).toBe('MessageSid and MessageStatus are required');
- });
-
- it('returns 200 with empty body (both fields missing)', async () => {
- const res = await request(app).post('/api/webhooks/twilio/status').send({});
-
- expect(res.status).toBe(400);
- expect(res.body.error).toBe('MessageSid and MessageStatus are required');
+ .post('/api/webhooks/twilio/sms')
+ .set('Content-Type', 'application/x-www-form-urlencoded')
+ .set('x-relay-signature', sig)
+ .send(`${rawBody}&tampered=1`);
+ expect(res.status).toBe(401);
});
+ });
- it('returns 200 for unknown Twilio status (no internal mapping)', async () => {
- const res = await request(app)
- .post('/api/webhooks/twilio/status')
- .send({ MessageSid: 'SM_UNKNOWN_789', MessageStatus: 'accepted' });
-
+ describe('POST /sms', () => {
+ it('records opt-out for STOP with empty TwiML', async () => {
+ const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, {
+ From: '+15005550006',
+ Body: 'STOP',
+ });
expect(res.status).toBe(200);
- expect(res.body.success).toBe(true);
+ expect(res.text).toBe(' ');
+ const repo = new SmsConsentRepository(database);
+ const row = await repo.findByPhoneAndPurpose('+15005550006', SCHOLARMANCY_SMS_PURPOSE);
+ expect(row?.revokedAt).toBeTruthy();
});
- });
- describe('POST /sms (inbound)', () => {
- it('returns 200 with TwiML for a normal inbound message', async () => {
- const res = await request(app).post('/api/webhooks/twilio/sms').send({
- MessageSid: 'SM_INBOUND_001',
+ it('clears opt-out on START', async () => {
+ const repo = new SmsConsentRepository(database);
+ await repo.recordOptOut('+15005550006', SCHOLARMANCY_SMS_PURPOSE);
+ const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, {
From: '+15005550006',
- To: '+18449003903',
- Body: 'Hello there',
+ Body: 'START',
});
-
expect(res.status).toBe(200);
- expect(res.headers['content-type']).toMatch(/text\/xml/);
expect(res.text).toBe(' ');
+ const row = await repo.findByPhoneAndPurpose('+15005550006', SCHOLARMANCY_SMS_PURPOSE);
+ expect(row?.revokedAt).toBeUndefined();
});
- it('returns opt-out TwiML for STOP keyword', async () => {
- const res = await request(app).post('/api/webhooks/twilio/sms').send({
- MessageSid: 'SM_INBOUND_STOP',
+ it('replies with HELP TwiML', async () => {
+ const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, {
From: '+15005550006',
- To: '+18449003903',
- Body: 'STOP',
+ Body: 'HELP',
});
-
expect(res.status).toBe(200);
- expect(res.text).toContain('unsubscribed');
-
- const auditLog = await database
- .collection('audit_logs')
- .findOne({ entityType: 'sms_opt_out' });
- expect(auditLog).toBeTruthy();
- expect(auditLog?.['entityId']).toBe('+15005550006');
+ expect(res.text).toContain('Scholarmancy');
+ expect(res.text).toContain('support@scholarmancy.com');
});
- it('returns opt-in TwiML for START keyword', async () => {
- const res = await request(app).post('/api/webhooks/twilio/sms').send({
- MessageSid: 'SM_INBOUND_START',
+ it('records consent on YES', async () => {
+ const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, {
From: '+15005550006',
- To: '+18449003903',
- Body: 'start',
+ Body: 'YES',
});
-
expect(res.status).toBe(200);
- expect(res.text).toContain('re-subscribed');
-
- const auditLog = await database
- .collection('audit_logs')
- .findOne({ entityType: 'sms_opt_in' });
- expect(auditLog).toBeTruthy();
- expect(auditLog?.['entityId']).toBe('+15005550006');
+ const repo = new SmsConsentRepository(database);
+ expect(await repo.hasActiveConsent('+15005550006', SCHOLARMANCY_SMS_PURPOSE)).toBe(true);
});
- it('handles empty body gracefully', async () => {
- const res = await request(app).post('/api/webhooks/twilio/sms').send({});
+ it('handles OptOutType=STOP', async () => {
+ const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, {
+ From: '+15005550007',
+ Body: '',
+ OptOutType: 'STOP',
+ });
+ expect(res.status).toBe(200);
+ const repo = new SmsConsentRepository(database);
+ const row = await repo.findByPhoneAndPurpose('+15005550007', SCHOLARMANCY_SMS_PURPOSE);
+ expect(row?.revokedAt).toBeTruthy();
+ });
+ it('clears opt-out on OptOutType=START', async () => {
+ const repo = new SmsConsentRepository(database);
+ await repo.recordOptOut('+15005550009', SCHOLARMANCY_SMS_PURPOSE);
+ const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, {
+ From: '+15005550009',
+ Body: '',
+ OptOutType: 'START',
+ });
expect(res.status).toBe(200);
- expect(res.headers['content-type']).toMatch(/text\/xml/);
expect(res.text).toBe(' ');
+ const row = await repo.findByPhoneAndPurpose('+15005550009', SCHOLARMANCY_SMS_PURPOSE);
+ expect(row?.revokedAt).toBeUndefined();
});
- it('handles missing Body field gracefully', async () => {
- const res = await request(app)
- .post('/api/webhooks/twilio/sms')
- .send({ MessageSid: 'SM_NO_BODY', From: '+15005550006' });
+ it('replies with HELP TwiML for OptOutType=HELP', async () => {
+ const res = await signedPost(app, '/api/webhooks/twilio/sms', SMS_INBOUND_WEBHOOK_URL, {
+ From: '+15005550010',
+ Body: '',
+ OptOutType: 'HELP',
+ });
+ expect(res.status).toBe(200);
+ expect(res.text).toContain('Scholarmancy');
+ expect(res.text).toContain('support@scholarmancy.com');
+ });
+ });
+
+ describe('POST /status', () => {
+ it('returns 200 with valid status payload', async () => {
+ await database.collection('communication_logs').insertOne({
+ userId: 'u1',
+ channel: 'sms',
+ type: 'notification',
+ subject: 'Test',
+ content: 'Hello',
+ recipientPhone: '+15005550001',
+ status: 'sent',
+ providerId: 'SM_TEST_123',
+ triggeredBy: 'system',
+ createdAt: new Date(),
+ });
+
+ const res = await signedPost(app, '/api/webhooks/twilio/status', SMS_STATUS_WEBHOOK_URL, {
+ MessageSid: 'SM_TEST_123',
+ MessageStatus: 'delivered',
+ });
expect(res.status).toBe(200);
- expect(res.text).toBe(' ');
+ expect(res.body.success).toBe(true);
+ });
+
+ it('records opt-out when ErrorCode is 21610', async () => {
+ const res = await signedPost(app, '/api/webhooks/twilio/status', SMS_STATUS_WEBHOOK_URL, {
+ MessageSid: 'SM_OPT',
+ MessageStatus: 'failed',
+ To: '+15005550008',
+ ErrorCode: '21610',
+ });
+ expect(res.status).toBe(200);
+ const repo = new SmsConsentRepository(database);
+ const row = await repo.findByPhoneAndPurpose('+15005550008', SCHOLARMANCY_SMS_PURPOSE);
+ expect(row?.revokedAt).toBeTruthy();
});
});
});
diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts
index 364095c..9ce142c 100644
--- a/packages/api/src/server.ts
+++ b/packages/api/src/server.ts
@@ -86,14 +86,12 @@ import {
SmtpTransport,
MongoQueue,
} from '@scholaracle/agents';
-import { SMSDelivery, applyTwilioApiBaseUrl } from '@scholaracle/agents';
import type { INotificationDelivery } from '@scholaracle/interfaces';
import type { MailService } from '@sendgrid/mail';
-import type { Twilio } from 'twilio';
import type { IEmailTransport } from '@scholaracle/agents';
import sgMail from '@sendgrid/mail';
-import twilio from 'twilio';
import nodemailer from 'nodemailer';
+import { createSmsStack } from './services/sms/createSmsStack';
export interface IServerConfig {
readonly port?: number;
@@ -104,12 +102,6 @@ export interface IServerConfig {
readonly sendGridApiKey?: string;
readonly sendGridFromEmail?: string;
readonly sendGridFromName?: string;
- readonly twilioAccountSid?: string;
- readonly twilioAuthToken?: string;
- readonly twilioApiKeySid?: string;
- readonly twilioApiKeySecret?: string;
- readonly twilioFromNumber?: string;
- readonly twilioMessagingServiceSid?: string;
readonly relayUrl?: string;
readonly relayApiKey?: string;
readonly relayWebhookSecret?: string;
@@ -138,48 +130,24 @@ function getSendGridConfig(config: IServerConfig): {
};
}
-/**
- * Get Twilio configuration from config or environment.
- *
- * @param config - Server configuration
- * @returns Twilio configuration
- */
-function getTwilioConfig(config: IServerConfig): {
- readonly accountSid: string;
- readonly authToken: string;
- readonly apiKeySid: string;
- readonly apiKeySecret: string;
- readonly fromNumber: string;
- readonly messagingServiceSid: string;
-} {
- return {
- accountSid: config.twilioAccountSid ?? process.env['TWILIO_ACCOUNT_SID'] ?? '',
- authToken: config.twilioAuthToken ?? process.env['TWILIO_AUTH_TOKEN'] ?? '',
- apiKeySid: config.twilioApiKeySid ?? process.env['TWILIO_API_KEY_SID'] ?? '',
- apiKeySecret: config.twilioApiKeySecret ?? process.env['TWILIO_API_KEY_SECRET'] ?? '',
- fromNumber: config.twilioFromNumber ?? process.env['TWILIO_FROM_NUMBER'] ?? '',
- messagingServiceSid:
- config.twilioMessagingServiceSid ?? process.env['TWILIO_MESSAGING_SERVICE_SID'] ?? '',
- };
-}
-
/**
* Initialize notification service with delivery services.
*
* @param config - Server configuration
* @returns Notification service and email infrastructure
*/
-function initializeNotificationService(config: IServerConfig): {
+function initializeNotificationService(
+ config: IServerConfig,
+ database?: Db
+): {
notificationService: NotificationService;
emailTransport: IEmailTransport;
fromEmail: string;
fromName: string;
- twilioClient: import('twilio').Twilio | null;
- twilioFromNumber: string;
- twilioMessagingServiceSid: string;
+ guardedSmsSender: import('@scholaracle/agents').GuardedSmsSender | null;
} {
const sendGridConfig = getSendGridConfig(config);
- const twilioConfig = getTwilioConfig(config);
+ const smsStack = database ? createSmsStack(database) : null;
const smtpHost = process.env['SMTP_HOST'];
const transport: IEmailTransport = smtpHost
@@ -211,36 +179,9 @@ function initializeNotificationService(config: IServerConfig): {
},
transport
);
- const hasApiKeyAuth = Boolean(
- twilioConfig.accountSid && twilioConfig.apiKeySid && twilioConfig.apiKeySecret
- );
- const hasAuthTokenAuth = Boolean(twilioConfig.accountSid && twilioConfig.authToken);
- const twilioConfigured =
- (hasApiKeyAuth || hasAuthTokenAuth) &&
- Boolean(twilioConfig.fromNumber || twilioConfig.messagingServiceSid);
- const twilioClient = twilioConfigured
- ? applyTwilioApiBaseUrl(
- hasApiKeyAuth
- ? twilio(twilioConfig.apiKeySid, twilioConfig.apiKeySecret, {
- accountSid: twilioConfig.accountSid,
- })
- : twilio(twilioConfig.accountSid, twilioConfig.authToken),
- process.env['TWILIO_API_BASE_URL']
- )
- : ({} as unknown as Twilio);
- const smsDelivery = new SMSDelivery(
- {
- accountSid: twilioConfig.accountSid,
- authToken: twilioConfig.authToken,
- fromNumber: twilioConfig.fromNumber,
- messagingServiceSid: twilioConfig.messagingServiceSid,
- },
- twilioClient
- );
-
const deliveryServices: readonly INotificationDelivery[] = [
emailDelivery,
- ...(twilioConfigured ? [smsDelivery] : []),
+ ...(smsStack ? [smsStack.smsDelivery] : []),
// Push and InApp are optional; omit when not configured to avoid delivery errors.
];
const deliveryRouter = new DeliveryRouter(deliveryServices);
@@ -253,9 +194,7 @@ function initializeNotificationService(config: IServerConfig): {
emailTransport: transport,
fromEmail: sendGridConfig.fromEmail,
fromName: sendGridConfig.fromName,
- twilioClient: twilioConfigured ? twilioClient : null,
- twilioFromNumber: twilioConfig.fromNumber,
- twilioMessagingServiceSid: twilioConfig.messagingServiceSid,
+ guardedSmsSender: smsStack?.guardedSender ?? null,
};
}
@@ -331,7 +270,7 @@ export function createApp(config: IServerConfig = {}, database?: Db): Express {
// express.raw() ever sees it (body-parser skips once req._body is set).
const jsonParser = express.json({ limit: '10mb' });
app.use((req: Request, res: Response, next: NextFunction) => {
- if (req.path === '/api/webhooks/noctusoft') {
+ if (req.path === '/api/webhooks/noctusoft' || req.path.startsWith('/api/webhooks/twilio')) {
next();
return;
}
@@ -348,7 +287,7 @@ export function createApp(config: IServerConfig = {}, database?: Db): Express {
throw new Error('JWT_SECRET environment variable is required in production');
}
- const notificationInit = initializeNotificationService(config);
+ const notificationInit = initializeNotificationService(config, database);
const { notificationService, emailTransport, fromEmail, fromName } = notificationInit;
app.use('/api/health', healthRouter);
@@ -437,15 +376,12 @@ export function createApp(config: IServerConfig = {}, database?: Db): Express {
emailTransport: magicEmailTransport,
fromEmail: magicFromEmail,
fromName: magicFromName,
- twilioClient: magicTwilioClient,
- twilioFromNumber,
- twilioMessagingServiceSid,
+ guardedSmsSender,
} = notificationInit;
const magicLinkSender = new MagicLinkSender(
magicEmailTransport,
{ fromEmail: magicFromEmail, fromName: magicFromName },
- magicTwilioClient,
- { fromNumber: twilioFromNumber, messagingServiceSid: twilioMessagingServiceSid }
+ guardedSmsSender
);
app.use(
@@ -459,6 +395,7 @@ export function createApp(config: IServerConfig = {}, database?: Db): Express {
sendInviteEmail: inviteEmailSender,
syncScheduler,
magicLinkSender,
+ guardedSmsSender,
})
);
app.use(
@@ -699,8 +636,7 @@ export function createApp(config: IServerConfig = {}, database?: Db): Express {
app.use('/api/webhooks/communications', communicationsWebhooksRouter({ database }));
// Twilio webhooks (inbound SMS, delivery status callbacks)
- const twilioAuthToken = config.twilioAuthToken ?? process.env['TWILIO_AUTH_TOKEN'] ?? '';
- app.use('/api/webhooks/twilio', twilioWebhookRouter({ database, twilioAuthToken }));
+ app.use('/api/webhooks/twilio', twilioWebhookRouter({ database }));
if (storeClient && storeConfig) {
app.use(
diff --git a/packages/api/src/services/provision/MagicLinkSender.ts b/packages/api/src/services/provision/MagicLinkSender.ts
index 5575599..380761a 100644
--- a/packages/api/src/services/provision/MagicLinkSender.ts
+++ b/packages/api/src/services/provision/MagicLinkSender.ts
@@ -1,16 +1,12 @@
import type { IEmailTransport } from '@scholaracle/agents';
-import type { Twilio } from 'twilio';
+import type { GuardedSmsSender } from '@scholaracle/agents';
+import { SCHOLARMANCY_SMS_BRAND } from '@scholaracle/contracts';
export interface IMagicLinkEmailConfig {
readonly fromEmail: string;
readonly fromName: string;
}
-export interface IMagicLinkSMSConfig {
- readonly fromNumber?: string;
- readonly messagingServiceSid?: string;
-}
-
export interface ISendMagicLinkParams {
readonly to: string;
readonly loginUrl: string;
@@ -23,16 +19,13 @@ export interface IMagicLinkSender {
}
/**
- * Sends one-time magic login links via email or SMS.
- * Email uses IEmailTransport (SendGrid or SMTP/Mailpit in dev).
- * SMS uses the Twilio client directly.
+ * Sends one-time magic login links via email or consent-gated SMS.
*/
export class MagicLinkSender implements IMagicLinkSender {
constructor(
private readonly _transport: IEmailTransport,
private readonly _emailConfig: IMagicLinkEmailConfig,
- private readonly _twilioClient: Twilio | null,
- private readonly _smsConfig: IMagicLinkSMSConfig
+ private readonly _guardedSms: GuardedSmsSender | null
) {}
public async sendEmail(params: ISendMagicLinkParams): Promise {
@@ -41,7 +34,7 @@ export class MagicLinkSender implements IMagicLinkSender {
const text = [
greeting,
'',
- 'You have been sent a one-time sign-in link for Scholaracle.',
+ `You have been sent a one-time sign-in link for ${SCHOLARMANCY_SMS_BRAND}.`,
'Click the link below to log in (expires in 24 hours):',
'',
loginUrl,
@@ -51,9 +44,9 @@ export class MagicLinkSender implements IMagicLinkSender {
const html = [
`${greeting}
`,
- 'You have been sent a one-time sign-in link for Scholaracle. ',
+ `
You have been sent a one-time sign-in link for ${SCHOLARMANCY_SMS_BRAND}. `,
'Click the button below to log in (expires in 24 hours):
',
- `Sign in to Scholaracle
`,
+ `Sign in to ${SCHOLARMANCY_SMS_BRAND}
`,
`Or copy this link: ${loginUrl}
`,
'If you did not expect this email, you can ignore it.
',
].join('');
@@ -61,24 +54,21 @@ export class MagicLinkSender implements IMagicLinkSender {
await this._transport.send({
to,
from: { email: this._emailConfig.fromEmail, name: this._emailConfig.fromName },
- subject: 'Your Scholaracle sign-in link',
+ subject: `Your ${SCHOLARMANCY_SMS_BRAND} sign-in link`,
text,
html,
});
}
public async sendSms(params: ISendMagicLinkParams): Promise {
- if (!this._twilioClient) {
+ if (!this._guardedSms) {
throw new Error('SMS delivery is not configured');
}
const { to, loginUrl } = params;
- const body = `Your Scholaracle sign-in link (expires in 24h): ${loginUrl}`;
- await this._twilioClient.messages.create({
- to,
- ...(this._smsConfig.messagingServiceSid
- ? { messagingServiceSid: this._smsConfig.messagingServiceSid }
- : { from: this._smsConfig.fromNumber ?? '' }),
- body,
+ const body = `Your sign-in link (expires in 24h): ${loginUrl}`;
+ await this._guardedSms.sendTransactional(to, body, {
+ templateName: 'magic_link',
+ triggeredBy: 'system',
});
}
}
diff --git a/packages/api/src/services/sms/createSmsStack.ts b/packages/api/src/services/sms/createSmsStack.ts
new file mode 100644
index 0000000..c83029f
--- /dev/null
+++ b/packages/api/src/services/sms/createSmsStack.ts
@@ -0,0 +1 @@
+export { createSmsStack, type ISmsStack } from '@scholaracle/agents';
diff --git a/packages/api/src/services/sms/recordSmsOptIn.ts b/packages/api/src/services/sms/recordSmsOptIn.ts
new file mode 100644
index 0000000..5303ad0
--- /dev/null
+++ b/packages/api/src/services/sms/recordSmsOptIn.ts
@@ -0,0 +1,26 @@
+import type { Request } from 'express';
+import { SMS_OPT_IN_TEXT_VERSION, SCHOLARMANCY_SMS_PURPOSE } from '@scholaracle/contracts';
+import { normalizePhoneE164 } from '@scholaracle/agents';
+import type { SmsConsentRepository } from '@scholaracle/database';
+
+/** Persists checkbox opt-in when user supplied phone + explicit consent. */
+export async function recordSmsOptInFromRequest(
+ consentRepo: SmsConsentRepository,
+ req: Request,
+ phone: string,
+ source: string
+): Promise {
+ const phoneE164 = normalizePhoneE164(phone);
+ if (!phoneE164) {
+ throw new Error('Invalid phone number');
+ }
+ await consentRepo.recordOptIn({
+ phoneE164,
+ purpose: SCHOLARMANCY_SMS_PURPOSE,
+ consentTextVersion: SMS_OPT_IN_TEXT_VERSION,
+ source,
+ ipAddress: req.ip ?? undefined,
+ userAgent: req.headers['user-agent'] ?? undefined,
+ });
+ return phoneE164;
+}
diff --git a/packages/api/src/services/sms/verifyRelayInboundSignature.test.ts b/packages/api/src/services/sms/verifyRelayInboundSignature.test.ts
new file mode 100644
index 0000000..32c8969
--- /dev/null
+++ b/packages/api/src/services/sms/verifyRelayInboundSignature.test.ts
@@ -0,0 +1,24 @@
+import { signRelayInboundBody, verifyRelayInboundSignature } from './verifyRelayInboundSignature';
+
+const URL = 'https://api.scholarmancy.com/api/webhooks/twilio/sms';
+const SECRET = 'test-relay-secret';
+
+describe('verifyRelayInboundSignature', () => {
+ it('accepts valid signature', () => {
+ const raw = 'From=%2B15125550100&Body=STOP';
+ const sig = signRelayInboundBody(URL, raw, SECRET);
+ expect(verifyRelayInboundSignature(URL, raw, sig, SECRET)).toBe(true);
+ });
+
+ it('rejects tampered body', () => {
+ const raw = 'From=%2B15125550100&Body=STOP';
+ const sig = signRelayInboundBody(URL, raw, SECRET);
+ expect(verifyRelayInboundSignature(URL, `${raw}x`, sig, SECRET)).toBe(false);
+ });
+
+ it('rejects wrong public URL', () => {
+ const raw = 'From=%2B15125550100&Body=STOP';
+ const sig = signRelayInboundBody(URL, raw, SECRET);
+ expect(verifyRelayInboundSignature(`${URL}/extra`, raw, sig, SECRET)).toBe(false);
+ });
+});
diff --git a/packages/api/src/services/sms/verifyRelayInboundSignature.ts b/packages/api/src/services/sms/verifyRelayInboundSignature.ts
new file mode 100644
index 0000000..dd8b939
--- /dev/null
+++ b/packages/api/src/services/sms/verifyRelayInboundSignature.ts
@@ -0,0 +1,31 @@
+import { createHmac, timingSafeEqual } from 'node:crypto';
+
+/** Relay inbound: base64(HMAC-SHA256(secret, publicUrl + rawBody)). */
+export function verifyRelayInboundSignature(
+ publicUrl: string,
+ rawBody: string,
+ signatureHeader: string,
+ secret: string
+): boolean {
+ const provided = signatureHeader.trim();
+ if (!provided || !secret) {
+ return false;
+ }
+ const expected = createHmac('sha256', secret)
+ .update(publicUrl + rawBody, 'utf8')
+ .digest('base64');
+ if (provided.length !== expected.length) {
+ return false;
+ }
+ try {
+ return timingSafeEqual(Buffer.from(provided, 'utf8'), Buffer.from(expected, 'utf8'));
+ } catch {
+ return false;
+ }
+}
+
+export function signRelayInboundBody(publicUrl: string, rawBody: string, secret: string): string {
+ return createHmac('sha256', secret)
+ .update(publicUrl + rawBody, 'utf8')
+ .digest('base64');
+}
diff --git a/packages/contracts/src/types/index.ts b/packages/contracts/src/types/index.ts
index f1c7c94..20ee693 100644
--- a/packages/contracts/src/types/index.ts
+++ b/packages/contracts/src/types/index.ts
@@ -1,4 +1,5 @@
export type { SyncSchedule, NotificationTone, NotificationFrequency } from './ScheduleFrequency';
export type { IStudentAlertPreferences } from './StudentAlertPreferences';
export type { IStudentContact, IAlertRecipientResolved } from './StudentContact';
+export * from './smsCompliance';
export * from './api';
diff --git a/packages/contracts/src/types/smsCompliance.test.ts b/packages/contracts/src/types/smsCompliance.test.ts
new file mode 100644
index 0000000..f111c35
--- /dev/null
+++ b/packages/contracts/src/types/smsCompliance.test.ts
@@ -0,0 +1,14 @@
+import {
+ SCHOLARMANCY_SMS_BRAND,
+ SCHOLARMANCY_SMS_PURPOSE,
+ buildSmsOptInLabelHtml,
+} from './smsCompliance';
+
+describe('smsCompliance constants', () => {
+ it('uses Scholarmancy brand and purpose in opt-in label', () => {
+ const label = buildSmsOptInLabelHtml();
+ expect(label).toContain(SCHOLARMANCY_SMS_BRAND);
+ expect(label).toContain(SCHOLARMANCY_SMS_PURPOSE);
+ expect(label).toContain('Message frequency varies');
+ });
+});
diff --git a/packages/contracts/src/types/smsCompliance.ts b/packages/contracts/src/types/smsCompliance.ts
new file mode 100644
index 0000000..8879892
--- /dev/null
+++ b/packages/contracts/src/types/smsCompliance.ts
@@ -0,0 +1,43 @@
+/** Scholarmancy SMS program purpose (toll-free verification). */
+export const SCHOLARMANCY_SMS_BRAND = 'Scholarmancy';
+
+export const SCHOLARMANCY_SMS_PURPOSE = 'grade and assignment alerts and sign-in links';
+
+export const SCHOLARMANCY_SMS_HELP_LINE = 'Scholarmancy: grade and assignment alerts for parents.';
+
+export const SCHOLARMANCY_SMS_SUPPORT_EMAIL = 'support@scholarmancy.com';
+
+/** Bumped when opt-in checkbox copy changes. */
+export const SMS_OPT_IN_TEXT_VERSION = '2026-10-01';
+
+export const SMS_RELAY_SEND_URL = 'https://api.twilio.noctusoft.com/sms/send';
+
+export const SMS_INBOUND_WEBHOOK_URL = 'https://api.scholarmancy.com/api/webhooks/twilio/sms';
+
+export const SMS_STATUS_WEBHOOK_URL = 'https://api.scholarmancy.com/api/webhooks/twilio/status';
+
+export const TWILIO_OPT_OUT_ERROR_CODE = 21610;
+
+/** Builds the standard web opt-in checkbox label (markdown links for UI). */
+export function buildSmsOptInLabelHtml(): string {
+ return (
+ `Text me ${SCHOLARMANCY_SMS_PURPOSE} from ${SCHOLARMANCY_SMS_BRAND}. ` +
+ 'Message frequency varies. Message and data rates may apply. ' +
+ 'Reply STOP to opt out, HELP for help. Consent is not a condition of purchase. ' +
+ 'See our SMS Terms and Privacy Policy.'
+ );
+}
+
+export interface ISmsConsentRecord {
+ readonly phoneE164: string;
+ readonly purpose: string;
+ readonly consentTextVersion: string;
+ readonly source: string;
+ readonly ipAddress?: string;
+ readonly userAgent?: string;
+ readonly consentedAt?: Date;
+ readonly revokedAt?: Date;
+ readonly confirmationSentAt?: Date;
+ readonly createdAt?: Date;
+ readonly updatedAt?: Date;
+}
diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts
index 152cf23..4a4167d 100644
--- a/packages/database/src/index.ts
+++ b/packages/database/src/index.ts
@@ -40,6 +40,7 @@ export * from './repositories/SmsDigestPendingRepository';
export * from './repositories/EmailDigestPendingRepository';
export * from './repositories/AiUsageRepository';
export * from './repositories/WebhookEventRepository';
+export * from './repositories/SmsConsentRepository';
// Connector / ingestion repositories
export * from './repositories/IngestDeviceAuthRepository';
diff --git a/packages/database/src/indexes.ts b/packages/database/src/indexes.ts
index 154357b..4124cfb 100644
--- a/packages/database/src/indexes.ts
+++ b/packages/database/src/indexes.ts
@@ -68,6 +68,10 @@ export async function createIndexes(database: Db): Promise {
const communicationLogsCollection = database.collection('communication_logs');
await communicationLogsCollection.createIndex({ userId: 1, createdAt: -1 });
await communicationLogsCollection.createIndex({ channel: 1, status: 1 });
+ await communicationLogsCollection.createIndex({ providerId: 1 });
+
+ const smsConsents = database.collection('sms_consents');
+ await smsConsents.createIndex({ phoneE164: 1, purpose: 1 }, { unique: true });
// Alerts collection indexes (if not already created)
const alertsCollection = database.collection('alerts');
diff --git a/packages/database/src/models/SmsConsent/SmsConsent.ts b/packages/database/src/models/SmsConsent/SmsConsent.ts
new file mode 100644
index 0000000..8e4845a
--- /dev/null
+++ b/packages/database/src/models/SmsConsent/SmsConsent.ts
@@ -0,0 +1,3 @@
+import type { ISmsConsentRecord } from '@scholaracle/contracts';
+
+export type { ISmsConsentRecord };
diff --git a/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.test.ts b/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.test.ts
new file mode 100644
index 0000000..e174eec
--- /dev/null
+++ b/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.test.ts
@@ -0,0 +1,61 @@
+import { MongoClient, type Db } from 'mongodb';
+import { MongoMemoryServer } from 'mongodb-memory-server';
+import { SCHOLARMANCY_SMS_PURPOSE, SMS_OPT_IN_TEXT_VERSION } from '@scholaracle/contracts';
+import { SmsConsentRepository } from './SmsConsentRepository';
+
+describe('SmsConsentRepository', () => {
+ let mongoServer: MongoMemoryServer;
+ let client: MongoClient;
+ let database: Db;
+ let repo: SmsConsentRepository;
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ client = new MongoClient(mongoServer.getUri());
+ await client.connect();
+ database = client.db('sms_consent_test');
+ repo = new SmsConsentRepository(database);
+ await repo.ensureIndexes();
+ });
+
+ afterAll(async () => {
+ await client.close();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await database.collection('sms_consents').deleteMany({});
+ });
+
+ it('records opt-in and reports active consent', async () => {
+ await repo.recordOptIn({
+ phoneE164: '+15125550100',
+ purpose: SCHOLARMANCY_SMS_PURPOSE,
+ consentTextVersion: SMS_OPT_IN_TEXT_VERSION,
+ source: '/register',
+ ipAddress: '127.0.0.1',
+ userAgent: 'jest',
+ });
+ const isActive = await repo.hasActiveConsent('+15125550100', SCHOLARMANCY_SMS_PURPOSE);
+ expect(isActive).toBe(true);
+ });
+
+ it('records opt-out and clears active consent', async () => {
+ await repo.recordOptIn({
+ phoneE164: '+15125550100',
+ purpose: SCHOLARMANCY_SMS_PURPOSE,
+ consentTextVersion: SMS_OPT_IN_TEXT_VERSION,
+ source: '/register',
+ });
+ await repo.recordOptOut('+15125550100', SCHOLARMANCY_SMS_PURPOSE);
+ expect(await repo.hasActiveConsent('+15125550100', SCHOLARMANCY_SMS_PURPOSE)).toBe(false);
+ });
+
+ it('completeReplyYes grants consent for pending numbers', async () => {
+ await repo.markConfirmationSent('+15125550101', SCHOLARMANCY_SMS_PURPOSE);
+ await repo.completeReplyYes('+15125550101', SCHOLARMANCY_SMS_PURPOSE);
+ expect(await repo.hasActiveConsent('+15125550101', SCHOLARMANCY_SMS_PURPOSE)).toBe(true);
+ const row = await repo.findByPhoneAndPurpose('+15125550101', SCHOLARMANCY_SMS_PURPOSE);
+ expect(row?.source).toBe('reply-yes');
+ });
+});
diff --git a/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.ts b/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.ts
new file mode 100644
index 0000000..7f22278
--- /dev/null
+++ b/packages/database/src/repositories/SmsConsentRepository/SmsConsentRepository.ts
@@ -0,0 +1,143 @@
+import type { Collection, Db } from 'mongodb';
+import type { ISmsConsentRecord } from '@scholaracle/contracts';
+import type { ISmsConsentReader, ISmsConsentWriter } from '@scholaracle/interfaces';
+
+const COLLECTION = 'sms_consents';
+
+type ISmsConsentDoc = ISmsConsentRecord & { _id?: unknown };
+
+/**
+ * Persists SMS opt-in/opt-out per phone and purpose (toll-free compliance).
+ */
+export class SmsConsentRepository implements ISmsConsentReader, ISmsConsentWriter {
+ private readonly _collection: Collection;
+
+ constructor(database: Db) {
+ this._collection = database.collection(COLLECTION);
+ }
+
+ public async ensureIndexes(): Promise {
+ await this._collection.createIndex({ phoneE164: 1, purpose: 1 }, { unique: true });
+ }
+
+ public async findByPhoneAndPurpose(
+ phoneE164: string,
+ purpose: string
+ ): Promise {
+ const doc = await this._collection.findOne({ phoneE164, purpose });
+ return doc ? this._toRecord(doc) : null;
+ }
+
+ public async hasActiveConsent(phoneE164: string, purpose: string): Promise {
+ const doc = await this._collection.findOne({ phoneE164, purpose });
+ if (!doc?.consentedAt) {
+ return false;
+ }
+ if (doc.revokedAt) {
+ return false;
+ }
+ return true;
+ }
+
+ public async recordOptIn(params: {
+ phoneE164: string;
+ purpose: string;
+ consentTextVersion: string;
+ source: string;
+ ipAddress?: string;
+ userAgent?: string;
+ }): Promise {
+ const now = new Date();
+ await this._collection.updateOne(
+ { phoneE164: params.phoneE164, purpose: params.purpose },
+ {
+ $set: {
+ phoneE164: params.phoneE164,
+ purpose: params.purpose,
+ consentTextVersion: params.consentTextVersion,
+ source: params.source,
+ ipAddress: params.ipAddress,
+ userAgent: params.userAgent,
+ consentedAt: now,
+ updatedAt: now,
+ },
+ $unset: { revokedAt: '' },
+ $setOnInsert: { createdAt: now },
+ },
+ { upsert: true }
+ );
+ const doc = await this._collection.findOne({
+ phoneE164: params.phoneE164,
+ purpose: params.purpose,
+ });
+ if (!doc) {
+ throw new Error('Failed to persist SMS consent');
+ }
+ return this._toRecord(doc);
+ }
+
+ public async recordOptOut(phoneE164: string, purpose: string): Promise {
+ const now = new Date();
+ await this._collection.updateOne(
+ { phoneE164, purpose },
+ {
+ $set: { revokedAt: now, updatedAt: now },
+ $setOnInsert: { phoneE164, purpose, createdAt: now },
+ },
+ { upsert: true }
+ );
+ }
+
+ public async clearOptOut(phoneE164: string, purpose: string): Promise {
+ const now = new Date();
+ await this._collection.updateOne(
+ { phoneE164, purpose },
+ { $unset: { revokedAt: '' }, $set: { updatedAt: now } }
+ );
+ }
+
+ public async markConfirmationSent(phoneE164: string, purpose: string): Promise {
+ const now = new Date();
+ await this._collection.updateOne(
+ { phoneE164, purpose },
+ {
+ $set: { confirmationSentAt: now, updatedAt: now },
+ $setOnInsert: { phoneE164, purpose, createdAt: now },
+ },
+ { upsert: true }
+ );
+ }
+
+ public async completeReplyYes(phoneE164: string, purpose: string): Promise {
+ const now = new Date();
+ await this._collection.updateOne(
+ { phoneE164, purpose },
+ {
+ $set: {
+ consentedAt: now,
+ source: 'reply-yes',
+ updatedAt: now,
+ },
+ $unset: { revokedAt: '' },
+ $setOnInsert: { phoneE164, purpose, createdAt: now },
+ },
+ { upsert: true }
+ );
+ }
+
+ private _toRecord(doc: ISmsConsentDoc): ISmsConsentRecord {
+ return {
+ phoneE164: doc.phoneE164,
+ purpose: doc.purpose,
+ consentTextVersion: doc.consentTextVersion ?? '',
+ source: doc.source ?? '',
+ ipAddress: doc.ipAddress,
+ userAgent: doc.userAgent,
+ consentedAt: doc.consentedAt,
+ revokedAt: doc.revokedAt,
+ confirmationSentAt: doc.confirmationSentAt,
+ createdAt: doc.createdAt,
+ updatedAt: doc.updatedAt,
+ };
+ }
+}
diff --git a/packages/database/src/repositories/SmsConsentRepository/index.ts b/packages/database/src/repositories/SmsConsentRepository/index.ts
new file mode 100644
index 0000000..1361e15
--- /dev/null
+++ b/packages/database/src/repositories/SmsConsentRepository/index.ts
@@ -0,0 +1 @@
+export { SmsConsentRepository } from './SmsConsentRepository';
diff --git a/packages/interfaces/src/index.ts b/packages/interfaces/src/index.ts
index 0ce370c..534920b 100644
--- a/packages/interfaces/src/index.ts
+++ b/packages/interfaces/src/index.ts
@@ -4,3 +4,4 @@ export * from './IAlertService';
export * from './parent';
export * from './studio';
export * from './guidance';
+export * from './sms';
diff --git a/packages/interfaces/src/sms/ISmsConsentRepository.ts b/packages/interfaces/src/sms/ISmsConsentRepository.ts
new file mode 100644
index 0000000..62d3f9a
--- /dev/null
+++ b/packages/interfaces/src/sms/ISmsConsentRepository.ts
@@ -0,0 +1,23 @@
+import type { ISmsConsentRecord } from '@scholaracle/contracts';
+
+export interface ISmsConsentReader {
+ findByPhoneAndPurpose(phoneE164: string, purpose: string): Promise;
+ hasActiveConsent(phoneE164: string, purpose: string): Promise;
+}
+
+export interface ISmsConsentWriter {
+ recordOptIn(params: {
+ phoneE164: string;
+ purpose: string;
+ consentTextVersion: string;
+ source: string;
+ ipAddress?: string;
+ userAgent?: string;
+ }): Promise;
+ recordOptOut(phoneE164: string, purpose: string): Promise;
+ clearOptOut(phoneE164: string, purpose: string): Promise;
+ markConfirmationSent(phoneE164: string, purpose: string): Promise;
+ completeReplyYes(phoneE164: string, purpose: string): Promise;
+}
+
+export interface ISmsConsentRepository extends ISmsConsentReader, ISmsConsentWriter {}
diff --git a/packages/interfaces/src/sms/index.ts b/packages/interfaces/src/sms/index.ts
new file mode 100644
index 0000000..23186a3
--- /dev/null
+++ b/packages/interfaces/src/sms/index.ts
@@ -0,0 +1,5 @@
+export type {
+ ISmsConsentReader,
+ ISmsConsentWriter,
+ ISmsConsentRepository,
+} from './ISmsConsentRepository';
diff --git a/packages/web/app/dashboard/settings/page.tsx b/packages/web/app/dashboard/settings/page.tsx
index 8aa5468..a6dfb6e 100644
--- a/packages/web/app/dashboard/settings/page.tsx
+++ b/packages/web/app/dashboard/settings/page.tsx
@@ -11,6 +11,7 @@ import { Switch } from '@/components/ui/switch';
import { settingsApi, type IUserSettingsResponse, type INotificationHistoryItem, type IDigestSlotApi } from '@/lib/api/settings';
import { EditDigestSlotDialog } from '@/components/settings/EditDigestSlotDialog';
import { StudentLoginsSection } from '@/components/settings/StudentLoginsSection';
+import { SmsOptInCheckbox } from '@/components/legal/SmsOptInCheckbox';
const ALERT_TYPE_KEYS = [
'missing_assignment',
@@ -26,6 +27,7 @@ export default function SettingsPage() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
+ const [smsConsent, setSmsConsent] = useState(false);
const [pushNotifications, setPushNotifications] = useState(true);
const [emailNotifications, setEmailNotifications] = useState(true);
@@ -84,6 +86,8 @@ export default function SettingsPage() {
if (s.profile) {
setName(s.profile.name ?? '');
setEmail(s.profile.email ?? '');
+ setPhone(s.profile.phone ?? '');
+ setSmsConsent(s.profile.smsConsent ?? false);
setOauthProviders([...(s.profile.oauthProviders ?? [])]);
}
setPushNotifications(s.notifications.push);
@@ -143,6 +147,10 @@ export default function SettingsPage() {
const ok = await settingsApi.update({
timezone,
+ profile: {
+ phone,
+ smsConsent,
+ },
notifications: {
push: pushNotifications,
email: emailNotifications,
@@ -312,6 +320,12 @@ export default function SettingsPage() {
disabled={isSaving || !isLoaded}
/>
+
diff --git a/packages/web/app/privacy/page.test.ts b/packages/web/app/privacy/page.test.ts
index 1edcc59..4a1c83c 100644
--- a/packages/web/app/privacy/page.test.ts
+++ b/packages/web/app/privacy/page.test.ts
@@ -5,6 +5,13 @@ describe('App Store legal pages', () => {
const read = (relative: string): string =>
readFileSync(join(__dirname, '..', relative), 'utf8');
+ it('privacy policy includes required SMS no-sharing sentence', () => {
+ const src = read('privacy/page.tsx');
+ expect(src).toMatch(
+ /We do not share, sell, or provide your mobile phone number or SMS opt-in data to third parties or affiliates for marketing or promotional purposes/
+ );
+ });
+
it('privacy policy covers Sign in with Apple, OAuth, and in-app deletion', () => {
const src = read('privacy/page.tsx');
expect(src).toMatch(/Sign in with Apple/);
@@ -18,6 +25,8 @@ describe('App Store legal pages', () => {
const src = read('terms/page.tsx');
expect(src).toMatch(/iOS app/);
expect(src).toMatch(/delete-account/);
+ expect(src).toMatch(/id="sms"/);
+ expect(src).toMatch(/support@scholarmancy\.com/);
});
it('support page exposes a reachable support email', () => {
diff --git a/packages/web/app/privacy/page.tsx b/packages/web/app/privacy/page.tsx
index efdf667..3f591c0 100644
--- a/packages/web/app/privacy/page.tsx
+++ b/packages/web/app/privacy/page.tsx
@@ -78,12 +78,15 @@ export default function PrivacyPage() {
- 3. SMS Text Messaging
+ Text messages
- By opting in to SMS notifications, you consent to receive text messages from Scholarmancy at the
- phone number you provide. Message frequency varies based on your alert preferences. Standard
- message and data rates may apply. You can opt out at any time by replying STOP to any message,
- or by updating your notification preferences in your account settings. Reply HELP for support.
+ If you opt in, Scholarmancy sends text messages about grade and assignment alerts and sign-in
+ links to the mobile number you provide. Message frequency varies. Message and data rates may
+ apply. Reply STOP to opt out or HELP for help. You can also update notification preferences in
+ your account settings.
+
+
+ We do not share, sell, or provide your mobile phone number or SMS opt-in data to third parties or affiliates for marketing or promotional purposes.
diff --git a/packages/web/app/register/page.tsx b/packages/web/app/register/page.tsx
index 7728609..a44d0ac 100644
--- a/packages/web/app/register/page.tsx
+++ b/packages/web/app/register/page.tsx
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { OAuthButtons } from '@/components/auth/OAuthButtons';
import { authApi } from '@/lib/api/auth';
+import { SmsOptInCheckbox } from '@/components/legal/SmsOptInCheckbox';
export default function RegisterPage() {
const router = useRouter();
@@ -161,22 +162,11 @@ export default function RegisterPage() {
-
- setSmsConsent(e.target.checked)}
- disabled={isLoading}
- className="mt-1 h-4 w-4 rounded border-gray-300"
- data-testid="sms-consent-checkbox"
- />
-
- I agree to receive SMS text message alerts about assignments and deadlines. Standard message
- and data rates may apply. Reply STOP to opt out, HELP for support.
-
-
+
diff --git a/packages/web/app/terms/page.tsx b/packages/web/app/terms/page.tsx
index 784edd4..70db1ab 100644
--- a/packages/web/app/terms/page.tsx
+++ b/packages/web/app/terms/page.tsx
@@ -48,14 +48,20 @@ export default function TermsPage() {
-
- 4. SMS Consent and Opt-In
+
+ 4. Scholarmancy SMS Program
- If you opt in to receive SMS notifications, you consent to receive automated text messages from
- Scholarmancy at the phone number you provide. Message frequency depends on your alert settings.
- Standard message and data rates may apply. You may opt out at any time by replying STOP,
- UNSTOP to resubscribe, or HELP for assistance. Your carrier is not liable for delayed or
- undelivered messages.
+ The Scholarmancy SMS program sends grade and assignment alerts and sign-in links to parents who
+ opt in on our registration or account settings pages by checking the SMS consent box and providing
+ a mobile number. Message frequency varies. Message and data rates may apply. Reply STOP to opt
+ out; reply HELP for help. For support, email{' '}
+
+ support@scholarmancy.com
+
+ . Carriers are not liable for delayed or undelivered messages.
diff --git a/packages/web/components/legal/SmsOptInCheckbox.tsx b/packages/web/components/legal/SmsOptInCheckbox.tsx
new file mode 100644
index 0000000..ad2647e
--- /dev/null
+++ b/packages/web/components/legal/SmsOptInCheckbox.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import Link from 'next/link';
+import { Label } from '@/components/ui/label';
+import {
+ SCHOLARMANCY_SMS_BRAND,
+ SCHOLARMANCY_SMS_PURPOSE,
+} from '@scholaracle/contracts';
+
+export interface ISmsOptInCheckboxProps {
+ readonly checked: boolean;
+ readonly onChange: (checked: boolean) => void;
+ readonly disabled?: boolean;
+ readonly id?: string;
+}
+
+/** Toll-free compliant SMS opt-in (unchecked by default; separate from terms). */
+export function SmsOptInCheckbox({
+ checked,
+ onChange,
+ disabled,
+ id = 'smsConsent',
+}: ISmsOptInCheckboxProps) {
+ return (
+
+ onChange(e.target.checked)}
+ disabled={disabled}
+ className="mt-1 h-4 w-4 rounded border-gray-300"
+ data-testid="sms-consent-checkbox"
+ />
+
+ Text me {SCHOLARMANCY_SMS_PURPOSE} from {SCHOLARMANCY_SMS_BRAND}. Message frequency varies.
+ Message and data rates may apply. Reply STOP to opt out, HELP for help. Consent is not a
+ condition of purchase. See our{' '}
+
+ SMS Terms
+ {' '}
+ and{' '}
+
+ Privacy Policy
+
+ .
+
+
+ );
+}
diff --git a/packages/web/lib/api/settings.ts b/packages/web/lib/api/settings.ts
index 6e89d5e..0761b1a 100644
--- a/packages/web/lib/api/settings.ts
+++ b/packages/web/lib/api/settings.ts
@@ -101,6 +101,10 @@ export interface IUserSettings {
export interface IUpdateSettingsRequest {
readonly dashboard?: IDashboardSettings;
+ readonly profile?: {
+ readonly phone?: string;
+ readonly smsConsent?: boolean;
+ };
readonly notifications?: INotificationSettings;
readonly alerts?: IAlertThresholds;
readonly timezone?: string;
@@ -131,6 +135,8 @@ export interface IUserSettingsResponse extends IUserSettings {
readonly profile?: {
readonly name: string;
readonly email: string;
+ readonly phone?: string;
+ readonly smsConsent?: boolean;
readonly oauthProviders: readonly string[];
};
}
diff --git a/packages/workers/package.json b/packages/workers/package.json
index a8c658c..f85d4cd 100644
--- a/packages/workers/package.json
+++ b/packages/workers/package.json
@@ -30,8 +30,7 @@
"@sentry/node": "^10.0.0",
"firebase-admin": "^12.0.0",
"mongodb": "^6.3.0",
- "nodemailer": "^6.9.0",
- "twilio": "^5.0.0"
+ "nodemailer": "^6.9.0"
},
"devDependencies": {
"@types/jest": "^29.5.11",
diff --git a/packages/workers/src/worker.test.ts b/packages/workers/src/worker.test.ts
index 877a009..514b1e1 100644
--- a/packages/workers/src/worker.test.ts
+++ b/packages/workers/src/worker.test.ts
@@ -27,21 +27,6 @@ jest.mock('@sendgrid/mail', () => {
};
});
-// Mock Twilio
-jest.mock('twilio', () => {
- return {
- __esModule: true,
- default: jest.fn(() => ({
- messages: {
- create: jest.fn().mockResolvedValue({
- sid: 'sms-123',
- status: 'queued',
- }),
- },
- })),
- };
-});
-
import { startWorker, type IWorkerConfig } from './worker';
import { MongoClient, type Db, type Collection } from 'mongodb';
@@ -94,9 +79,7 @@ describe('Worker', () => {
'SENDGRID_API_KEY',
'SENDGRID_FROM_EMAIL',
'SENDGRID_FROM_NAME',
- 'TWILIO_ACCOUNT_SID',
- 'TWILIO_AUTH_TOKEN',
- 'TWILIO_FROM_NUMBER',
+ 'NOCTUSOFT_API_KEY',
]) {
savedEnv[key] = process.env[key];
}
@@ -210,21 +193,6 @@ describe('Worker', () => {
await triggerShutdown();
});
- it('should accept custom Twilio config values', async () => {
- const config: IWorkerConfig = {
- mongodbUri: 'mongodb://localhost:27017',
- twilioAccountSid: 'AC-test-sid',
- twilioAuthToken: 'test-auth-token',
- twilioFromNumber: '+15551234567',
- };
-
- await startWorker(config);
- await new Promise((resolve) => setTimeout(resolve, 100));
- expect(mockMongoClient.connect).toHaveBeenCalled();
-
- await triggerShutdown();
- });
-
it('should fall back to SendGrid env vars when config omitted', async () => {
process.env['SENDGRID_API_KEY'] = 'SG.env-key';
process.env['SENDGRID_FROM_EMAIL'] = 'env@example.com';
@@ -237,10 +205,8 @@ describe('Worker', () => {
await triggerShutdown();
});
- it('should fall back to Twilio env vars when config omitted', async () => {
- process.env['TWILIO_ACCOUNT_SID'] = 'AC-env-sid';
- process.env['TWILIO_AUTH_TOKEN'] = 'env-auth-token';
- process.env['TWILIO_FROM_NUMBER'] = '+15559999999';
+ it('should start when NOCTUSOFT_API_KEY is set for SMS stack', async () => {
+ process.env['NOCTUSOFT_API_KEY'] = 'nsk_test_sms_key';
await startWorker({ mongodbUri: 'mongodb://localhost:27017' });
await new Promise((resolve) => setTimeout(resolve, 100));
diff --git a/packages/workers/src/worker.ts b/packages/workers/src/worker.ts
index bce528f..b10a182 100644
--- a/packages/workers/src/worker.ts
+++ b/packages/workers/src/worker.ts
@@ -29,14 +29,13 @@ import { SyncWorker, SyncScheduler } from '@scholaracle/agents';
import type { AdapterRunnerFn } from '@scholaracle/agents';
import { EmailDelivery, SendGridTransport, SmtpTransport } from '@scholaracle/agents';
import type { IEmailTransport } from '@scholaracle/agents';
-import { SMSDelivery, applyTwilioApiBaseUrl } from '@scholaracle/agents';
+import { createSmsStack } from '@scholaracle/agents';
import { PushDelivery, ExpoPushDelivery } from '@scholaracle/agents';
import { InAppDelivery } from '@scholaracle/agents';
import sgMail from '@sendgrid/mail';
-import twilio from 'twilio';
import nodemailer from 'nodemailer';
import type { MailService } from '@sendgrid/mail';
-import type { Twilio } from 'twilio';
+import type { GuardedSmsSender } from '@scholaracle/agents';
import { ConnectorTokenService } from '@scholaracle/auth';
import { randomUUID } from 'crypto';
import { createAdapterRunner } from './adapter-runner';
@@ -51,12 +50,6 @@ export interface IWorkerConfig {
readonly sendGridApiKey?: string;
readonly sendGridFromEmail?: string;
readonly sendGridFromName?: string;
- readonly twilioAccountSid?: string;
- readonly twilioAuthToken?: string;
- readonly twilioApiKeySid?: string;
- readonly twilioApiKeySecret?: string;
- readonly twilioFromNumber?: string;
- readonly twilioMessagingServiceSid?: string;
readonly firebaseProjectId?: string;
readonly pollIntervalMs?: number;
readonly concurrency?: number;
@@ -79,32 +72,7 @@ function getSendGridConfig(config: IWorkerConfig): {
config.sendGridFromEmail ??
process.env['SENDGRID_FROM_EMAIL'] ??
'notifications@scholarmancy.com',
- fromName: config.sendGridFromName ?? process.env['SENDGRID_FROM_NAME'] ?? 'Scholaracle',
- };
-}
-
-/**
- * Get Twilio configuration from config or environment.
- *
- * @param config - Worker configuration
- * @returns Twilio configuration
- */
-function getTwilioConfig(config: IWorkerConfig): {
- readonly accountSid: string;
- readonly authToken: string;
- readonly apiKeySid: string;
- readonly apiKeySecret: string;
- readonly fromNumber: string;
- readonly messagingServiceSid: string;
-} {
- return {
- accountSid: config.twilioAccountSid ?? process.env['TWILIO_ACCOUNT_SID'] ?? '',
- authToken: config.twilioAuthToken ?? process.env['TWILIO_AUTH_TOKEN'] ?? '',
- apiKeySid: config.twilioApiKeySid ?? process.env['TWILIO_API_KEY_SID'] ?? '',
- apiKeySecret: config.twilioApiKeySecret ?? process.env['TWILIO_API_KEY_SECRET'] ?? '',
- fromNumber: config.twilioFromNumber ?? process.env['TWILIO_FROM_NUMBER'] ?? '',
- messagingServiceSid:
- config.twilioMessagingServiceSid ?? process.env['TWILIO_MESSAGING_SERVICE_SID'] ?? '',
+ fromName: config.sendGridFromName ?? process.env['SENDGRID_FROM_NAME'] ?? 'Scholarmancy',
};
}
@@ -117,12 +85,7 @@ const MAX_SMS_LENGTH = 1600;
* Flush pending SMS digest: send one combined SMS per user with digest enabled, then clear pending.
* Intended to run once per day at SMS_DIGEST_UTC_HOUR.
*/
-async function flushSmsDigests(
- database: Db,
- twilioClient: Twilio,
- fromNumber: string,
- messagingServiceSid?: string
-): Promise {
+async function flushSmsDigests(database: Db, guardedSender: GuardedSmsSender): Promise {
const repo = new SmsDigestPendingRepository(database);
const userIds = await repo.getDistinctUserIds();
if (userIds.length === 0) return;
@@ -132,29 +95,17 @@ async function flushSmsDigests(
if (items.length === 0) continue;
const phone = items[0]!.phone;
const parts = items.map((i) => `${i.subject}\n${i.body}`);
- let body = `Scholaracle daily digest (${items.length} alert${items.length === 1 ? '' : 's'}):\n\n${parts.join('\n\n')}`;
+ let body = `Scholarmancy daily digest (${items.length} alert${items.length === 1 ? '' : 's'}):\n\n${parts.join('\n\n')}`;
if (body.length > MAX_SMS_LENGTH) {
body = `${body.substring(0, MAX_SMS_LENGTH - 3)}...`;
}
const commLogRepo = new CommunicationLogRepository(database);
try {
- const msg = await twilioClient.messages.create({
- to: phone,
- ...(messagingServiceSid ? { messagingServiceSid } : { from: fromNumber }),
- body,
- });
- await commLogRepo.create({
+ await guardedSender.sendTransactional(phone, body, {
userId,
- channel: 'sms',
- type: 'notification',
subject: `SMS Digest (${items.length} alerts)`,
- content: body,
- recipientPhone: phone,
- status: 'sent',
- sentAt: new Date(),
- triggeredBy: 'scheduled',
templateName: 'sms_digest',
- providerId: msg.sid,
+ triggeredBy: 'scheduled',
});
await repo.deleteByUserId(userId);
} catch (err) {
@@ -220,23 +171,7 @@ function initializeNotificationService(
emailTransport?: IEmailTransport
): NotificationService {
const sendGridConfig = getSendGridConfig(config);
- const twilioConfig = getTwilioConfig(config);
-
- const hasApiKeyAuth = Boolean(
- twilioConfig.accountSid && twilioConfig.apiKeySid && twilioConfig.apiKeySecret
- );
- const hasAuthTokenAuth = Boolean(twilioConfig.accountSid && twilioConfig.authToken);
- const twilioClient =
- hasApiKeyAuth || hasAuthTokenAuth
- ? applyTwilioApiBaseUrl(
- hasApiKeyAuth
- ? twilio(twilioConfig.apiKeySid, twilioConfig.apiKeySecret, {
- accountSid: twilioConfig.accountSid,
- })
- : twilio(twilioConfig.accountSid, twilioConfig.authToken),
- process.env['TWILIO_API_BASE_URL']
- )
- : ({} as unknown as Twilio);
+ const smsStack = database ? createSmsStack(database) : null;
const transport: IEmailTransport = emailTransport ?? getEmailTransport(config);
@@ -250,16 +185,6 @@ function initializeNotificationService(
},
transport
);
- const smsDelivery = new SMSDelivery(
- {
- accountSid: twilioConfig.accountSid,
- authToken: twilioConfig.authToken,
- fromNumber: twilioConfig.fromNumber,
- messagingServiceSid: twilioConfig.messagingServiceSid,
- },
- twilioClient
- );
-
const firebaseProjectId = config.firebaseProjectId ?? 'default';
const pushDelivery = new PushDelivery({ projectId: firebaseProjectId });
@@ -289,7 +214,7 @@ function initializeNotificationService(
const deliveryRouter = new DeliveryRouter([
emailDelivery,
- smsDelivery,
+ ...(smsStack ? [smsStack.smsDelivery] : []),
...(expoPushDelivery ? [expoPushDelivery] : []),
pushDelivery,
inAppDelivery,
@@ -552,35 +477,14 @@ export async function startWorker(config: IWorkerConfig = {}): Promise {
});
syncScheduler.start();
- const twilioConfig = getTwilioConfig(config);
- const digestHasApiKey = Boolean(
- twilioConfig.accountSid && twilioConfig.apiKeySid && twilioConfig.apiKeySecret
- );
- const digestHasAuthToken = Boolean(twilioConfig.accountSid && twilioConfig.authToken);
- const twilioClientForDigest =
- digestHasApiKey || digestHasAuthToken
- ? applyTwilioApiBaseUrl(
- digestHasApiKey
- ? twilio(twilioConfig.apiKeySid, twilioConfig.apiKeySecret, {
- accountSid: twilioConfig.accountSid,
- })
- : twilio(twilioConfig.accountSid, twilioConfig.authToken),
- process.env['TWILIO_API_BASE_URL']
- )
- : null;
- const hasSender = Boolean(twilioConfig.fromNumber || twilioConfig.messagingServiceSid);
- if (twilioClientForDigest && hasSender) {
+ const digestSmsStack = createSmsStack(database);
+ if (digestSmsStack) {
safeInterval(
'sms-digest',
async () => {
const now = new Date();
if (now.getUTCHours() === DIGEST_UTC_HOUR) {
- await flushSmsDigests(
- database,
- twilioClientForDigest,
- twilioConfig.fromNumber,
- twilioConfig.messagingServiceSid || undefined
- );
+ await flushSmsDigests(database, digestSmsStack.guardedSender);
}
},
60_000
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index df80883..25e6f29 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -68,15 +68,15 @@ importers:
'@sendgrid/mail':
specifier: ^8.1.0
version: 8.1.6
+ libphonenumber-js:
+ specifier: ^1.12.9
+ version: 1.13.14
mongodb:
specifier: ^6.3.0
version: 6.21.0
nodemailer:
specifier: ^6.9.0
version: 6.10.1
- twilio:
- specifier: ^5.0.0
- version: 5.13.1
devDependencies:
'@types/jest':
specifier: ^29.5.11
@@ -180,9 +180,6 @@ importers:
rrule:
specifier: ^2.7.2
version: 2.8.1
- twilio:
- specifier: ^5.0.0
- version: 5.13.1
ua-parser-js:
specifier: ^2.0.9
version: 2.0.10
@@ -553,10 +550,10 @@ importers:
version: 1.20.1
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))
+ version: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3))
ts-jest:
specifier: ^29.1.1
- version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)))(typescript@6.0.3)
+ version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)))(typescript@6.0.3)
typescript:
specifier: ~6.0.3
version: 6.0.3
@@ -786,9 +783,6 @@ importers:
nodemailer:
specifier: ^6.9.0
version: 6.10.1
- twilio:
- specifier: ^5.0.0
- version: 5.13.1
devDependencies:
'@types/jest':
specifier: ^29.5.11
@@ -830,49 +824,6 @@ packages:
zod:
optional: true
- '@apimatic/authentication-adapters@0.5.14':
- resolution: {integrity: sha512-V7nhHShPrU8LfjKKHoVJNS50SveSL77CexVuS4aeQyXx99HwdQVJwl2MK0KAYM6/b2ufQbJ7Eee2fzQT0TVXSQ==}
- engines: {node: '>=14.15.0 || >=16.0.0'}
-
- '@apimatic/axios-client-adapter@0.3.21':
- resolution: {integrity: sha512-pr/XvAvH9FjbpwM+B7vHQxM7alocOX1kLNtSpXKW3yxTYxksF3ydnUuQ85rRbCoNpyfMOIjnRBCNUBzX5p2Hnw==}
- engines: {node: '>=14.15.0 || >=16.0.0'}
-
- '@apimatic/convert-to-stream@0.1.9':
- resolution: {integrity: sha512-C9NEKnDZoTRBRVeUGXVyAEmy6P5o+8oLwEckTKj0iBlExJLEXNt14nf4wxfzRO1KR8j5Bw8S6yStKCrQzcVERA==}
- engines: {node: '>=14.15.0 || >=16.0.0'}
-
- '@apimatic/core-interfaces@0.2.14':
- resolution: {integrity: sha512-PQmSU32ndxtDddMCjbkNY/sVvDwQAsHUGKrdG5aGVE7iw/qvB2Tm2zyCarOB5TlDr4OB+/tuLCVhji0icx6MHg==}
- engines: {node: '>=14.15.0 || >=16.0.0'}
-
- '@apimatic/core@0.10.30':
- resolution: {integrity: sha512-MqODm1YwuW5yK7gkVtqiRQBgoAfjsTSNYTYJP4cg/JDaF8RokpiupSEDuUW6Xdo3fl/4tGCzphtAlKSeLUWKVA==}
- engines: {node: '>=14.15.0 || >=16.0.0'}
-
- '@apimatic/file-wrapper@0.3.9':
- resolution: {integrity: sha512-Fh3UE7UPs2v4wkJdsD+uJFF147+7X0qkQfKBdeLZx6mZ5RmBJOBbS6ApvstQTV279YsHiiedKUZGJ6XLoVU+pQ==}
- engines: {node: '>=14.15.0 || >=16.0.0'}
-
- '@apimatic/http-headers@0.3.8':
- resolution: {integrity: sha512-ShvCuT39hYfBTI+H1I16m5i6XZCyUy2kQJ6Jhfj78TwsW5r6AyCbzW7DEro8GN2nNYRU1+E/hrgH6J85YmriOA==}
- engines: {node: '>=14.15.0 || >=16.0.0'}
-
- '@apimatic/http-query@0.3.9':
- resolution: {integrity: sha512-D6nqXcCR3P6iWbJ9uFXyyF2z1PEhTbGFbHNNuwF1NQ4tnThQk67DW9ou7/XcWi21zLh9MUchDWw9I0iE+5F2xA==}
- engines: {node: '>=14.15.0 || >=16.0.0'}
-
- '@apimatic/json-bigint@1.2.0':
- resolution: {integrity: sha512-+bmVzYMdZu0Ya5L+my4FXFUih54OvQA/qlZsFOYdOoostyUuB27UDrVWQs/WVCmS0ADdo5vTU0eeTrrBkHoySw==}
-
- '@apimatic/proxy@0.1.4':
- resolution: {integrity: sha512-Vzgfu7wcA5aEJyj2SjQ00Tb06fhBof8gDo1kSsF6sZBm4QjdFywN5AMbQwhfFOKjHqcsNmJspdeqcdymUQ77jA==}
- engines: {node: '>=14.15.0 || >=16.0.0'}
-
- '@apimatic/schema@0.7.21':
- resolution: {integrity: sha512-RCke4toXjA7fBRxQVa1GR+Lj9utVOEJ3voDI26dhk+bZuAac4UXPzkTEaIO3AIe/o8pcKCOkpNIzhzm57Cv2Qg==}
- engines: {node: '>=14.15.0 || >=16.0.0'}
-
'@apm-js-collab/code-transformer-bundler-plugins@0.7.4':
resolution: {integrity: sha512-nAfOeZPSUAQvJa1iFT/5oCrTm5YQhMMrfCNthNnaXHZiOQhu1KGuLoIx7HtbAi3wfwaBYLaICPIeenIaEwcXIg==}
engines: {node: '>=18.0.0'}
@@ -3567,9 +3518,6 @@ packages:
'@types/multer@1.4.13':
resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==}
- '@types/node@14.18.63':
- resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
-
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
@@ -4730,9 +4678,6 @@ packages:
dateformat@4.6.3:
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
- dayjs@1.11.21:
- resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
-
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
@@ -4810,9 +4755,6 @@ packages:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
- detect-browser@5.3.0:
- resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==}
-
detect-europe-js@0.1.2:
resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
@@ -4827,9 +4769,6 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
- detect-node@2.1.0:
- resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
-
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
@@ -5574,10 +5513,6 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
- form-data-encoder@4.1.0:
- resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==}
- engines: {node: '>= 18'}
-
form-data@2.5.6:
resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==}
engines: {node: '>= 0.12'}
@@ -5586,10 +5521,6 @@ packages:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
- formdata-node@6.0.3:
- resolution: {integrity: sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==}
- engines: {node: '>= 18'}
-
formidable@2.1.5:
resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==}
@@ -6447,6 +6378,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ libphonenumber-js@1.13.14:
+ resolution: {integrity: sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==}
+
lighthouse-logger@1.4.2:
resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
@@ -6635,12 +6569,6 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
- lodash.defaultsdeep@4.6.1:
- resolution: {integrity: sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==}
-
- lodash.flatmap@4.5.0:
- resolution: {integrity: sha512-/OcpcAGWlrZyoHGeHh3cAoa6nGdX6QYtmzNP84Jqol6UEQQ2gIaU3H+0eICcjcKGl0/XF8LWOujNn9lffsnaOg==}
-
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
@@ -7846,10 +7774,6 @@ packages:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'}
- scmp@2.1.0:
- resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==}
- deprecated: Just use Node.js's crypto.timingSafeEqual()
-
section-matter@1.0.0:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
@@ -7995,14 +7919,6 @@ packages:
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
- square@39.1.1:
- resolution: {integrity: sha512-75b/UWbXl6xk1cG0jEWeWTRHAbDseF78kdPa3N4Cm57KMkDwOS2qGSLfooyqMDmFEKhQAfTA0WSiMkw40hQ/2A==}
- engines: {node: '>=14.17.0'}
-
- square@44.2.1:
- resolution: {integrity: sha512-mGqFhxdGMKornyxtbyZbLYaPHbqnef/297kx6lGlNIxxvtcgAekuAOM/fHOs4V949oFV6F0Lu4GMVjutVH15NA==}
- engines: {node: '>=18.0.0'}
-
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -8246,9 +8162,6 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
- tiny-warning@1.0.3:
- resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
-
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
@@ -8372,10 +8285,6 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
- twilio@5.13.1:
- resolution: {integrity: sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ==}
- engines: {node: '>=14.0'}
-
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -8722,10 +8631,6 @@ packages:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
- xmlbuilder@13.0.2:
- resolution: {integrity: sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==}
- engines: {node: '>=6.0'}
-
xmlbuilder@15.1.1:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
@@ -8811,87 +8716,6 @@ snapshots:
optionalDependencies:
zod: 4.4.3
- '@apimatic/authentication-adapters@0.5.14':
- dependencies:
- '@apimatic/core-interfaces': 0.2.14
- '@apimatic/http-headers': 0.3.8
- '@apimatic/http-query': 0.3.9
- tslib: 2.8.1
-
- '@apimatic/axios-client-adapter@0.3.21':
- dependencies:
- '@apimatic/convert-to-stream': 0.1.9
- '@apimatic/core-interfaces': 0.2.14
- '@apimatic/file-wrapper': 0.3.9
- '@apimatic/http-headers': 0.3.8
- '@apimatic/http-query': 0.3.9
- '@apimatic/json-bigint': 1.2.0
- '@apimatic/proxy': 0.1.4
- axios: 1.19.0
- detect-browser: 5.3.0
- detect-node: 2.1.0
- form-data: 4.0.6
- lodash.flatmap: 4.5.0
- tiny-warning: 1.0.3
- tslib: 2.8.1
- transitivePeerDependencies:
- - debug
- - supports-color
-
- '@apimatic/convert-to-stream@0.1.9':
- dependencies:
- tslib: 2.8.1
-
- '@apimatic/core-interfaces@0.2.14':
- dependencies:
- '@apimatic/file-wrapper': 0.3.9
- '@apimatic/json-bigint': 1.2.0
- tslib: 2.8.1
-
- '@apimatic/core@0.10.30':
- dependencies:
- '@apimatic/convert-to-stream': 0.1.9
- '@apimatic/core-interfaces': 0.2.14
- '@apimatic/file-wrapper': 0.3.9
- '@apimatic/http-headers': 0.3.8
- '@apimatic/http-query': 0.3.9
- '@apimatic/json-bigint': 1.2.0
- '@apimatic/schema': 0.7.21
- detect-browser: 5.3.0
- detect-node: 2.1.0
- form-data: 4.0.6
- lodash.defaultsdeep: 4.6.1
- lodash.flatmap: 4.5.0
- tiny-warning: 1.0.3
- tslib: 2.8.1
-
- '@apimatic/file-wrapper@0.3.9':
- dependencies:
- tslib: 2.8.1
-
- '@apimatic/http-headers@0.3.8':
- dependencies:
- tslib: 2.8.1
-
- '@apimatic/http-query@0.3.9':
- dependencies:
- '@apimatic/core-interfaces': 0.2.14
- '@apimatic/file-wrapper': 0.3.9
- tslib: 2.8.1
-
- '@apimatic/json-bigint@1.2.0': {}
-
- '@apimatic/proxy@0.1.4':
- dependencies:
- http-proxy-agent: 7.0.2
- https-proxy-agent: 7.0.6
- transitivePeerDependencies:
- - supports-color
-
- '@apimatic/schema@0.7.21':
- dependencies:
- tslib: 2.8.1
-
'@apm-js-collab/code-transformer-bundler-plugins@0.7.4':
dependencies:
'@apm-js-collab/code-transformer': 0.18.1
@@ -10498,7 +10322,7 @@ snapshots:
- supports-color
- ts-node
- '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))':
+ '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3))':
dependencies:
'@jest/console': 29.7.0
'@jest/reporters': 29.7.0
@@ -10512,7 +10336,7 @@ snapshots:
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
- jest-config: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))
+ jest-config: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3))
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -12021,8 +11845,6 @@ snapshots:
dependencies:
'@types/express': 4.17.25
- '@types/node@14.18.63': {}
-
'@types/node@20.19.43':
dependencies:
undici-types: 6.21.0
@@ -13299,13 +13121,13 @@ snapshots:
- supports-color
- ts-node
- create-jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)):
+ create-jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
- jest-config: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))
+ jest-config: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3))
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@@ -13396,8 +13218,6 @@ snapshots:
dateformat@4.6.3: {}
- dayjs@1.11.21: {}
-
debug@2.6.9:
dependencies:
ms: 2.0.0
@@ -13446,8 +13266,6 @@ snapshots:
destroy@1.2.0: {}
- detect-browser@5.3.0: {}
-
detect-europe-js@0.1.2: {}
detect-libc@2.1.2: {}
@@ -13456,8 +13274,6 @@ snapshots:
detect-node-es@1.1.0: {}
- detect-node@2.1.0: {}
-
dezalgo@1.0.4:
dependencies:
asap: 2.0.6
@@ -14523,8 +14339,6 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
- form-data-encoder@4.1.0: {}
-
form-data@2.5.6:
dependencies:
asynckit: 0.4.0
@@ -14543,8 +14357,6 @@ snapshots:
hasown: 2.0.4
mime-types: 2.1.35
- formdata-node@6.0.3: {}
-
formidable@2.1.5:
dependencies:
'@paralleldrive/cuid2': 2.3.1
@@ -15219,16 +15031,16 @@ snapshots:
- supports-color
- ts-node
- jest-cli@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)):
+ jest-cli@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)):
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
- create-jest: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))
+ create-jest: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3))
exit: 0.1.2
import-local: 3.2.0
- jest-config: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))
+ jest-config: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.3
@@ -15269,7 +15081,7 @@ snapshots:
- babel-plugin-macros
- supports-color
- jest-config@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)):
+ jest-config@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)):
dependencies:
'@babel/core': 7.29.7
'@jest/test-sequencer': 29.7.0
@@ -15295,7 +15107,38 @@ snapshots:
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 20.19.43
- ts-node: 10.9.2(@types/node@20.19.43)(typescript@6.0.3)
+ ts-node: 10.9.2(@types/node@22.20.1)(typescript@6.0.3)
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+
+ jest-config@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)):
+ dependencies:
+ '@babel/core': 7.29.7
+ '@jest/test-sequencer': 29.7.0
+ '@jest/types': 29.6.3
+ babel-jest: 29.7.0(@babel/core@7.29.7)
+ chalk: 4.1.2
+ ci-info: 3.9.0
+ deepmerge: 4.3.1
+ glob: 7.2.3
+ graceful-fs: 4.2.11
+ jest-circus: 29.7.0
+ jest-environment-node: 29.7.0
+ jest-get-type: 29.6.3
+ jest-regex-util: 29.6.3
+ jest-resolve: 29.7.0
+ jest-runner: 29.7.0
+ jest-util: 29.7.0
+ jest-validate: 29.7.0
+ micromatch: 4.0.8
+ parse-json: 5.2.0
+ pretty-format: 29.7.0
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ '@types/node': 22.20.1
+ ts-node: 10.9.2(@types/node@22.20.1)(typescript@6.0.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -15573,12 +15416,12 @@ snapshots:
- supports-color
- ts-node
- jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)):
+ jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)):
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3))
'@jest/types': 29.6.3
import-local: 3.2.0
- jest-cli: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))
+ jest-cli: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -15731,6 +15574,8 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ libphonenumber-js@1.13.14: {}
+
lighthouse-logger@1.4.2:
dependencies:
debug: 2.6.9
@@ -15897,10 +15742,6 @@ snapshots:
lodash.debounce@4.0.8: {}
- lodash.defaultsdeep@4.6.1: {}
-
- lodash.flatmap@4.5.0: {}
-
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
@@ -17225,8 +17066,6 @@ snapshots:
ajv-formats: 2.1.1(ajv@8.20.0)
ajv-keywords: 5.1.0(ajv@8.20.0)
- scmp@2.1.0: {}
-
section-matter@1.0.0:
dependencies:
extend-shallow: 2.0.1
@@ -17421,31 +17260,6 @@ snapshots:
sprintf-js@1.0.3: {}
- square@39.1.1:
- dependencies:
- '@apimatic/authentication-adapters': 0.5.14
- '@apimatic/axios-client-adapter': 0.3.21
- '@apimatic/core': 0.10.30
- '@apimatic/json-bigint': 1.2.0
- '@apimatic/schema': 0.7.21
- '@types/node': 14.18.63
- transitivePeerDependencies:
- - debug
- - supports-color
-
- square@44.2.1:
- dependencies:
- form-data: 4.0.6
- form-data-encoder: 4.1.0
- formdata-node: 6.0.3
- node-fetch: 2.7.0
- readable-stream: 4.7.0
- square-legacy: square@39.1.1
- transitivePeerDependencies:
- - debug
- - encoding
- - supports-color
-
stable-hash@0.0.5: {}
stack-utils@2.0.6:
@@ -17736,8 +17550,6 @@ snapshots:
tiny-invariant@1.3.3: {}
- tiny-warning@1.0.3: {}
-
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.5)
@@ -17811,12 +17623,12 @@ snapshots:
esbuild: 0.24.2
jest-util: 30.4.1
- ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3)))(typescript@6.0.3):
+ ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3)))(typescript@6.0.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.9
- jest: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3))
+ jest: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -17867,14 +17679,14 @@ snapshots:
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
- ts-node@10.9.2(@types/node@20.19.43)(typescript@6.0.3):
+ ts-node@10.9.2(@types/node@22.20.1)(typescript@6.0.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
- '@types/node': 20.19.43
+ '@types/node': 22.20.1
acorn: 8.18.0
acorn-walk: 8.3.5
arg: 4.1.3
@@ -17904,19 +17716,6 @@ snapshots:
tw-animate-css@1.4.0: {}
- twilio@5.13.1:
- dependencies:
- axios: 1.19.0
- dayjs: 1.11.21
- https-proxy-agent: 5.0.1
- jsonwebtoken: 9.0.3
- qs: 6.15.3
- scmp: 2.1.0
- xmlbuilder: 13.0.2
- transitivePeerDependencies:
- - debug
- - supports-color
-
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -18305,8 +18104,6 @@ snapshots:
xmlbuilder@11.0.1: {}
- xmlbuilder@13.0.2: {}
-
xmlbuilder@15.1.1: {}
xmlchars@2.2.0: {}