You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
278 lines
9.8 KiB
278 lines
9.8 KiB
/** |
|
* 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<void> | 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<void> { |
|
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<void> { |
|
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<void> { |
|
logger.info('Manual poll triggered'); |
|
return this.poll(); |
|
} |
|
|
|
/** |
|
* Poll for new repo announcements and provision repos |
|
*/ |
|
private async poll(): Promise<void> { |
|
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); |
|
} |
|
}
|
|
|