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

/**
* 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);
}
}