import type { SzPlatformClient } from '../classes.platformclient.js'; import * as plugins from '../plugins.js'; export interface IServiceWebPushCredentials { typedUrl: string; credentialId: string; credentialSecret: string; } export interface IServiceWebPushConfigurationStatus { configured: boolean; typedUrl?: string; credentialIdConfigured: boolean; credentialSecretConfigured: boolean; missing: string[]; } export interface IServiceWebPushEnqueueOptions extends Omit< plugins.servezoneInterfaces.requests.webpush.IReq_EnqueueWebPush['request'], 'auth' | 'owner' > {} export type TServiceWebPushCancellationTarget = plugins.servezoneInterfaces.data.TWebPushCancellationTarget; /** * Credential-scoped Web Push delivery for a deployed service. * * This connector is separate from the legacy device-token push connector. It * only accepts the service credential injected into the workload and never * sends a caller-provided owner scope. */ export class SzWebPushConnector { public platformClientRef: SzPlatformClient; private webPushTypedSocket?: plugins.typedsocket.TypedSocket; private webPushTypedSocketUrl?: string; private webPushTypedSocketPromise?: Promise; private webPushTypedSocketAbortController?: AbortController; constructor(platformClientRefArg: SzPlatformClient) { this.platformClientRef = platformClientRefArg; } public async getConfigurationStatus(): Promise { const env = await this.readWebPushEnv(); const missing: string[] = []; if (!env.typedUrl) missing.push('WEB_PUSH_TYPED_URL'); if (!env.credentialId) missing.push('WEB_PUSH_API_CREDENTIAL_ID'); if (!env.credentialSecret) missing.push('WEB_PUSH_API_CREDENTIAL_SECRET'); return { configured: missing.length === 0, typedUrl: env.typedUrl ? this.normalizeTypedSocketUrl(env.typedUrl) : undefined, credentialIdConfigured: Boolean(env.credentialId), credentialSecretConfigured: Boolean(env.credentialSecret), missing, }; } public async getWebPushServiceStatus(): Promise { if (this.platformClientRef.debugMode) { return { ready: false, bindingId: 'debug', bindingStatus: 'disabled', retiringVapidKeys: [], maxPayloadBytes: 0, maxTtlSeconds: 0, contentEncoding: 'aes128gcm', message: 'Web Push is disabled in platformclient debug mode', }; } const { typedSocket, auth } = await this.getAuthenticatedClient(); const request = typedSocket.createTypedRequest< plugins.servezoneInterfaces.requests.webpush.IReq_GetWebPushServiceStatus >('getWebPushServiceStatus'); return await request.fire({ auth }); } public async getWebPushPublicKey(): Promise { const status = await this.getWebPushServiceStatus(); if (!status.ready || !status.activeVapidKey?.publicKey) { throw new Error(status.message || 'Web Push is not ready'); } return status.activeVapidKey.publicKey; } public async enqueueWebPush( optionsArg: IServiceWebPushEnqueueOptions, ): Promise { if (!optionsArg.idempotencyKey?.trim()) { throw new Error('idempotencyKey is required'); } if (!optionsArg.subscriptionId?.trim()) { throw new Error('subscriptionId is required'); } if (this.platformClientRef.debugMode) { return { accepted: true, spoolItemId: 'debug', message: 'Web Push accepted in platformclient debug mode', }; } const { typedSocket, auth } = await this.getAuthenticatedClient(); const request = typedSocket.createTypedRequest< plugins.servezoneInterfaces.requests.webpush.IReq_EnqueueWebPush >('enqueueWebPush'); return await request.fire({ auth, idempotencyKey: optionsArg.idempotencyKey.trim(), subscriptionId: optionsArg.subscriptionId.trim(), subscription: optionsArg.subscription, vapidKeyId: optionsArg.vapidKeyId, payload: optionsArg.payload, ttlSeconds: optionsArg.ttlSeconds, urgency: optionsArg.urgency, collapseKey: optionsArg.collapseKey, }); } public async cancelWebPush( targetArg: TServiceWebPushCancellationTarget, ): Promise { if (targetArg.type === 'spoolItem' && !targetArg.spoolItemId?.trim()) { throw new Error('spoolItemId is required'); } if (targetArg.type === 'subscription' && !targetArg.subscriptionId?.trim()) { throw new Error('subscriptionId is required'); } const target = targetArg.type === 'spoolItem' ? { type: 'spoolItem' as const, spoolItemId: targetArg.spoolItemId.trim() } : { type: 'subscription' as const, subscriptionId: targetArg.subscriptionId.trim() }; if (this.platformClientRef.debugMode) { return { success: true, cancelledCount: 0, alreadyTerminalCount: 0, message: 'Web Push cancellation accepted in platformclient debug mode', }; } const { typedSocket, auth } = await this.getAuthenticatedClient(); const request = typedSocket.createTypedRequest< plugins.servezoneInterfaces.requests.webpush.IReq_CancelWebPush >('cancelWebPush'); return await request.fire({ auth, target }); } public async getWebPushDeliveryStatus( spoolItemIdArg: string, ): Promise { const spoolItemId = String(spoolItemIdArg || '').trim(); if (!spoolItemId) { throw new Error('spoolItemId is required'); } if (this.platformClientRef.debugMode) { return { spoolItemId, state: 'queued', attempts: 0, acceptedAt: 0, updatedAt: 0, }; } const { typedSocket, auth } = await this.getAuthenticatedClient(); const request = typedSocket.createTypedRequest< plugins.servezoneInterfaces.requests.webpush.IReq_GetWebPushDeliveryStatus >('getWebPushDeliveryStatus'); const result = await request.fire({ auth, spoolItemId }); return result.delivery; } public async stop(): Promise { const socket = this.webPushTypedSocket; const socketPromise = this.webPushTypedSocketPromise; this.webPushTypedSocket = undefined; this.webPushTypedSocketPromise = undefined; this.webPushTypedSocketUrl = undefined; this.webPushTypedSocketAbortController?.abort(); this.webPushTypedSocketAbortController = undefined; if (socket) { await socket.stop(); return; } if (socketPromise) { try { const pendingSocket = await socketPromise; await pendingSocket.stop(); } catch { // A failed or aborted connection leaves no socket to close. } } } private async getAuthenticatedClient(): Promise<{ typedSocket: plugins.typedsocket.TypedSocket; auth: plugins.servezoneInterfaces.requests.webpush.IWebPushAppCredentialAuth; }> { const credentials = await this.getCredentials(); return { typedSocket: await this.getWebPushTypedSocket(credentials.typedUrl), auth: { credentialId: credentials.credentialId, credentialSecret: credentials.credentialSecret, }, }; } private async getCredentials(): Promise { const env = await this.readWebPushEnv(); if (!env.typedUrl) throw new Error('WEB_PUSH_TYPED_URL is required for Web Push'); if (!env.credentialId) { throw new Error('WEB_PUSH_API_CREDENTIAL_ID is required for Web Push'); } if (!env.credentialSecret) { throw new Error('WEB_PUSH_API_CREDENTIAL_SECRET is required for Web Push'); } return { typedUrl: env.typedUrl, credentialId: env.credentialId, credentialSecret: env.credentialSecret, }; } private async readWebPushEnv(): Promise<{ typedUrl?: string; credentialId?: string; credentialSecret?: string; }> { const directEnv = this.normalizeCredentialBundle({ typedUrl: await this.platformClientRef.getConfiguredEnvVar('WEB_PUSH_TYPED_URL'), credentialId: await this.platformClientRef.getConfiguredEnvVar( 'WEB_PUSH_API_CREDENTIAL_ID', ), credentialSecret: await this.platformClientRef.getConfiguredEnvVar( 'WEB_PUSH_API_CREDENTIAL_SECRET', ), }); if (this.hasCredentialBundleValue(directEnv)) { return directEnv; } return {}; } private normalizeCredentialBundle(bundleArg: { typedUrl?: string; credentialId?: string; credentialSecret?: string; }): Partial { const typedUrl = this.normalizeConfigValue(bundleArg.typedUrl); return { typedUrl: typedUrl ? this.normalizeTypedSocketUrl(typedUrl) || undefined : undefined, credentialId: this.normalizeConfigValue(bundleArg.credentialId), credentialSecret: this.normalizeConfigValue(bundleArg.credentialSecret), }; } private hasCredentialBundleValue(bundleArg: Partial): boolean { return Boolean( bundleArg.typedUrl || bundleArg.credentialId || bundleArg.credentialSecret, ); } private normalizeConfigValue(valueArg?: string): string | undefined { const value = valueArg?.trim(); return value || undefined; } private async getWebPushTypedSocket(urlArg: string): Promise { const normalizedUrl = this.normalizeTypedSocketUrl(urlArg); if (this.webPushTypedSocket && this.webPushTypedSocketUrl === normalizedUrl) { return this.webPushTypedSocket; } if (this.webPushTypedSocketPromise && this.webPushTypedSocketUrl === normalizedUrl) { return await this.webPushTypedSocketPromise; } if (this.webPushTypedSocket || this.webPushTypedSocketPromise) { await this.stop(); } this.webPushTypedSocketUrl = normalizedUrl; const abortController = new AbortController(); this.webPushTypedSocketAbortController = abortController; const socketPromise = plugins.typedsocket.TypedSocket.createClient( this.platformClientRef.typedrouter, normalizedUrl, { autoReconnect: true, abortSignal: abortController.signal }, ); this.webPushTypedSocketPromise = socketPromise; try { const socket = await socketPromise; if ( this.webPushTypedSocketPromise === socketPromise && this.webPushTypedSocketUrl === normalizedUrl ) { this.webPushTypedSocket = socket; } else { await socket.stop(); } return socket; } catch (error) { if (this.webPushTypedSocketPromise === socketPromise) { this.webPushTypedSocketPromise = undefined; this.webPushTypedSocketUrl = undefined; this.webPushTypedSocketAbortController = undefined; } throw error; } } private normalizeTypedSocketUrl(urlArg: string): string { return urlArg.trim().replace(/\/typedrequest\/?$/, '').replace(/\/+$/, ''); } }