/** * Service for polling NIP-34 repo announcements and auto-provisioning repos */ import { NostrClient } from './nostr-client.js'; import { KIND } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js'; import { RepoManager } from '../git/repo-manager.js'; import { OwnershipTransferService } from './ownership-transfer-service.js'; import { getCachedUserLevel } from '../security/user-level-cache.js'; import logger from '../logger.js'; import { extractCloneUrls } from '../../utils/nostr-utils.js'; export class RepoPollingService { private nostrClient: NostrClient; private repoManager: RepoManager; private pollingInterval: number; private intervalId: NodeJS.Timeout | null = null; private domain: string; private relays: string[]; private initialPollPromise: Promise | null = null; private isInitialPollComplete: boolean = false; constructor( relays: string[], repoRoot: string, domain: string, pollingInterval: number = 60000 // 1 minute ) { this.relays = relays; this.nostrClient = new NostrClient(relays); this.repoManager = new RepoManager(repoRoot, domain); this.pollingInterval = pollingInterval; this.domain = domain; } /** * Start polling for repo announcements * Returns a promise that resolves when the initial poll completes */ start(): Promise { if (this.intervalId) { this.stop(); } // Poll immediately and wait for it to complete this.initialPollPromise = this.poll(); // Then poll at intervals this.intervalId = setInterval(() => { this.poll(); }, this.pollingInterval); return this.initialPollPromise; } /** * Wait for initial poll to complete (useful for server startup) */ async waitForInitialPoll(): Promise { if (this.initialPollPromise) { await this.initialPollPromise; } } /** * Check if initial poll has completed */ isReady(): boolean { return this.isInitialPollComplete; } /** * Stop polling and cleanup resources */ stop(): void { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } // Close Nostr client connections if (this.nostrClient) { this.nostrClient.close(); } } /** * Trigger a manual poll (useful after user verification) */ async triggerPoll(): Promise { logger.info('Manual poll triggered'); return this.poll(); } /** * Poll for new repo announcements and provision repos */ private async poll(): Promise { try { logger.debug('Starting repo poll...'); const events = await this.nostrClient.fetchEvents([ { kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 } ]); // Filter for repos that list our domain const relevantEvents = events.filter(event => { // Skip local-only forks (synthetic announcements not published to Nostr) const isLocalOnly = event.tags.some(t => t[0] === 'local-only' && t[1] === 'true'); if (isLocalOnly) { return false; } const cloneUrls = this.extractCloneUrls(event); const listsDomain = cloneUrls.some(url => url.includes(this.domain)); if (listsDomain) { logger.debug({ eventId: event.id, pubkey: event.pubkey.slice(0, 16) + '...', cloneUrls: cloneUrls.slice(0, 3) // Log first 3 URLs }, 'Found repo announcement that lists this domain'); } return listsDomain; }); logger.info({ totalEvents: events.length, relevantEvents: relevantEvents.length, domain: this.domain }, 'Filtered repo announcements'); // Provision each repo for (const event of relevantEvents) { try { // Extract repo ID from d-tag const dTag = event.tags.find(t => t[0] === 'd')?.[1]; if (!dTag) { logger.warn({ eventId: event.id }, 'Repo announcement missing d-tag'); continue; } // Check if this is an existing repo or new repo const cloneUrls = this.extractCloneUrls(event); const domainUrl = cloneUrls.find(url => url.includes(this.domain)); if (!domainUrl) continue; const repoPath = this.repoManager.parseRepoUrl(domainUrl); if (!repoPath) continue; const repoExists = this.repoManager.repoExists(repoPath.fullPath); const isExistingRepo = repoExists; // Fetch self-transfer event for this repo const ownershipService = new OwnershipTransferService(this.relays); const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${event.pubkey}:${dTag}`; const selfTransferEvents = await this.nostrClient.fetchEvents([ { kinds: [KIND.OWNERSHIP_TRANSFER], '#a': [repoTag], authors: [event.pubkey], limit: 10 } ]); // Find self-transfer event (from owner to themselves) let selfTransferEvent: NostrEvent | undefined; for (const transferEvent of selfTransferEvents) { const pTag = transferEvent.tags.find(t => t[0] === 'p'); if (pTag && pTag[1] === event.pubkey) { // Decode npub if needed let toPubkey = pTag[1]; try { const { nip19 } = await import('nostr-tools'); const decoded = nip19.decode(toPubkey); if (decoded.type === 'npub') { toPubkey = decoded.data as string; } } catch { // Assume it's already hex } if (transferEvent.pubkey === event.pubkey && toPubkey === event.pubkey) { selfTransferEvent = transferEvent; break; } } } // For existing repos without self-transfer, create one retroactively if (isExistingRepo && !selfTransferEvent) { // Security: Truncate pubkey in logs const truncatedPubkey = event.pubkey.length > 16 ? `${event.pubkey.slice(0, 8)}...${event.pubkey.slice(-4)}` : event.pubkey; logger.info({ repoId: dTag, pubkey: truncatedPubkey }, 'Existing repo has no self-transfer event. Creating template for owner to sign and publish.'); try { // Create a self-transfer event template for the existing repo // The owner will need to sign and publish this to relays const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(event.pubkey, dTag); // Create an unsigned event template that can be included in the repo // This serves as a reference and the owner can use it to create the actual event const selfTransferTemplate = { ...initialOwnershipEvent, id: '', // Will be computed when signed sig: '', // Needs owner signature _note: 'This is a template. The owner must sign and publish this event to relays for it to be valid.' } as NostrEvent & { _note?: string }; // Use the template (even though it's unsigned, it will be included in the repo) selfTransferEvent = selfTransferTemplate; logger.warn({ repoId: dTag, pubkey: event.pubkey }, 'Self-transfer event template created. Owner should sign and publish it to relays.'); } catch (err) { logger.error({ error: err, repoId: dTag }, 'Failed to create self-transfer event template'); } } // Check if user has unlimited access before provisioning new repos // This prevents spam and abuse if (!isExistingRepo) { const userLevel = getCachedUserLevel(event.pubkey); const { hasUnlimitedAccess } = await import('../../utils/user-access.js'); const hasAccess = hasUnlimitedAccess(userLevel?.level); logger.debug({ eventId: event.id, pubkey: event.pubkey.slice(0, 16) + '...', cachedLevel: userLevel?.level || 'none', hasAccess, isExistingRepo }, 'Checking user access for repo provisioning'); if (!hasAccess) { logger.warn({ eventId: event.id, pubkey: event.pubkey.slice(0, 16) + '...', level: userLevel?.level || 'none', cacheExists: !!userLevel }, 'Skipping repo provisioning: user does not have unlimited access'); continue; } } // Provision the repo with self-transfer event if available await this.repoManager.provisionRepo(event, selfTransferEvent, isExistingRepo); logger.info({ eventId: event.id, isExistingRepo }, 'Provisioned repo from announcement'); } catch (error) { logger.error({ error, eventId: event.id }, 'Failed to provision repo from announcement'); } } // Mark initial poll as complete if (!this.isInitialPollComplete) { this.isInitialPollComplete = true; logger.info('Initial repo poll completed'); } } catch (error) { logger.error({ error }, 'Error polling for repo announcements'); // Still mark as complete even on error (to prevent blocking) if (!this.isInitialPollComplete) { this.isInitialPollComplete = true; logger.warn('Initial repo poll completed with errors'); } } } /** * Extract clone URLs from a NIP-34 repo announcement * Uses shared utility (without normalization) */ private extractCloneUrls(event: NostrEvent): string[] { return extractCloneUrls(event, false); } }