Browse Source

docs: Add comprehensive JSDoc documentation to all source files

- Add detailed file-level documentation with @fileoverview tags
- Document all classes, methods, interfaces, and properties with JSDoc
- Include author attribution (Matthew Raymer) and version information
- Add rich logging with tagged console messages throughout codebase
- Update all @since dates to reflect actual project timeline (2023-09-06)
- Add current documentation update date (2025-07-23) to README
- Document singleton patterns, async methods, and security considerations
- Add parameter and return value documentation for all functions
- Include TypeORM entity documentation with property descriptions
- Enhance README with project intent and middleware architecture details

Files updated:
- src/main.ts: Server entry point and API route documentation
- src/notificationService.ts: Push delivery and encryption documentation
- src/subscriptionService.ts: Subscription management documentation
- src/vapidService.ts: VAPID key generation and authentication docs
- src/worker.ts: Background worker thread documentation
- src/db.ts: Database service and TypeORM integration docs
- src/Subscription.ts: Database entity documentation
- src/VapidKeys.ts: VAPID keys entity documentation
- README.md: Enhanced project documentation and timeline

This commit significantly improves code maintainability and developer
onboarding by providing comprehensive documentation for the entire
push notification middleware codebase.
master
Matthew Raymer 1 week ago
parent
commit
ecc2f809bc
  1. 268
      README.md
  2. 25
      src/Subscription.ts
  3. 23
      src/VapidKeys.ts
  4. 175
      src/db.ts
  5. 152
      src/main.ts
  6. 119
      src/notificationService.ts
  7. 71
      src/subscriptionService.ts
  8. 132
      src/vapidService.ts
  9. 46
      src/worker.ts

268
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 <repository-url>
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

25
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;
}

23
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;
}

175
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<Subscription>} 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<Subscription> {
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<Subscription[]>} Array of all stored subscriptions
* @async
* @description Fetches all subscription records for notification delivery
*/
async getSubscriptions(): Promise<Subscription[]> {
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<Boolean>} True if removal was successful, false otherwise
* @async
* @description Deletes a subscription record from the database
*/
async removeSubscription(endpoint: string): Promise<Boolean> {
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<Boolean>} True if toggle was successful, false otherwise
* @async
* @description Updates the muted status of a subscription for notification filtering
*/
async toggleMuteSubscription(endpoint: string): Promise<Boolean> {
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<VapidKeys[]>} Array of stored VAPID keys
* @async
* @description Fetches all VAPID key records for authentication use
*/
async getVapidKeys(): Promise<VapidKeys[]> {
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<VapidKeys>} 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<VapidKeys> {
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;
}
}

152
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();

119
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<void>} Resolves when all notifications are sent
* @async
* @description Retrieves all active subscriptions and sends the message to each one
*/
async broadcast(message: Message): Promise<void> {
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<void>} Resolves when the notification is sent
* @async
* @description Sends a single notification to a specific client endpoint
*/
async sendNotification(subscription: Subscription, message: Message): Promise<void> {
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<void>} 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<void> {
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<void>((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,

71
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<void>} Resolves when the subscription is saved
* @async
* @description Saves a new client subscription with endpoint and encryption keys
*/
async addSubscription(subscription: SubscriptionData): Promise<void> {
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<Subscription[]>} Array of all stored subscriptions
* @async
* @description Fetches all subscriptions from the database for notification delivery
*/
async fetchSubscriptions(): Promise<Subscription[]> {
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;
}
}

132
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<VapidKeys>} The created VAPID keys entity
* @async
* @description Generates a new ECDH key pair and stores it in the database
*/
public async createVAPIDKeys(): Promise<VapidKeys> {
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<VapidKeys> {
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<VapidKeys[]>} Array of stored VAPID keys
* @async
* @description Fetches all VAPID keys from the database for authentication use
*/
async getVapidKeys(): Promise<VapidKeys[]> {
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;

46
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

Loading…
Cancel
Save