import { Event } from 'nostr-tools' import { nostrService } from './nostr' import { PLATFORM_NPUB } from './platformConfig' import type { SimplePoolWithSub } from '@/types/nostr-tools-extended' import type { ContentDeliveryTracking } from './platformTrackingTypes' import { buildTrackingEvent, getTrackingKind } from './platformTrackingEvents' import { parseTrackingEvent } from './platformTrackingQueries' export type { ContentDeliveryTracking } from './platformTrackingTypes' /** * Platform tracking service * Publishes tracking events on Nostr for content delivery verification * These events are signed by the platform and can be queried for audit purposes */ export class PlatformTrackingService { private readonly platformPubkey: string = PLATFORM_NPUB private async publishTrackingEvent(event: Event): Promise { // Publish to all active relays via websocketService (routes to Service Worker) const { websocketService } = await import('./websocketService') const { relaySessionManager } = await import('./relaySessionManager') const activeRelays = await relaySessionManager.getActiveRelays() if (activeRelays.length === 0) { // Fallback to primary relay if no active relays const { getPrimaryRelaySync } = await import('./config') const relayUrl = getPrimaryRelaySync() await websocketService.publishEvent(event, [relayUrl]) } else { // Publish to all active relays console.warn(`[PlatformTracking] Publishing tracking event ${event.id} to ${activeRelays.length} active relay(s)`) await websocketService.publishEvent(event, activeRelays) } } private validateTrackingPool(): { pool: SimplePoolWithSub; authorPubkey: string } | null { const pool = nostrService.getPool() if (!pool) { console.error('Pool not initialized for platform tracking') return null } const authorPubkey = nostrService.getPublicKey() if (!authorPubkey) { console.error('Author public key not available for tracking') return null } return { pool, authorPubkey } } /** * Publish a content delivery tracking event * This event is published by the author but tagged for platform tracking * The platform can query these events to track all content deliveries */ async trackContentDelivery( tracking: ContentDeliveryTracking, authorPrivateKey: string ): Promise { try { const validation = this.validateTrackingPool() if (!validation) { return null } const { authorPubkey } = validation const event = buildTrackingEvent(tracking, authorPubkey, authorPrivateKey, this.platformPubkey) await this.publishTrackingEvent(event) console.warn('Platform tracking event published', { eventId: event.id, articleId: tracking.articleId, recipientPubkey: tracking.recipientPubkey, messageEventId: tracking.messageEventId, authorAmount: tracking.authorAmount, platformCommission: tracking.platformCommission, timestamp: new Date().toISOString(), }) return event.id } catch (error) { console.error('Error publishing platform tracking event', { articleId: tracking.articleId, recipientPubkey: tracking.recipientPubkey, error: error instanceof Error ? error.message : 'Unknown error', timestamp: new Date().toISOString(), }) return null } } /** * Query tracking events for an article * Returns all delivery tracking events for a specific article * Uses websocketService to route events to Service Worker */ async getArticleDeliveries(articleId: string): Promise { try { const { getPrimaryRelaySync } = await import('./config') const relayUrl = getPrimaryRelaySync() return queryDeliveries({ relayUrl, filters: [buildArticleDeliveryFilter({ platformPubkey: this.platformPubkey, articleId })], }) } catch (error) { console.error('Error querying article deliveries', { articleId, error: error instanceof Error ? error.message : 'Unknown error', }) return [] } } /** * Query all deliveries for a recipient * Uses websocketService to route events to Service Worker */ async getRecipientDeliveries(recipientPubkey: string): Promise { try { const { getPrimaryRelaySync } = await import('./config') const relayUrl = getPrimaryRelaySync() return queryDeliveries({ relayUrl, filters: [buildRecipientDeliveryFilter({ platformPubkey: this.platformPubkey, recipientPubkey })], }) } catch (error) { console.error('Error querying recipient deliveries', { recipientPubkey, error: error instanceof Error ? error.message : 'Unknown error', }) return [] } } } export const platformTracking = new PlatformTrackingService() function buildArticleDeliveryFilter(params: { platformPubkey: string; articleId: string }): Record { return { kinds: [getTrackingKind()], '#p': [params.platformPubkey], '#article': [params.articleId], limit: 100, } } function buildRecipientDeliveryFilter(params: { platformPubkey: string; recipientPubkey: string }): Record { return { kinds: [getTrackingKind()], '#p': [params.platformPubkey], '#recipient': [params.recipientPubkey], limit: 100, } } async function queryDeliveries(params: { relayUrl: string filters: Record[] }): Promise { const { websocketService } = await import('./websocketService') const { swClient } = await import('./swClient') return createDeliveryQueryPromise({ websocketService, swClient, relayUrl: params.relayUrl, filters: params.filters }) } function createDeliveryQueryPromise(params: { websocketService: typeof import('./websocketService').websocketService swClient: typeof import('./swClient').swClient relayUrl: string filters: Record[] }): Promise { return new Promise((resolve) => { const state = createDeliveryQueryState({ resolve }) startDeliverySubscription({ websocketService: params.websocketService, relayUrl: params.relayUrl, filters: params.filters, state }) attachEoseListener({ swClient: params.swClient, relayUrl: params.relayUrl, state, timeoutMs: 5000 }) }) } function createDeliveryQueryState(params: { resolve: (value: ContentDeliveryTracking[]) => void }): { deliveries: ContentDeliveryTracking[] addDelivery: (delivery: ContentDeliveryTracking) => void finalize: () => void setUnsubscribe: (unsub: (() => void) | null) => void markEoseReceived: () => void isEoseReceived: () => boolean } { const deliveries: ContentDeliveryTracking[] = [] let resolved = false let unsubscribe: (() => void) | null = null let eoseReceived = false const finalize = (): void => { if (resolved) { return } resolved = true unsubscribe?.() params.resolve(deliveries) } return { deliveries, addDelivery: (delivery): void => { deliveries.push(delivery) }, finalize, setUnsubscribe: (unsub): void => { unsubscribe = unsub }, markEoseReceived: (): void => { eoseReceived = true }, isEoseReceived: (): boolean => eoseReceived, } } function startDeliverySubscription(params: { websocketService: typeof import('./websocketService').websocketService relayUrl: string filters: Record[] state: { addDelivery: (delivery: ContentDeliveryTracking) => void; setUnsubscribe: (unsub: (() => void) | null) => void } }): void { void params.websocketService.subscribe([params.relayUrl], params.filters, (event: Event) => { const delivery = parseTrackingEvent(event) if (delivery) { params.state.addDelivery(delivery) } }).then((unsub) => { params.state.setUnsubscribe(unsub) }) } function attachEoseListener(params: { swClient: typeof import('./swClient').swClient relayUrl: string timeoutMs: number state: { finalize: () => void; markEoseReceived: () => void; isEoseReceived: () => boolean } }): void { const unsubscribeEose = params.swClient.onMessage('WEBSOCKET_EOSE', (data: unknown): void => { const relays = readEoseRelays(data) if (relays && relays.includes(params.relayUrl) && !params.state.isEoseReceived()) { params.state.markEoseReceived() unsubscribeEose() params.state.finalize() } }) setTimeout(() => { if (!params.state.isEoseReceived()) { unsubscribeEose() params.state.finalize() } }, params.timeoutMs) } function readEoseRelays(data: unknown): string[] | null { if (typeof data !== 'object' || data === null) { return null } const maybe = data as { relays?: unknown } return Array.isArray(maybe.relays) && maybe.relays.every((r) => typeof r === 'string') ? (maybe.relays) : null }