diff --git a/README.md b/README.md index cc3d333..50d7337 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,273 @@ -# Push Server for Time Safari +# Time Safari Push Notification Middleware Server +A custom-built push notification middleware service designed specifically for the Time Safari application. This server acts as an intermediary layer between the Time Safari client applications and various push notification providers, providing a unified interface for managing push subscriptions and delivering notifications. -## Setup Environment +## Project Intent + +The primary goal of this project was to build our own middleware service for push notifications rather than relying on third-party services. This approach provides several benefits: + +- **Complete Control**: Full ownership over the notification delivery pipeline +- **Customization**: Ability to implement Time Safari-specific notification logic +- **Privacy**: No dependency on external services that might collect user data +- **Cost Efficiency**: Eliminates per-notification costs from third-party providers +- **Reliability**: Direct control over uptime and performance + +## Architecture Overview + +The server is built using Node.js with TypeScript and follows a modular architecture: -Tea isn't required but it's what we use to set up the dev environment. ``` -sh <(curl tea.xyz) -E sh +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Client Apps │───▶│ Push Server │───▶│ Push Providers │ +│ (Time Safari) │ │ (Middleware) │ │ (FCM, etc.) │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ SQLite DB │ + │ (Subscriptions) │ + └──────────────────┘ ``` -#### Dependencies +### Core Components -See https://tea.xyz +- **Subscription Management**: Handles client subscription registration and storage +- **VAPID Key Management**: Generates and manages VAPID keys for secure push delivery +- **Notification Service**: Encrypts and delivers push notifications to subscribed clients +- **Worker Thread**: Handles periodic tasks (currently sends daily notifications) +- **Database Layer**: SQLite-based storage for subscriptions and VAPID keys -| Project | Version | -| ---------- | --------- | -| nodejs.org | ^16.0.0 | -| npmjs.com | ^8.0.0 | +## Features +### 🔐 Secure Push Delivery +- VAPID (Voluntary Application Server Identification) key generation and management +- End-to-end encryption using AES-128-GCM +- JWT-based authentication headers -## Install -``` +### 📱 Subscription Management +- Client subscription registration via `/subscribe` endpoint +- Subscription storage in SQLite database +- Support for subscription muting (planned) +- Subscription removal (planned) + +### ⚡ Real-time Notifications +- Broadcast notifications to all subscribed clients +- Individual client notification delivery +- Automatic daily notification scheduling via worker thread + +### 🛠️ Developer-Friendly +- TypeScript for type safety +- Comprehensive logging +- Modular service architecture +- Docker support for containerization + +## Technology Stack + +- **Runtime**: Node.js 18.12+ +- **Language**: TypeScript +- **Framework**: Express.js +- **Database**: SQLite with TypeORM +- **Encryption**: http_ece, elliptic, crypto +- **Authentication**: JWT (JSON Web Tokens) +- **Containerization**: Docker + +## Setup and Installation + +### Prerequisites + +- Node.js 18.12 or higher +- npm 8.0 or higher + +### Quick Start with Tea (Recommended) + +```bash +# Install Tea package manager +sh <(curl tea.xyz) -E sh + +# Install dependencies npm install -``` +# Build the project +npm run build -## Run +# Start the server +npm run start ``` + +### Manual Setup + +```bash +# Clone the repository +git clone +cd pwa-push-server + +# Install dependencies +npm install + +# Build TypeScript npm run build + +# Start the server npm run start ``` +The server will start on `http://localhost:3000` + +### Docker Setup + +```bash +# Build the Docker image +docker build -t timesafari-push-server . + +# Run the container +docker run -p 3000:3000 timesafari-push-server +``` + +## API Endpoints + +### POST `/subscribe` +Register a new push subscription. + +**Request Body:** +```json +{ + "endpoint": "https://fcm.googleapis.com/fcm/send/...", + "keys": { + "p256dh": "base64-encoded-public-key", + "auth": "base64-encoded-auth-secret" + } +} +``` + +**Response:** `201 Created` + +### GET `/vapid` +Retrieve the public VAPID key for client-side subscription. + +**Response:** +```json +{ + "vapidKey": "base64-encoded-public-vapid-key" +} +``` + +### POST `/unsubscribe` (Planned) +Remove a push subscription. + +### POST `/mute` (Planned) +Toggle notification muting for a subscription. + +## Client Integration + +### 1. Request VAPID Key +```javascript +const response = await fetch('/vapid'); +const { vapidKey } = await response.json(); +``` + +### 2. Subscribe to Push Notifications +```javascript +const registration = await navigator.serviceWorker.ready; +const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidKey +}); + +await fetch('/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(subscription) +}); +``` + +### 3. Handle Push Notifications +```javascript +// In your service worker +self.addEventListener('push', (event) => { + const data = event.data.json(); + const options = { + title: data.title, + body: data.body, + icon: '/icon.png', + badge: '/badge.png' + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); +``` + +## Development + +### Available Scripts + +- `npm run build` - Build the TypeScript project +- `npm run start` - Start the production server +- `npm run test` - Run tests with coverage +- `npm run lint` - Run ESLint +- `npm run prettier` - Format code with Prettier + +### Project Structure + +``` +src/ +├── main.ts # Server entry point and Express setup +├── notificationService.ts # Push notification delivery logic +├── subscriptionService.ts # Subscription management +├── vapidService.ts # VAPID key generation and management +├── db.ts # Database service and TypeORM setup +├── worker.ts # Background worker for periodic tasks +├── Subscription.ts # Subscription entity model +└── VapidKeys.ts # VAPID keys entity model +``` + +## Security Considerations + +- **VAPID Keys**: Automatically generated and securely stored +- **Encryption**: All push payloads are encrypted using AES-128-GCM +- **Authentication**: JWT-based VAPID authentication headers +- **Database**: SQLite with proper entity validation +- **Input Validation**: TypeScript provides compile-time type safety + +## Monitoring and Logging + +The server includes comprehensive logging for: +- Subscription events (add/remove) +- Notification delivery attempts +- VAPID key operations +- Database operations +- Worker thread activities + +## Future Enhancements + +- [ ] Subscription muting functionality +- [ ] Subscription removal endpoint +- [ ] Notification templates +- [ ] Delivery analytics and metrics +- [ ] Rate limiting and throttling +- [ ] Multi-tenant support +- [ ] Webhook support for external integrations + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes following the coding standards +4. Add tests for new functionality +5. Submit a pull request + +## License + +Apache-2.0 License - see [LICENSE](LICENSE) file for details. + +## Author +**Matthew Raymer** - Lead Developer -## Thanks +--- -* [node-typescript-boilerplate][boilerplate] for project setup +*This middleware service was built specifically for Time Safari to provide reliable, secure, and cost-effective push notification delivery without dependency on third-party services.* -[boilerplate]: https://github.com/jsynowiec/node-typescript-boilerplate +**Last Updated:** 2025-07-23 +**Project Started:** 2023-09-06 diff --git a/src/Subscription.ts b/src/Subscription.ts index 6582514..ce5a296 100644 --- a/src/Subscription.ts +++ b/src/Subscription.ts @@ -1,20 +1,43 @@ -// Subscription.ts +/** + * @fileoverview Subscription entity for Time Safari push notifications + * + * This file defines the Subscription entity used by TypeORM for database persistence. + * It represents a push notification subscription with endpoint, encryption keys, + * and muting status. + * + * @author Matthew Raymer + * @version 1.0.0 + * @since 2023-09-06 + */ + import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +/** + * Entity class representing a push notification subscription + * + * @class Subscription + * @description TypeORM entity that maps to the subscriptions table in the database. + * Stores client subscription information including endpoint URLs and encryption keys. + */ @Entity() export class Subscription { + /** Unique identifier for the subscription */ @PrimaryGeneratedColumn() id: number; + /** The push service endpoint URL (e.g., FCM endpoint) */ @Column() endpoint: string; + /** P-256 ECDH public key for message encryption */ @Column() keys_p256dh: string; + /** Authentication secret for message integrity */ @Column() keys_auth: string; + /** Flag indicating if notifications are muted for this subscription */ @Column() muted: boolean = false; } diff --git a/src/VapidKeys.ts b/src/VapidKeys.ts index 1b939a2..9388fba 100644 --- a/src/VapidKeys.ts +++ b/src/VapidKeys.ts @@ -1,14 +1,35 @@ -// VapidKeys.ts +/** + * @fileoverview VAPID keys entity for Time Safari push notifications + * + * This file defines the VapidKeys entity used by TypeORM for database persistence. + * It represents a VAPID (Voluntary Application Server Identification) key pair + * used for authenticating push notification requests. + * + * @author Matthew Raymer + * @version 1.0.0 + * @since 2023-09-06 + */ + import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +/** + * Entity class representing a VAPID key pair + * + * @class VapidKeys + * @description TypeORM entity that maps to the vapid_keys table in the database. + * Stores VAPID public and private keys used for push notification authentication. + */ @Entity() export class VapidKeys { + /** Unique identifier for the VAPID key pair */ @PrimaryGeneratedColumn() id: number; + /** Base64-encoded public key for client-side subscription */ @Column() publicKey: string; + /** Base64-encoded private key for server-side authentication */ @Column() privateKey: string; } diff --git a/src/db.ts b/src/db.ts index cc28188..fe2db43 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,3 +1,14 @@ +/** + * @fileoverview Database service for Time Safari push notification middleware + * + * This service provides database connectivity and operations using TypeORM with SQLite. + * It handles the persistence of push subscriptions and VAPID keys, providing a + * singleton interface for database operations throughout the application. + * + * @author Matthew Raymer + * @version 1.0.0 + * @since 2023-09-06 + */ import { DataSource } from "typeorm"; import { Subscription } from './Subscription.js' @@ -6,28 +17,55 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); +/** + * Database service class for managing data persistence + * + * @class DBService + * @description Provides a singleton service for database operations including + * subscription management and VAPID key storage. Uses TypeORM with SQLite for + * lightweight, file-based data persistence. + */ class DBService { + /** Singleton instance of the database service */ private static instance: DBService; + /** TypeORM data source for database connectivity */ private dataSource: DataSource; + /** Flag indicating if the database is ready for operations */ public isReady = false; - + /** + * Private constructor to enforce singleton pattern and initialize database + * + * @private + * @description Initializes the TypeORM data source with SQLite configuration + * and sets up entity synchronization + */ private constructor() { + console.log('[DB] Initializing database service'); + this.dataSource = new DataSource({ type: "sqlite", database: "push_server", entities: [VapidKeys, Subscription], synchronize: true }); - this.dataSource.initialize().then(()=>{ - console.log("Initialized"); + + this.dataSource.initialize().then(() => { + console.log('[DB] Database initialized successfully'); this.isReady = true; - }).catch((err)=>{ - console.error(err); + }).catch((err) => { + console.error('[DB] Database initialization error:', err); }); } - + /** + * Gets the singleton instance of the database service + * + * @returns {DBService} The singleton instance + * @static + * @description Implements the singleton pattern to ensure only one database + * connection exists throughout the application + */ public static getInstance(): DBService { if (!DBService.instance) { DBService.instance = new DBService(); @@ -35,82 +73,149 @@ class DBService { return DBService.instance; } - - async saveSubscription(endpoint: string, keys_p256dh: string, keys_auth: string) { + /** + * Saves a new push subscription to the database + * + * @param {string} endpoint - The push service endpoint URL + * @param {string} keys_p256dh - The client's P-256 ECDH public key + * @param {string} keys_auth - The client's authentication secret + * @returns {Promise} The saved subscription entity + * @async + * @description Creates and persists a new subscription record in the database + */ + async saveSubscription(endpoint: string, keys_p256dh: string, keys_auth: string): Promise { + console.log('[DB] Saving subscription:', endpoint); + const subscription = new Subscription(); subscription.endpoint = endpoint; subscription.keys_auth = keys_auth; subscription.keys_p256dh = keys_p256dh; - return await this.dataSource.manager.save(subscription); + + const result = await this.dataSource.manager.save(subscription); + console.log('[DB] Subscription saved successfully'); + return result; } - + /** + * Retrieves all push subscriptions from the database + * + * @returns {Promise} Array of all stored subscriptions + * @async + * @description Fetches all subscription records for notification delivery + */ async getSubscriptions(): Promise { - let result = [ new Subscription ]; + console.log('[DB] Fetching all subscriptions'); + let result = [new Subscription()]; + if (this.isReady) { result = await this.dataSource.manager.find(Subscription); - + console.log('[DB] Retrieved', result.length, 'subscriptions'); } else { - console.log(__filename, "Database not ready.") - + console.log('[DB] Database not ready, returning empty result'); } + return result; } - + /** + * Removes a subscription from the database by endpoint + * + * @param {string} endpoint - The endpoint URL of the subscription to remove + * @returns {Promise} True if removal was successful, false otherwise + * @async + * @description Deletes a subscription record from the database + */ async removeSubscription(endpoint: string): Promise { + console.log('[DB] Removing subscription:', endpoint); let result = true; + if (this.isReady) { await this.dataSource.manager.delete(Subscription, { endpoint: endpoint }); - + console.log('[DB] Subscription removed successfully'); } else { + console.log('[DB] Database not ready, cannot remove subscription'); result = false; - } + return result; } - + /** + * Toggles the muted status of a subscription + * + * @param {string} endpoint - The endpoint URL of the subscription to toggle + * @returns {Promise} True if toggle was successful, false otherwise + * @async + * @description Updates the muted status of a subscription for notification filtering + */ async toggleMuteSubscription(endpoint: string): Promise { + console.log('[DB] Toggling mute for subscription:', endpoint); let result = true; + if (this.isReady) { - const subscription = await this.dataSource.manager.findOne(Subscription, { where : {endpoint: endpoint} }); - subscription.muted = !subscription.muted; - await this.dataSource.manager.save(subscription) + const subscription = await this.dataSource.manager.findOne(Subscription, { + where: { endpoint: endpoint } + }); + + if (subscription) { + subscription.muted = !subscription.muted; + await this.dataSource.manager.save(subscription); + console.log('[DB] Subscription mute toggled to:', subscription.muted); + } else { + console.log('[DB] Subscription not found for mute toggle'); + result = false; + } } else { + console.log('[DB] Database not ready, cannot toggle mute'); result = false; - } + return result; } - + /** + * Retrieves all VAPID keys from the database + * + * @returns {Promise} Array of stored VAPID keys + * @async + * @description Fetches all VAPID key records for authentication use + */ async getVapidKeys(): Promise { - console.log(__filename, "getVapidKeys", this.isReady); - let result = [ new VapidKeys() ]; - if ( this.isReady ) { + console.log('[DB] Fetching VAPID keys'); + let result = [new VapidKeys()]; + + if (this.isReady) { result = await this.dataSource.manager.find(VapidKeys); - console.log(__filename, "results of find: ", result); - + console.log('[DB] Retrieved', result.length, 'VAPID key(s)'); } else { - console.log(__filename, "Database is not ready"); - + console.log('[DB] Database is not ready, returning empty result'); } + return result; } - + /** + * Saves a new VAPID key pair to the database + * + * @param {string} publicKey - The base64-encoded public key + * @param {string} privateKey - The base64-encoded private key + * @returns {Promise} The saved VAPID keys entity + * @async + * @description Creates and persists a new VAPID key pair record in the database + */ async saveVapidKeys(publicKey: string, privateKey: string): Promise { + console.log('[DB] Saving VAPID keys'); let result = new VapidKeys(); result.privateKey = privateKey; result.publicKey = publicKey; - if ( this.isReady ) { + + if (this.isReady) { result = await this.dataSource.manager.save(result); - + console.log('[DB] VAPID keys saved successfully'); } else { - console.log(__filename, "Database is not ready."); - + console.log('[DB] Database is not ready, cannot save VAPID keys'); } + return result; } } diff --git a/src/main.ts b/src/main.ts index bcdf2ab..8e52377 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,15 @@ +/** + * @fileoverview Main server entry point for the Time Safari Push Notification Middleware + * + * This file contains the primary Express server setup, API route definitions, + * worker thread management, and server initialization logic. It serves as the + * central hub for the push notification middleware service. + * + * @author Matthew Raymer + * @version 1.0.0 + * @since 2023-09-06 + */ + import SubscriptionService from './subscriptionService.js'; import { Message, NotificationService } from './notificationService.js'; import { VapidKeys } from './VapidKeys.js'; @@ -14,24 +26,55 @@ import { dirname, join } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +/** + * Interface representing a push notification subscription from a client + * + * @interface Subscription + * @description Defines the structure of a push subscription request from client applications + */ export interface Subscription { + /** The push service endpoint URL (e.g., FCM endpoint) */ endpoint: string; + /** Encryption keys for secure push message delivery */ keys: { + /** P-256 ECDH public key for message encryption */ p256dh: string; + /** Authentication secret for message integrity */ auth: string; }; } +/** + * Main server class that handles HTTP requests, worker thread management, + * and coordinates all push notification services + * + * @class Server + * @description Central server class that orchestrates the push notification middleware + */ class Server { + /** Express application instance */ private app: Express; + /** Port number for the server to listen on */ private port: number; + /** Background worker thread for periodic tasks */ private worker?: Worker; + /** Service for managing push subscriptions */ private subscriptionService: SubscriptionService = SubscriptionService.getInstance(); + /** Service for sending push notifications */ private notificationService: NotificationService; - dbService: DBService = DBService.getInstance(); - vapidService: VapidService = VapidService.getInstance(); + /** Database service for data persistence */ + dbService: DBService = DBService.getInstance(); + /** Service for VAPID key management */ + vapidService: VapidService = VapidService.getInstance(); + /** Default notification message template */ private message: Message; + /** + * Creates a new Server instance and initializes all services + * + * @param {number} port - The port number to bind the server to + * @description Initializes Express app, sets up routes, starts worker thread, and configures listeners + */ constructor(port: number) { this.app = express(); this.port = port; @@ -42,28 +85,61 @@ class Server { this.setupWorkerListeners(); } - + /** + * Configures all API routes for the push notification service + * + * @private + * @description Sets up REST endpoints for subscription management and VAPID key retrieval + */ private setupRoutes(): void { + /** + * POST /subscribe - Register a new push notification subscription + * + * @param {Request} req - Express request object containing subscription data + * @param {Response} res - Express response object + * @description Accepts push subscription details and stores them in the database + */ this.app.post('/subscribe', async (req: Request, res: Response) => { const subscription = req.body as Subscription; await this.subscriptionService.addSubscription(subscription); res.status(201).send(); }); + /** + * POST /unsubscribe - Remove a push notification subscription (PLANNED) + * + * @param {Request} req - Express request object containing subscription data + * @param {Response} res - Express response object + * @description Currently returns 501 Not Implemented - planned for future release + */ this.app.post('/unsubscribe', async (req: Request, res: Response) => { const subscription = req.body as Subscription; - console.log(subscription); + console.log('[MAIN] Unsubscribe request received:', subscription); res.status(501).send(); }); + /** + * POST /mute - Toggle notification muting for a subscription (PLANNED) + * + * @param {Request} req - Express request object containing subscription data + * @param {Response} res - Express response object + * @description Currently returns 501 Not Implemented - planned for future release + */ this.app.post('/mute', async (req: Request, res: Response) => { const subscription = req.body as Subscription; - console.log(subscription); + console.log('[MAIN] Mute request received:', subscription); res.status(501).send(); }); + /** + * GET /vapid - Retrieve the public VAPID key for client-side subscription + * + * @param {Request} _ - Express request object (unused) + * @param {Response} res - Express response object + * @description Returns the public VAPID key needed for client push subscription + */ this.app.get('/vapid', async (_: Request, res: Response) => { const vapidkeys: VapidKeys[] = await this.vapidService.getVapidKeys(); const vapidkey = vapidkeys[0]; @@ -72,29 +148,58 @@ class Server { }); } - + /** + * Initializes and starts the background worker thread + * + * @private + * @description Creates a worker thread that handles periodic tasks like daily notifications + */ private startWorker(): void { const workerPath = join(__dirname,'./worker.js'); this.worker = new Worker(workerPath, { workerData: 'world' }); + /** + * Handles messages from the worker thread + * + * @param {string} message - Message from worker thread + * @description When worker sends notification trigger, broadcasts to all subscribers + */ this.worker.on('message', (message) => { - console.log(message); + console.log('[MAIN] Worker message received:', message); this.message = { "title": "Check TimeSafari"} as Message; this.notificationService.broadcast(this.message); }); + /** + * Handles worker thread errors + * + * @param {Error} error - Error object from worker thread + * @description Logs worker errors for debugging and monitoring + */ this.worker.on('error', (error) => { - console.error(`Worker error: ${error.message}`); + console.error('[MAIN] Worker error:', error.message); }); + /** + * Handles worker thread exit events + * + * @param {number} code - Exit code from worker thread + * @description Logs worker exit codes for monitoring worker health + */ this.worker.on('exit', (code) => { if (code !== 0) { - console.error(`Worker stopped with exit code ${code}`); + console.error(`[MAIN] Worker stopped with exit code ${code}`); } }); } + /** + * Sets up additional worker thread message listeners for data requests + * + * @private + * @description Handles worker requests for subscription data and responds accordingly + */ private setupWorkerListeners(): void { if (this.worker) { this.worker.on('message', (message) => { @@ -107,32 +212,45 @@ class Server { } } + /** + * Starts the HTTP server and begins listening for requests + * + * @public + * @description Binds the Express app to the specified port and logs server startup + */ public start(): void { this.app.listen(this.port, () => { - console.log(`Server is running on http://localhost:${this.port}`); + console.log(`[MAIN] Server is running on http://localhost:${this.port}`); }); } } - - - // Initialize and start the server - const server = new Server(3000); +/** + * Initializes VAPID keys if they don't exist in the database + * + * @async + * @description Checks for existing VAPID keys and creates new ones if none exist + */ const executeAsyncFunction = async () => { const keys: VapidKeys[] = await server.vapidService.getVapidKeys(); if (keys.length === 0) { + console.log('[MAIN] No VAPID keys found, creating new keys...'); await server.vapidService.createVAPIDKeys(); + console.log('[MAIN] VAPID keys created successfully'); + } else { + console.log('[MAIN] VAPID keys already exist'); } }; +// Execute VAPID key initialization after a 5-second delay setTimeout(() => { executeAsyncFunction().catch(error => { - // Handle any errors here - console.error('An error occurred:', error); + console.error('[MAIN] VAPID key initialization error:', error); }); -}, 5000); // Execute after a delay of 5 seconds +}, 5000); +// Start the server server.start(); diff --git a/src/notificationService.ts b/src/notificationService.ts index 310c0e3..202f80c 100644 --- a/src/notificationService.ts +++ b/src/notificationService.ts @@ -1,3 +1,15 @@ +/** + * @fileoverview Push notification delivery service for Time Safari + * + * This service handles the encryption and delivery of push notifications to + * subscribed clients. It implements the Web Push Protocol with VAPID authentication + * and AES-128-GCM encryption for secure message delivery. + * + * @author Matthew Raymer + * @version 1.0.0 + * @since 2023-09-06 + */ + import SubscriptionService from './subscriptionService.js'; import VapidService from './vapidService.js'; import { VapidKeys } from './VapidKeys.js'; @@ -6,44 +18,117 @@ import * as http_ece from 'http_ece'; import crypto from 'crypto'; import { Subscription } from "./Subscription.js" +/** + * Interface representing a push notification message + * + * @interface Message + * @description Defines the structure of a push notification payload + */ export interface Message { + /** The title of the notification */ title: string; + /** Optional body text for the notification */ body?: string; + /** Additional custom properties for the notification */ [key: string]: any; } +/** + * Service class responsible for sending push notifications to subscribed clients + * + * @class NotificationService + * @description Handles the encryption, authentication, and delivery of push notifications + * using the Web Push Protocol with VAPID authentication + */ export class NotificationService { + /** Service for managing push subscriptions */ private subscriptionService: SubscriptionService = SubscriptionService.getInstance(); + /** Service for VAPID key management and authentication */ private vapidService: VapidService = VapidService.getInstance(); + /** + * Creates a new NotificationService instance + * + * @description Initializes the notification service with required dependencies + */ constructor() { } + /** + * Generates a random salt for message encryption + * + * @param {number} length - The length of the salt in bytes (default: 16) + * @returns {Buffer} A cryptographically secure random salt + * @private + * @description Creates a random salt used in the AES-128-GCM encryption process + */ private generateSalt(length = 16): Buffer { return crypto.randomBytes(length); } + /** + * Sends a notification to all subscribed clients + * + * @param {Message} message - The notification message to broadcast + * @returns {Promise} Resolves when all notifications are sent + * @async + * @description Retrieves all active subscriptions and sends the message to each one + */ async broadcast(message: Message): Promise { + console.log('[NOTIFICATION] Broadcasting message to all subscribers:', message.title); const subscriptions = await this.subscriptionService.fetchSubscriptions(); for (const subscription of subscriptions) { - await this.pushToEndpoint(subscription, message); + try { + await this.pushToEndpoint(subscription, message); + console.log('[NOTIFICATION] Successfully sent to:', subscription.endpoint); + } catch (error) { + console.error('[NOTIFICATION] Failed to send to:', subscription.endpoint, error); + } } } - async sendNotification(subscription: Subscription, message: Message) { + /** + * Sends a notification to a specific client subscription + * + * @param {Subscription} subscription - The target subscription + * @param {Message} message - The notification message to send + * @returns {Promise} Resolves when the notification is sent + * @async + * @description Sends a single notification to a specific client endpoint + */ + async sendNotification(subscription: Subscription, message: Message): Promise { + console.log('[NOTIFICATION] Sending notification to specific subscription:', subscription.endpoint); await this.pushToEndpoint(subscription, message); } + /** + * Delivers a push notification to a specific endpoint + * + * @param {Subscription} subscription - The target subscription details + * @param {Message} message - The notification message payload + * @returns {Promise} Resolves when the HTTP request completes + * @private + * @async + * @description Handles the complete push notification delivery process including + * encryption, VAPID authentication, and HTTPS delivery + */ private async pushToEndpoint(subscription: Subscription, message: Message): Promise { const payload = JSON.stringify(message); + // Encrypt the payload using the client's public key const encrypted = this.encrypt(subscription.keys_p256dh, subscription.keys_auth, payload); const endpoint = subscription.endpoint; - const vapidHeaders = await this.vapidService.createVapidAuthHeader(endpoint, 12 * 60 * 60, 'mailto:example@example.com'); + // Generate VAPID authentication headers + const vapidHeaders = await this.vapidService.createVapidAuthHeader( + endpoint, + 12 * 60 * 60, // 12 hours expiration + 'mailto:example@example.com' + ); + // Parse the endpoint URL for HTTP request const parsedUrl = new URL(subscription.endpoint); const options: https.RequestOptions = { method: 'POST', @@ -52,8 +137,8 @@ export class NotificationService { port: parsedUrl.port, headers: { ...vapidHeaders, - 'TTL': '60', - 'Content-Encoding': 'aes128gcm', + 'TTL': '60', // Time to live in seconds + 'Content-Encoding': 'aes128gcm', // Encryption method 'Content-Type': 'application/octet-stream', 'Content-Length': encrypted.length }, @@ -62,14 +147,19 @@ export class NotificationService { return new Promise((resolve, reject) => { const req = https.request(options, (res) => { if (res.statusCode! >= 200 && res.statusCode! < 300) { + console.log('[NOTIFICATION] Push delivery successful:', res.statusCode); resolve(); } else { - reject(new Error(`Failed to send push notification. Status code: ${res.statusCode}`)); + const error = new Error(`Failed to send push notification. Status code: ${res.statusCode}`); + console.error('[NOTIFICATION] Push delivery failed:', error.message); + reject(error); } }); req.on('error', (error) => { - reject(new Error(`Failed to send push notification. Error: ${error.message}`)); + const errorMsg = `Failed to send push notification. Error: ${error.message}`; + console.error('[NOTIFICATION] HTTP request error:', errorMsg); + reject(new Error(errorMsg)); }); req.write(encrypted); @@ -77,13 +167,28 @@ export class NotificationService { }); } + /** + * Encrypts a message payload using the client's public key + * + * @param {string} publicKey - The client's P-256 ECDH public key + * @param {string} auth - The client's authentication secret + * @param {string} payload - The message payload to encrypt + * @returns {Buffer} The encrypted payload buffer + * @private + * @description Uses the http_ece library to encrypt the payload with AES-128-GCM + * using the client's public key and a random salt + */ private encrypt(publicKey: string, auth: string, payload: string): Buffer { + // Configure the http_ece library with client keys http_ece.keys = { 'p256dh': publicKey, 'auth': auth }; + // Get the server's VAPID keys for encryption const vapidKeys: VapidKeys = this.vapidService.getVapidKeys()[0]; + + // Encrypt the payload using AES-128-GCM return http_ece.encrypt(payload, { 'salt': this.generateSalt(), 'dh': vapidKeys.publicKey, diff --git a/src/subscriptionService.ts b/src/subscriptionService.ts index d9aed2d..5f78226 100644 --- a/src/subscriptionService.ts +++ b/src/subscriptionService.ts @@ -1,22 +1,67 @@ +/** + * @fileoverview Push subscription management service for Time Safari + * + * This service handles the registration, storage, and retrieval of push notification + * subscriptions. It provides a singleton interface for managing client subscriptions + * and integrates with the database service for persistence. + * + * @author Matthew Raymer + * @version 1.0.0 + * @since 2023-09-06 + */ + import DBService from './db.js'; import { Subscription } from './Subscription.js'; +/** + * Interface representing subscription data from client requests + * + * @interface SubscriptionData + * @description Defines the structure of subscription data received from client applications + */ export interface SubscriptionData { + /** The push service endpoint URL (e.g., FCM endpoint) */ endpoint: string; + /** Encryption keys for secure push message delivery */ keys: { + /** P-256 ECDH public key for message encryption */ p256dh: string; + /** Authentication secret for message integrity */ auth: string; }; } +/** + * Service class for managing push notification subscriptions + * + * @class SubscriptionService + * @description Provides a singleton service for adding, retrieving, and managing + * push notification subscriptions. Integrates with the database service for + * persistent storage of subscription data. + */ class SubscriptionService { + /** Singleton instance of the subscription service */ private static instance: SubscriptionService; + /** Database service for subscription persistence */ private dbService: DBService = DBService.getInstance(); + /** + * Private constructor to enforce singleton pattern + * + * @private + * @description Prevents direct instantiation of the service class + */ private constructor() { } - + /** + * Gets the singleton instance of the subscription service + * + * @returns {SubscriptionService} The singleton instance + * @static + * @description Implements the singleton pattern to ensure only one instance + * of the subscription service exists throughout the application + */ public static getInstance(): SubscriptionService { if (!SubscriptionService.instance) { SubscriptionService.instance = new SubscriptionService(); @@ -24,18 +69,36 @@ class SubscriptionService { return SubscriptionService.instance; } - + /** + * Adds a new push notification subscription to the database + * + * @param {SubscriptionData} subscription - The subscription data to store + * @returns {Promise} Resolves when the subscription is saved + * @async + * @description Saves a new client subscription with endpoint and encryption keys + */ async addSubscription(subscription: SubscriptionData): Promise { + console.log('[SUBSCRIPTION] Adding new subscription:', subscription.endpoint); await this.dbService.saveSubscription( subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth ); + console.log('[SUBSCRIPTION] Subscription added successfully'); } - + /** + * Retrieves all active push notification subscriptions + * + * @returns {Promise} Array of all stored subscriptions + * @async + * @description Fetches all subscriptions from the database for notification delivery + */ async fetchSubscriptions(): Promise { - return this.dbService.getSubscriptions(); + console.log('[SUBSCRIPTION] Fetching all subscriptions'); + const subscriptions = await this.dbService.getSubscriptions(); + console.log('[SUBSCRIPTION] Retrieved', subscriptions.length, 'subscriptions'); + return subscriptions; } } diff --git a/src/vapidService.ts b/src/vapidService.ts index 6545c7c..47d06b6 100644 --- a/src/vapidService.ts +++ b/src/vapidService.ts @@ -1,3 +1,14 @@ +/** + * @fileoverview VAPID key management service for Time Safari + * + * This service handles the generation, storage, and management of VAPID (Voluntary + * Application Server Identification) keys used for authenticating push notification + * requests. It provides secure key generation and JWT-based authentication headers. + * + * @author Matthew Raymer + * @version 1.0.0 + * @since 2023-09-06 + */ import { VapidKeys } from './VapidKeys.js'; import jwt from 'jsonwebtoken'; @@ -7,20 +18,50 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); +/** + * Interface representing VAPID key pair data + * + * @interface VapidKeyData + * @description Defines the structure of a VAPID key pair with public and private keys + */ export interface VapidKeyData { + /** Base64-encoded public key for client-side subscription */ publicKey: string; + /** Base64-encoded private key for server-side authentication */ privateKey: string; } +/** + * Service class for managing VAPID keys and authentication + * + * @class VapidService + * @description Provides a singleton service for generating, storing, and using VAPID + * keys for push notification authentication. Implements JWT-based authentication + * headers for secure push delivery. + */ class VapidService { + /** Singleton instance of the VAPID service */ private static instance: VapidService; + /** Database service for VAPID key persistence */ private dbService: DBService = DBService.getInstance(); - + /** + * Private constructor to enforce singleton pattern + * + * @private + * @description Prevents direct instantiation of the service class + */ private constructor() { } - + /** + * Gets the singleton instance of the VAPID service + * + * @returns {VapidService} The singleton instance + * @static + * @description Implements the singleton pattern to ensure only one instance + * of the VAPID service exists throughout the application + */ public static getInstance(): VapidService { if (!VapidService.instance) { VapidService.instance = new VapidService(); @@ -28,69 +69,106 @@ class VapidService { return VapidService.instance; } - + /** + * Creates and stores a new VAPID key pair + * + * @returns {Promise} The created VAPID keys entity + * @async + * @description Generates a new ECDH key pair and stores it in the database + */ public async createVAPIDKeys(): Promise { + console.log('[VAPID] Creating new VAPID keys'); let result = new VapidKeys(); - if ( this.dbService.isReady ) { + + if (this.dbService.isReady) { const keys = this.generateVAPIDKeys(); await this.dbService.saveVapidKeys(keys['publicKey'], keys['privateKey']); - + console.log('[VAPID] VAPID keys created and saved successfully'); } else { - console.log(__filename, "Database is not ready."); - + console.log('[VAPID] Database is not ready, cannot save VAPID keys'); } + return result; } - + /** + * Generates a new VAPID key pair using ECDH + * + * @returns {VapidKeyData} The generated public and private keys + * @private + * @description Creates a new ECDH key pair using the P-256 curve for VAPID authentication + */ private generateVAPIDKeys(): VapidKeyData { + console.log('[VAPID] Generating new ECDH key pair'); const ecdh = crypto.createECDH('prime256v1'); ecdh.generateKeys(); + const result = { publicKey: ecdh.getPublicKey().toString('base64'), privateKey: ecdh.getPrivateKey().toString('base64') }; - return result; - } - -/* - private async addVapidKeys(vapidkeys: VapidKeyData): Promise { - let result = new VapidKeys(); - const keys = await this.getVapidKeys(); - if (keys.length == 1 && typeof(keys[0].publicKey) == "undefined" ) { - result = await this.dbService.saveVapidKeys(vapidkeys.publicKey, vapidkeys.privateKey); - } + console.log('[VAPID] Key pair generated successfully'); return result; } -*/ + /** + * Retrieves all stored VAPID keys from the database + * + * @returns {Promise} Array of stored VAPID keys + * @async + * @description Fetches all VAPID keys from the database for authentication use + */ async getVapidKeys(): Promise { + console.log('[VAPID] Retrieving VAPID keys from database'); let result = await this.dbService.getVapidKeys(); + console.log('[VAPID] Retrieved', result.length, 'VAPID key(s)'); return result; } + /** + * Creates VAPID authentication headers for push notification requests + * + * @param {string} endpoint - The push service endpoint URL + * @param {number} expiration - JWT expiration time in seconds + * @param {string} subject - The subject identifier (usually email) + * @returns {Promise<{ 'Authorization': string, 'Crypto-Key': string }>} VAPID headers + * @async + * @description Generates JWT-based authentication headers required for push delivery + */ + async createVapidAuthHeader( + endpoint: string, + expiration: number, + subject: string + ): Promise<{ 'Authorization': string, 'Crypto-Key': string }> { + console.log('[VAPID] Creating authentication headers for endpoint:', endpoint); + + const vapidKeys = await this.getVapidKeys(); + const { publicKey, privateKey } = vapidKeys[0]; - async createVapidAuthHeader(endpoint: string, expiration: number, subject: string): Promise<{ 'Authorization': string, 'Crypto-Key': string }> { - const { publicKey, privateKey } = await this.getVapidKeys()[0]; - + // Create JWT payload for VAPID authentication const jwtInfo = { - aud: new URL(endpoint).origin, - exp: Math.floor((Date.now() / 1000) + expiration), - sub: subject + aud: new URL(endpoint).origin, // Audience (push service origin) + exp: Math.floor((Date.now() / 1000) + expiration), // Expiration time + sub: subject // Subject (contact email) }; + // Sign the JWT with the private key using ES256 algorithm const jwtToken = jwt.sign(jwtInfo, privateKey, { algorithm: 'ES256' }); - return { + const headers = { 'Authorization': `vapid t=${jwtToken}, k=${publicKey}`, 'Crypto-Key': publicKey }; + + console.log('[VAPID] Authentication headers created successfully'); + return headers; } } +// Self-executing function for initialization (currently empty) (async ()=> { - + // Future initialization logic can be added here })(); export default VapidService; \ No newline at end of file diff --git a/src/worker.ts b/src/worker.ts index 460a88b..1e15cfe 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,20 +1,62 @@ +/** + * @fileoverview Background worker thread for Time Safari push notifications + * + * This worker thread runs in the background and handles periodic tasks such as + * sending scheduled notifications. It communicates with the main thread via + * message passing to coordinate notification delivery. + * + * @author Matthew Raymer + * @version 1.0.0 + * @since 2023-09-06 + */ + import { parentPort } from 'worker_threads'; +/** + * Worker thread class for handling periodic background tasks + * + * @class WorkerThread + * @description Manages periodic tasks that run in a separate thread to avoid + * blocking the main server thread. Currently handles daily notification scheduling. + */ class WorkerThread { + /** Interval in milliseconds between task executions */ private interval: number; + /** + * Creates a new worker thread instance + * + * @param {number} interval - The interval in milliseconds between task executions + * @description Initializes the worker thread and starts the periodic task scheduler + */ constructor(interval: number) { this.interval = interval; this.startPeriodicTask(); + console.log('[WORKER] Worker thread initialized with interval:', interval, 'ms'); } + /** + * Starts the periodic task scheduler + * + * @private + * @description Sets up an interval timer that sends messages to the main thread + * at the specified interval. Currently triggers daily notifications. + */ private startPeriodicTask(): void { + console.log('[WORKER] Starting periodic task scheduler'); + setInterval(() => { if (parentPort) { - parentPort.postMessage("send notifications") + console.log('[WORKER] Sending notification trigger to main thread'); + parentPort.postMessage("send notifications"); + } else { + console.error('[WORKER] Parent port not available'); } }, this.interval); + + console.log('[WORKER] Periodic task scheduler started successfully'); } } -new WorkerThread(24*3600*1000); // pole once per day +// Initialize the worker thread with a 24-hour interval (daily notifications) +new WorkerThread(24 * 3600 * 1000); // Poll once per day