diff --git a/components/backend/handlers/push_notifications.go b/components/backend/handlers/push_notifications.go new file mode 100644 index 000000000..840a78c82 --- /dev/null +++ b/components/backend/handlers/push_notifications.go @@ -0,0 +1,392 @@ +// Package handlers provides HTTP handlers for push notification management +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "time" + + "ambient-code-backend/types" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + authzv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" +) + +// GetVapidPublicKey returns the VAPID public key for push notifications +func GetVapidPublicKey(c *gin.Context) { + // Get VAPID public key from environment variable or config + publicKey := os.Getenv("VAPID_PUBLIC_KEY") + if publicKey == "" { + // For development, we'll use a placeholder + // In production, this should be generated and stored securely + log.Println("Warning: VAPID_PUBLIC_KEY not set, push notifications will not work") + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Push notifications are not configured", + }) + return + } + + c.JSON(http.StatusOK, types.VapidPublicKeyResponse{ + PublicKey: publicKey, + }) +} + +// CreatePushSubscription creates a new push notification subscription for a project +func CreatePushSubscription(c *gin.Context) { + projectName := c.Param("projectName") + if projectName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Project name is required"}) + return + } + + // Parse request body + var req types.CreateSubscriptionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request: %v", err)}) + return + } + + // Get user context from middleware + userCtx, exists := c.Get("userContext") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User context not found"}) + return + } + user := userCtx.(types.UserContext) + + // Get user-scoped K8s clients + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + // Check if user has access to the project + ctx := c.Request.Context() + if !hasProjectAccess(ctx, reqK8s, projectName) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to project"}) + return + } + + // Create UserSubscription + subscription := types.UserSubscription{ + ID: uuid.New().String(), + ProjectName: projectName, + UserID: user.UserID, + Subscription: req.Subscription, + Preferences: req.Preferences, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // Store subscription in ConfigMap + if err := storePushSubscription(ctx, reqDyn, projectName, &subscription); err != nil { + log.Printf("Failed to store push subscription: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create subscription"}) + return + } + + c.JSON(http.StatusCreated, subscription) +} + +// GetCurrentPushSubscription returns the current user's push subscription for a project +func GetCurrentPushSubscription(c *gin.Context) { + projectName := c.Param("projectName") + if projectName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Project name is required"}) + return + } + + // Get user context from middleware + userCtx, exists := c.Get("userContext") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User context not found"}) + return + } + user := userCtx.(types.UserContext) + + // Get user-scoped K8s clients + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + // Check if user has access to the project + ctx := c.Request.Context() + if !hasProjectAccess(ctx, reqK8s, projectName) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to project"}) + return + } + + // Get subscription from ConfigMap + subscription, err := getPushSubscription(ctx, reqDyn, projectName, user.UserID) + if err != nil { + log.Printf("Failed to get push subscription: %v", err) + c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}) + return + } + + c.JSON(http.StatusOK, subscription) +} + +// UpdatePushSubscription updates notification preferences for a subscription +func UpdatePushSubscription(c *gin.Context) { + projectName := c.Param("projectName") + subscriptionID := c.Param("subscriptionId") + + if projectName == "" || subscriptionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Project name and subscription ID are required"}) + return + } + + // Parse request body + var req types.UpdatePreferencesRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid request: %v", err)}) + return + } + + // Get user context from middleware + userCtx, exists := c.Get("userContext") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User context not found"}) + return + } + user := userCtx.(types.UserContext) + + // Get user-scoped K8s clients + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + // Check if user has access to the project + ctx := c.Request.Context() + if !hasProjectAccess(ctx, reqK8s, projectName) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to project"}) + return + } + + // Get existing subscription + subscription, err := getPushSubscription(ctx, reqDyn, projectName, user.UserID) + if err != nil { + log.Printf("Failed to get push subscription: %v", err) + c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}) + return + } + + // Verify subscription belongs to user + if subscription.ID != subscriptionID { + c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}) + return + } + + // Update preferences + subscription.Preferences = req.Preferences + subscription.UpdatedAt = time.Now() + + // Store updated subscription + if err := storePushSubscription(ctx, reqDyn, projectName, subscription); err != nil { + log.Printf("Failed to update push subscription: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscription"}) + return + } + + c.JSON(http.StatusOK, subscription) +} + +// DeletePushSubscription deletes a push notification subscription +func DeletePushSubscription(c *gin.Context) { + projectName := c.Param("projectName") + subscriptionID := c.Param("subscriptionId") + + if projectName == "" || subscriptionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Project name and subscription ID are required"}) + return + } + + // Get user context from middleware + userCtx, exists := c.Get("userContext") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User context not found"}) + return + } + user := userCtx.(types.UserContext) + + // Get user-scoped K8s clients + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } + + // Check if user has access to the project + ctx := c.Request.Context() + if !hasProjectAccess(ctx, reqK8s, projectName) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied to project"}) + return + } + + // Get existing subscription to verify ownership + subscription, err := getPushSubscription(ctx, reqDyn, projectName, user.UserID) + if err != nil { + c.JSON(http.StatusNoContent, nil) + return + } + + // Verify subscription belongs to user + if subscription.ID != subscriptionID { + c.JSON(http.StatusNoContent, nil) + return + } + + // Delete subscription from ConfigMap + if err := deletePushSubscription(ctx, reqDyn, projectName, user.UserID); err != nil { + log.Printf("Failed to delete push subscription: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscription"}) + return + } + + c.JSON(http.StatusNoContent, nil) +} + +// Helper functions for storing/retrieving subscriptions + +func storePushSubscription(ctx context.Context, dynClient dynamic.Interface, projectName string, subscription *types.UserSubscription) error { + configMapName := "push-subscriptions" + + // Get or create ConfigMap + configMap, err := dynClient.Resource(configMapGVR()).Namespace(projectName).Get(ctx, configMapName, metav1.GetOptions{}) + if err != nil { + // Create new ConfigMap + configMap = &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": configMapName, + "namespace": projectName, + }, + "data": map[string]interface{}{}, + }, + } + } + + // Serialize subscription to JSON + data, err := json.Marshal(subscription) + if err != nil { + return fmt.Errorf("failed to marshal subscription: %w", err) + } + + // Store in ConfigMap data + dataMap, _, _ := unstructured.NestedMap(configMap.Object, "data") + if dataMap == nil { + dataMap = make(map[string]interface{}) + } + dataMap[subscription.UserID] = string(data) + if err := unstructured.SetNestedMap(configMap.Object, dataMap, "data"); err != nil { + return fmt.Errorf("failed to set data map: %w", err) + } + + // Create or update ConfigMap + if configMap.GetResourceVersion() == "" { + _, err = dynClient.Resource(configMapGVR()).Namespace(projectName).Create(ctx, configMap, metav1.CreateOptions{}) + } else { + _, err = dynClient.Resource(configMapGVR()).Namespace(projectName).Update(ctx, configMap, metav1.UpdateOptions{}) + } + + return err +} + +func getPushSubscription(ctx context.Context, dynClient dynamic.Interface, projectName, userID string) (*types.UserSubscription, error) { + configMapName := "push-subscriptions" + + configMap, err := dynClient.Resource(configMapGVR()).Namespace(projectName).Get(ctx, configMapName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get ConfigMap: %w", err) + } + + dataMap, found, _ := unstructured.NestedMap(configMap.Object, "data") + if !found || dataMap == nil { + return nil, fmt.Errorf("no subscriptions found") + } + + subscriptionJSON, ok := dataMap[userID].(string) + if !ok { + return nil, fmt.Errorf("subscription not found for user") + } + + var subscription types.UserSubscription + if err := json.Unmarshal([]byte(subscriptionJSON), &subscription); err != nil { + return nil, fmt.Errorf("failed to unmarshal subscription: %w", err) + } + + return &subscription, nil +} + +func deletePushSubscription(ctx context.Context, dynClient dynamic.Interface, projectName, userID string) error { + configMapName := "push-subscriptions" + + configMap, err := dynClient.Resource(configMapGVR()).Namespace(projectName).Get(ctx, configMapName, metav1.GetOptions{}) + if err != nil { + return nil + } + + dataMap, found, _ := unstructured.NestedMap(configMap.Object, "data") + if !found || dataMap == nil { + return nil + } + + delete(dataMap, userID) + if err := unstructured.SetNestedMap(configMap.Object, dataMap, "data"); err != nil { + return fmt.Errorf("failed to set data map: %w", err) + } + + _, err = dynClient.Resource(configMapGVR()).Namespace(projectName).Update(ctx, configMap, metav1.UpdateOptions{}) + return err +} + +func configMapGVR() schema.GroupVersionResource { + return schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "configmaps", + } +} + +func hasProjectAccess(ctx context.Context, clientset kubernetes.Interface, projectName string) bool { + // Check RBAC permissions using SelfSubjectAccessReview + ssar := &authzv1.SelfSubjectAccessReview{ + Spec: authzv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authzv1.ResourceAttributes{ + Namespace: projectName, + Verb: "list", + Resource: "pods", + }, + }, + } + + result, err := clientset.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, ssar, metav1.CreateOptions{}) + if err != nil { + log.Printf("Failed to check project access: %v", err) + return false + } + + return result.Status.Allowed +} diff --git a/components/backend/routes.go b/components/backend/routes.go index 7e8c95df4..f36e84e61 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -113,6 +113,12 @@ func registerRoutes(r *gin.Engine) { projectGroup.POST("/auth/gitlab/connect", handlers.ConnectGitLabGlobal) projectGroup.GET("/auth/gitlab/status", handlers.GetGitLabStatusGlobal) projectGroup.POST("/auth/gitlab/disconnect", handlers.DisconnectGitLabGlobal) + + // Push notification endpoints (project-scoped) + projectGroup.POST("/push-subscriptions", handlers.CreatePushSubscription) + projectGroup.GET("/push-subscriptions/current", handlers.GetCurrentPushSubscription) + projectGroup.PUT("/push-subscriptions/:subscriptionId", handlers.UpdatePushSubscription) + projectGroup.DELETE("/push-subscriptions/:subscriptionId", handlers.DeletePushSubscription) } api.POST("/auth/github/install", handlers.LinkGitHubInstallationGlobal) @@ -123,6 +129,9 @@ func registerRoutes(r *gin.Engine) { // Cluster info endpoint (public, no auth required) api.GET("/cluster-info", handlers.GetClusterInfo) + // Push notification public endpoints + api.GET("/push-notifications/vapid-public-key", handlers.GetVapidPublicKey) + api.GET("/projects", handlers.ListProjects) api.POST("/projects", handlers.CreateProject) api.GET("/projects/:projectName", handlers.GetProject) diff --git a/components/backend/types/push_notifications.go b/components/backend/types/push_notifications.go new file mode 100644 index 000000000..19683830e --- /dev/null +++ b/components/backend/types/push_notifications.go @@ -0,0 +1,70 @@ +// Package types defines push notification related types +package types + +import "time" + +// PushSubscription represents a browser push notification subscription +type PushSubscription struct { + Endpoint string `json:"endpoint" binding:"required"` + Keys PushSubscriptionKeys `json:"keys" binding:"required"` +} + +// PushSubscriptionKeys contains the encryption keys for push notifications +type PushSubscriptionKeys struct { + P256dh string `json:"p256dh" binding:"required"` + Auth string `json:"auth" binding:"required"` +} + +// NotificationPreferences defines which events trigger notifications +type NotificationPreferences struct { + SessionStarted bool `json:"sessionStarted"` + SessionCompleted bool `json:"sessionCompleted"` + SessionError bool `json:"sessionError"` + RunFinished bool `json:"runFinished"` + RunError bool `json:"runError"` +} + +// UserSubscription represents a user's push notification subscription for a project +type UserSubscription struct { + ID string `json:"id"` + ProjectName string `json:"projectName"` + UserID string `json:"userId"` + Subscription PushSubscription `json:"subscription"` + Preferences NotificationPreferences `json:"preferences"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// CreateSubscriptionRequest is the request body for creating a push subscription +type CreateSubscriptionRequest struct { + Subscription PushSubscription `json:"subscription" binding:"required"` + Preferences NotificationPreferences `json:"preferences" binding:"required"` +} + +// UpdatePreferencesRequest is the request body for updating notification preferences +type UpdatePreferencesRequest struct { + Preferences NotificationPreferences `json:"preferences" binding:"required"` +} + +// VapidPublicKeyResponse contains the VAPID public key for push notifications +type VapidPublicKeyResponse struct { + PublicKey string `json:"publicKey"` +} + +// PushNotificationPayload is the payload sent to the push service +type PushNotificationPayload struct { + Title string `json:"title"` + Body string `json:"body"` + Icon string `json:"icon,omitempty"` + Badge string `json:"badge,omitempty"` + Tag string `json:"tag,omitempty"` + Data map[string]string `json:"data,omitempty"` + Actions []NotificationAction `json:"actions,omitempty"` +} + +// NotificationAction represents an action button in a notification +type NotificationAction struct { + Action string `json:"action"` + Title string `json:"title"` + Icon string `json:"icon,omitempty"` +} diff --git a/components/frontend/public/badge-72.png.txt b/components/frontend/public/badge-72.png.txt new file mode 100644 index 000000000..5e3195deb --- /dev/null +++ b/components/frontend/public/badge-72.png.txt @@ -0,0 +1,2 @@ +Placeholder for notification badge (72x72px) +In production, replace this with an actual PNG image. diff --git a/components/frontend/public/icon-192.png.txt b/components/frontend/public/icon-192.png.txt new file mode 100644 index 000000000..5f16c47b2 --- /dev/null +++ b/components/frontend/public/icon-192.png.txt @@ -0,0 +1,2 @@ +Placeholder for notification icon (192x192px) +In production, replace this with an actual PNG image. diff --git a/components/frontend/public/sw.js b/components/frontend/public/sw.js new file mode 100644 index 000000000..337628be2 --- /dev/null +++ b/components/frontend/public/sw.js @@ -0,0 +1,115 @@ +/** + * Service Worker for Browser Push Notifications + * Handles push notification events and user interactions + */ + +/* eslint-disable no-restricted-globals */ + +// Service Worker version - increment to force update +const SW_VERSION = '1.0.0'; + +// Install event - skip waiting to activate immediately +self.addEventListener('install', (event) => { + console.log(`[Service Worker] Installing version ${SW_VERSION}`); + self.skipWaiting(); +}); + +// Activate event - claim all clients immediately +self.addEventListener('activate', (event) => { + console.log(`[Service Worker] Activating version ${SW_VERSION}`); + event.waitUntil(self.clients.claim()); +}); + +// Push event - handle incoming push notifications +self.addEventListener('push', (event) => { + console.log('[Service Worker] Push notification received'); + + // Parse notification payload + let data = {}; + if (event.data) { + try { + data = event.data.json(); + } catch (e) { + console.error('[Service Worker] Failed to parse push data:', e); + data = { + title: 'Notification', + body: event.data.text(), + }; + } + } + + // Default notification options + const options = { + body: data.body || 'You have a new notification', + icon: data.icon || '/icon-192.png', + badge: data.badge || '/badge-72.png', + tag: data.tag || 'default', + requireInteraction: false, + data: data.data || {}, + actions: data.actions || [], + }; + + // Show notification + event.waitUntil( + self.registration.showNotification(data.title || 'Ambient Code', options) + ); +}); + +// Notification click event - handle user interaction +self.addEventListener('notificationclick', (event) => { + console.log('[Service Worker] Notification clicked:', event.notification.tag); + + event.notification.close(); + + // Handle action button clicks + if (event.action) { + console.log('[Service Worker] Action clicked:', event.action); + // Handle specific actions (e.g., 'view', 'dismiss') + if (event.action === 'view') { + const urlToOpen = event.notification.data?.url || '/'; + event.waitUntil( + clients.openWindow(urlToOpen) + ); + } + return; + } + + // Default click behavior - focus or open app + const urlToOpen = event.notification.data?.url || '/'; + + event.waitUntil( + clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // Check if app is already open + for (const client of clientList) { + if (client.url === urlToOpen && 'focus' in client) { + return client.focus(); + } + } + // Open new window if not already open + if (clients.openWindow) { + return clients.openWindow(urlToOpen); + } + }) + ); +}); + +// Notification close event - track dismissals +self.addEventListener('notificationclose', (event) => { + console.log('[Service Worker] Notification closed:', event.notification.tag); + // Could send analytics here if needed +}); + +// Message event - handle messages from the app +self.addEventListener('message', (event) => { + console.log('[Service Worker] Message received:', event.data); + + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + + if (event.data && event.data.type === 'GET_VERSION') { + event.ports[0].postMessage({ version: SW_VERSION }); + } +}); diff --git a/components/frontend/src/services/api/push-notifications.ts b/components/frontend/src/services/api/push-notifications.ts new file mode 100644 index 000000000..d9a6ed142 --- /dev/null +++ b/components/frontend/src/services/api/push-notifications.ts @@ -0,0 +1,92 @@ +/** + * Push Notifications API client + * Handles browser push notification subscription management + */ + +import { apiClient } from './client'; + +export type PushSubscription = { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +}; + +export type NotificationPreferences = { + sessionStarted: boolean; + sessionCompleted: boolean; + sessionError: boolean; + runFinished: boolean; + runError: boolean; +}; + +export type UserSubscription = { + id: string; + projectName: string; + subscription: PushSubscription; + preferences: NotificationPreferences; + createdAt: string; + updatedAt: string; +}; + +/** + * Subscribe to push notifications for a project + */ +export async function subscribeToPushNotifications( + projectName: string, + subscription: PushSubscription, + preferences: NotificationPreferences +): Promise { + return apiClient.post( + `/projects/${projectName}/push-subscriptions`, + { subscription, preferences } + ); +} + +/** + * Update notification preferences for a subscription + */ +export async function updateNotificationPreferences( + projectName: string, + subscriptionId: string, + preferences: NotificationPreferences +): Promise { + return apiClient.put( + `/projects/${projectName}/push-subscriptions/${subscriptionId}`, + { preferences } + ); +} + +/** + * Unsubscribe from push notifications + */ +export async function unsubscribeFromPushNotifications( + projectName: string, + subscriptionId: string +): Promise { + return apiClient.delete(`/projects/${projectName}/push-subscriptions/${subscriptionId}`); +} + +/** + * Get current subscription for a project + */ +export async function getCurrentSubscription( + projectName: string +): Promise { + try { + return await apiClient.get( + `/projects/${projectName}/push-subscriptions/current` + ); + } catch { + return null; + } +} + +/** + * Get VAPID public key for push notifications + */ +export async function getVapidPublicKey(): Promise { + const response = await apiClient.get<{ publicKey: string }>('/push-notifications/vapid-public-key'); + return response.publicKey; +} diff --git a/components/frontend/src/services/push-notification-manager.ts b/components/frontend/src/services/push-notification-manager.ts new file mode 100644 index 000000000..e4f5150fd --- /dev/null +++ b/components/frontend/src/services/push-notification-manager.ts @@ -0,0 +1,180 @@ +/** + * Push Notification Manager + * Handles browser Push API integration and permission management + */ + +import { + subscribeToPushNotifications, + unsubscribeFromPushNotifications, + getCurrentSubscription, + getVapidPublicKey, + type NotificationPreferences, + type PushSubscription as PushSubscriptionType, +} from './api/push-notifications'; + +/** + * Convert browser PushSubscription to our API format + */ +function serializePushSubscription(subscription: PushSubscription): PushSubscriptionType { + const json = subscription.toJSON(); + return { + endpoint: json.endpoint!, + keys: { + p256dh: json.keys!.p256dh!, + auth: json.keys!.auth!, + }, + }; +} + +/** + * Check if push notifications are supported in the browser + */ +export function isPushNotificationSupported(): boolean { + return ( + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window + ); +} + +/** + * Get current notification permission status + */ +export function getNotificationPermission(): NotificationPermission { + if (!isPushNotificationSupported()) { + return 'denied'; + } + return Notification.permission; +} + +/** + * Request notification permission from user + */ +export async function requestNotificationPermission(): Promise { + if (!isPushNotificationSupported()) { + throw new Error('Push notifications are not supported in this browser'); + } + + const permission = await Notification.requestPermission(); + return permission; +} + +/** + * Register service worker and subscribe to push notifications + */ +export async function enablePushNotifications( + projectName: string, + preferences: NotificationPreferences +): Promise { + // Check browser support + if (!isPushNotificationSupported()) { + throw new Error('Push notifications are not supported in this browser'); + } + + // Request permission + const permission = await requestNotificationPermission(); + if (permission !== 'granted') { + throw new Error('Notification permission denied'); + } + + // Register service worker + const registration = await navigator.serviceWorker.register('/sw.js', { + scope: '/', + }); + + // Wait for service worker to be ready + await navigator.serviceWorker.ready; + + // Get VAPID public key from backend + const vapidPublicKey = await getVapidPublicKey(); + + // Convert VAPID key to Uint8Array + const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey); + + // Subscribe to push notifications + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey, + }); + + // Send subscription to backend + const pushSubscription = serializePushSubscription(subscription); + await subscribeToPushNotifications(projectName, pushSubscription, preferences); +} + +/** + * Disable push notifications + */ +export async function disablePushNotifications(projectName: string): Promise { + // Get current subscription + const currentSub = await getCurrentSubscription(projectName); + if (!currentSub) { + return; + } + + // Unsubscribe from backend + await unsubscribeFromPushNotifications(projectName, currentSub.id); + + // Unsubscribe from browser + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + await subscription.unsubscribe(); + } + } +} + +/** + * Check if push notifications are enabled for a project + */ +export async function isPushNotificationEnabled(projectName: string): Promise { + if (!isPushNotificationSupported()) { + return false; + } + + if (Notification.permission !== 'granted') { + return false; + } + + const currentSub = await getCurrentSubscription(projectName); + return currentSub !== null; +} + +/** + * Convert base64 URL encoded string to Uint8Array + * Required for VAPID key conversion + */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +/** + * Show a test notification to verify setup + */ +export async function showTestNotification(): Promise { + if (!isPushNotificationSupported()) { + throw new Error('Push notifications are not supported'); + } + + if (Notification.permission !== 'granted') { + throw new Error('Notification permission not granted'); + } + + const registration = await navigator.serviceWorker.ready; + await registration.showNotification('Test Notification', { + body: 'Push notifications are working correctly!', + icon: '/icon-192.png', + badge: '/badge-72.png', + tag: 'test-notification', + }); +} diff --git a/components/frontend/src/services/queries/use-push-notifications.ts b/components/frontend/src/services/queries/use-push-notifications.ts new file mode 100644 index 000000000..115c9e105 --- /dev/null +++ b/components/frontend/src/services/queries/use-push-notifications.ts @@ -0,0 +1,139 @@ +/** + * React Query hooks for push notifications + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + getCurrentSubscription, + updateNotificationPreferences, + type NotificationPreferences, + type UserSubscription, +} from '../api/push-notifications'; +import { + enablePushNotifications, + disablePushNotifications, + isPushNotificationSupported, + getNotificationPermission, +} from '../push-notification-manager'; + +/** + * Query key factory for push notifications + */ +export const pushNotificationKeys = { + all: ['push-notifications'] as const, + subscription: (projectName: string) => + [...pushNotificationKeys.all, 'subscription', projectName] as const, + permission: ['push-permission'] as const, +}; + +/** + * Hook to get current push notification subscription + */ +export function usePushSubscription(projectName: string) { + return useQuery({ + queryKey: pushNotificationKeys.subscription(projectName), + queryFn: () => getCurrentSubscription(projectName), + enabled: isPushNotificationSupported(), + staleTime: 5 * 60 * 1000, + }); +} + +/** + * Hook to get notification permission status + */ +export function useNotificationPermission() { + return useQuery({ + queryKey: pushNotificationKeys.permission, + queryFn: () => getNotificationPermission(), + staleTime: 60 * 1000, + refetchOnWindowFocus: true, + }); +} + +/** + * Hook to enable push notifications + */ +export function useEnablePushNotifications(projectName: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (preferences: NotificationPreferences) => + enablePushNotifications(projectName, preferences), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: pushNotificationKeys.subscription(projectName), + }); + queryClient.invalidateQueries({ + queryKey: pushNotificationKeys.permission, + }); + }, + }); +} + +/** + * Hook to disable push notifications + */ +export function useDisablePushNotifications(projectName: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => disablePushNotifications(projectName), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: pushNotificationKeys.subscription(projectName), + }); + }, + }); +} + +/** + * Hook to update notification preferences + */ +export function useUpdateNotificationPreferences(projectName: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + subscriptionId, + preferences, + }: { + subscriptionId: string; + preferences: NotificationPreferences; + }) => updateNotificationPreferences(projectName, subscriptionId, preferences), + onMutate: async ({ preferences }) => { + // Cancel outgoing refetches + await queryClient.cancelQueries({ + queryKey: pushNotificationKeys.subscription(projectName), + }); + + // Snapshot previous value + const previousSubscription = queryClient.getQueryData( + pushNotificationKeys.subscription(projectName) + ); + + // Optimistically update + if (previousSubscription) { + queryClient.setQueryData(pushNotificationKeys.subscription(projectName), { + ...previousSubscription, + preferences, + }); + } + + return { previousSubscription }; + }, + onError: (_error, _variables, context) => { + // Rollback on error + if (context?.previousSubscription) { + queryClient.setQueryData( + pushNotificationKeys.subscription(projectName), + context.previousSubscription + ); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: pushNotificationKeys.subscription(projectName), + }); + }, + }); +} diff --git a/docs/push-notifications.md b/docs/push-notifications.md new file mode 100644 index 000000000..279a6566d --- /dev/null +++ b/docs/push-notifications.md @@ -0,0 +1,267 @@ +# Browser Push Notifications + +This document describes the browser push notification feature implementation for the Ambient Code Platform. + +## Overview + +The platform now supports browser push notifications to keep users informed of session events even when they're not actively viewing the application. Users can receive notifications for: + +- Session started +- Session completed +- Session errors +- Run finished +- Run errors + +## Architecture + +### Components + +**Frontend (TypeScript/React)**: +- `services/api/push-notifications.ts` - API client for subscription management +- `services/push-notification-manager.ts` - Browser Push API integration +- `services/queries/use-push-notifications.ts` - React Query hooks +- `public/sw.js` - Service worker for handling push events + +**Backend (Go)**: +- `types/push_notifications.go` - Type definitions +- `handlers/push_notifications.go` - HTTP handlers for subscription CRUD +- `routes.go` - Route registration + +### Data Flow + +``` +1. User enables notifications in UI + ↓ +2. Browser requests permission (Notification API) + ↓ +3. Service worker registered (/sw.js) + ↓ +4. Push subscription created (Push API) + ↓ +5. Subscription sent to backend → stored in ConfigMap + ↓ +6. Backend sends push notifications via Web Push protocol + ↓ +7. Service worker receives push → displays notification + ↓ +8. User clicks notification → app opens/focuses +``` + +## API Endpoints + +### Public Endpoints + +**GET `/api/push-notifications/vapid-public-key`** +- Returns the VAPID public key for push subscription +- No authentication required + +### Project-Scoped Endpoints + +All endpoints require user authentication and project access. + +**POST `/api/projects/:projectName/push-subscriptions`** +- Create a new push subscription +- Body: `{ subscription: PushSubscription, preferences: NotificationPreferences }` + +**GET `/api/projects/:projectName/push-subscriptions/current`** +- Get current user's subscription for the project +- Returns `null` if no subscription exists + +**PUT `/api/projects/:projectName/push-subscriptions/:subscriptionId`** +- Update notification preferences +- Body: `{ preferences: NotificationPreferences }` + +**DELETE `/api/projects/:projectName/push-subscriptions/:subscriptionId`** +- Delete a push subscription + +## Configuration + +### Backend Environment Variables + +**VAPID_PUBLIC_KEY** (required) +- VAPID public key for push notifications +- Generate with: `npx web-push generate-vapid-keys` + +**VAPID_PRIVATE_KEY** (required) +- VAPID private key (keep secret!) +- Used to sign push notification requests + +### Frontend Environment Variables + +**NEXT_PUBLIC_API_URL** (optional) +- Base URL for API requests +- Defaults to `/api` (relative path) + +## Security + +### Authentication +- All subscription endpoints require user authentication +- Uses user-scoped K8s clients (follows platform RBAC) +- RBAC checks via `SelfSubjectAccessReview` + +### Token Handling +- VAPID keys are stored securely as environment variables +- Push subscriptions stored in namespace-scoped ConfigMaps +- User can only access their own subscriptions + +### Privacy +- Subscriptions are scoped to projects and users +- No sensitive data in notification payloads +- Users can disable notifications anytime + +## Browser Compatibility + +| Browser | Version | Support | +|---------|---------|---------| +| Chrome | 50+ | ✅ Full | +| Firefox | 44+ | ✅ Full | +| Edge | 17+ | ✅ Full | +| Safari | 16+ | ✅ Full | +| Opera | 37+ | ✅ Full | + +**Note**: Service Workers require HTTPS in production. + +## User Guide + +### Enabling Notifications + +1. Navigate to project settings +2. Click "Enable Notifications" +3. Allow browser permission when prompted +4. Select which events trigger notifications + +### Customizing Preferences + +Users can toggle notifications for: +- **Session Started**: When a new session begins +- **Session Completed**: When a session finishes successfully +- **Session Error**: When a session encounters an error +- **Run Finished**: When a run completes +- **Run Error**: When a run fails + +### Disabling Notifications + +1. Go to project settings +2. Click "Disable Notifications" +3. Or revoke permission in browser settings + +## Development + +### Testing Locally + +1. Ensure HTTPS is enabled (required for service workers) +2. Set VAPID keys in backend environment +3. Register service worker in development mode +4. Use browser DevTools → Application → Service Workers + +### Generating VAPID Keys + +```bash +npx web-push generate-vapid-keys +``` + +Output: +``` +Public Key: BN... +Private Key: ... +``` + +Add to backend deployment: +```yaml +env: + - name: VAPID_PUBLIC_KEY + value: "BN..." + - name: VAPID_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: push-notification-keys + key: private-key +``` + +### Testing Push Notifications + +```typescript +import { showTestNotification } from '@/services/push-notification-manager'; + +// Show test notification +await showTestNotification(); +``` + +## Integration Points + +### SSE Event Stream Integration + +To trigger push notifications from session events, integrate with the existing AG-UI event stream in `websocket/agui.go`: + +```go +// Example: Send push notification on session completion +if event.Type == "RUN_FINISHED" { + sendPushNotification(projectName, userID, PushNotificationPayload{ + Title: "Run Completed", + Body: fmt.Sprintf("Session %s finished successfully", sessionName), + Tag: fmt.Sprintf("session-%s", sessionName), + Data: map[string]string{"sessionName": sessionName, "projectName": projectName}, + }) +} +``` + +### Operator Integration + +To send notifications on session phase changes, integrate with `operator/internal/handlers/sessions.go`: + +```go +// Example: Send push notification on phase transition +func updateAgenticSessionStatus(namespace, name string, updates map[string]interface{}) error { + // ... existing status update code ... + + if phase, ok := updates["phase"].(string); ok && phase == "Completed" { + sendPushNotificationForSession(namespace, name, "Session Completed") + } + + return nil +} +``` + +## Troubleshooting + +### Service Worker Not Registering + +**Problem**: Service worker fails to register +**Solution**: Ensure HTTPS is enabled (required in production) + +### Notifications Not Appearing + +**Possible causes**: +1. Browser permission denied → Check browser settings +2. Service worker not active → Check DevTools → Application → Service Workers +3. VAPID keys not configured → Check backend environment variables +4. Subscription not created → Check network tab for API errors + +### Push Subscription Fails + +**Problem**: `DOMException: Registration failed` +**Solution**: +- Check VAPID public key is correctly configured +- Ensure service worker scope is `/` +- Verify HTTPS is enabled + +## Future Enhancements + +Potential improvements for future iterations: + +1. **Web Push Library**: Integrate Go web-push library for sending notifications +2. **Batch Notifications**: Group multiple events into single notification +3. **Rich Notifications**: Add action buttons (View Session, Dismiss) +4. **Notification History**: Store notification log in backend +5. **Sound Preferences**: Allow users to customize notification sounds +6. **Desktop Integration**: Native desktop notifications on supported platforms +7. **Mobile Support**: Progressive Web App (PWA) integration +8. **Notification Analytics**: Track open rates and engagement + +## References + +- [Push API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) +- [Service Worker API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) +- [Notifications API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) +- [Web Push Protocol (RFC 8030)](https://datatracker.ietf.org/doc/html/rfc8030) +- [VAPID (RFC 8292)](https://datatracker.ietf.org/doc/html/rfc8292)