Browse Source
Nostr-Signature: 40f01e84f96661bb7fea13aa63c7da428118061b0a1470a11890d4f9cd6d685b 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc dbb6947defac6c7f92a3cf6f72352a94ffe2c4b33e65f8410518a40406c93f1f5a3e13e81f2f04f676d826e6cf03ec802328f5228300f80a8114fa3fd26eaeffmain
14 changed files with 12 additions and 494 deletions
@ -1,278 +0,0 @@ |
|||||||
/** |
|
||||||
* 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); |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,30 +0,0 @@ |
|||||||
/** |
|
||||||
* Shared utility for triggering repo polls |
|
||||||
* This provides a consistent interface for triggering polls from anywhere in the codebase |
|
||||||
*/ |
|
||||||
|
|
||||||
import { getRepoPollingService } from '../services/service-registry.js'; |
|
||||||
import logger from '../services/logger.js'; |
|
||||||
|
|
||||||
/** |
|
||||||
* Trigger a repo poll |
|
||||||
* This is the single source of truth for triggering polls |
|
||||||
* @param context Optional context string for logging (e.g., 'user-verification', 'manual-refresh') |
|
||||||
* @returns Promise that resolves when poll is triggered (not when it completes) |
|
||||||
*/ |
|
||||||
export async function triggerRepoPoll(context?: string): Promise<void> { |
|
||||||
const pollingService = getRepoPollingService(); |
|
||||||
|
|
||||||
if (!pollingService) { |
|
||||||
logger.warn({ context }, 'Poll request received but polling service not initialized'); |
|
||||||
throw new Error('Polling service not available'); |
|
||||||
} |
|
||||||
|
|
||||||
// Trigger poll asynchronously (non-blocking)
|
|
||||||
// The poll will complete in the background
|
|
||||||
pollingService.triggerPoll().catch((err) => { |
|
||||||
logger.error({ error: err, context }, 'Failed to trigger poll'); |
|
||||||
}); |
|
||||||
|
|
||||||
logger.info({ context }, 'Repo poll triggered'); |
|
||||||
} |
|
||||||
@ -1,33 +0,0 @@ |
|||||||
/** |
|
||||||
* API endpoint for manually triggering a repo poll |
|
||||||
* This allows users to refresh the repo list and trigger provisioning of new repos |
|
||||||
*
|
|
||||||
* This is the public API interface for triggering polls. |
|
||||||
* All poll triggers should go through this endpoint or the shared triggerRepoPoll utility. |
|
||||||
*/ |
|
||||||
|
|
||||||
import { json } from '@sveltejs/kit'; |
|
||||||
import type { RequestHandler } from './$types'; |
|
||||||
import { triggerRepoPoll } from '$lib/utils/repo-poll-trigger.js'; |
|
||||||
import { extractRequestContext } from '$lib/utils/api-context.js'; |
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => { |
|
||||||
const requestContext = extractRequestContext(event); |
|
||||||
const clientIp = requestContext.clientIp || 'unknown'; |
|
||||||
|
|
||||||
try { |
|
||||||
await triggerRepoPoll('api-endpoint'); |
|
||||||
|
|
||||||
return json({
|
|
||||||
success: true, |
|
||||||
message: 'Poll triggered successfully' |
|
||||||
}); |
|
||||||
} catch (err) { |
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err); |
|
||||||
|
|
||||||
return json({
|
|
||||||
success: false,
|
|
||||||
error: errorMessage
|
|
||||||
}, { status: err instanceof Error && errorMessage.includes('not available') ? 503 : 500 }); |
|
||||||
} |
|
||||||
}; |
|
||||||
Loading…
Reference in new issue