story-research-zapwall/lib/platformTracking.ts
2026-01-10 10:50:47 +01:00

264 lines
8.8 KiB
TypeScript

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<void> {
// 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<string | null> {
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<ContentDeliveryTracking[]> {
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<ContentDeliveryTracking[]> {
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<string, unknown> {
return {
kinds: [getTrackingKind()],
'#p': [params.platformPubkey],
'#article': [params.articleId],
limit: 100,
}
}
function buildRecipientDeliveryFilter(params: { platformPubkey: string; recipientPubkey: string }): Record<string, unknown> {
return {
kinds: [getTrackingKind()],
'#p': [params.platformPubkey],
'#recipient': [params.recipientPubkey],
limit: 100,
}
}
async function queryDeliveries(params: {
relayUrl: string
filters: Record<string, unknown>[]
}): Promise<ContentDeliveryTracking[]> {
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<string, unknown>[]
}): Promise<ContentDeliveryTracking[]> {
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<string, unknown>[]
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
}