Compare commits
No commits in common. '7bb1c8d66b2790c887f617705c728fb1ba4ce5e7' and 'e831e1f5b9a046fb7540a4f45523a54a7720efa0' have entirely different histories.
7bb1c8d66b
...
e831e1f5b9
17 changed files with 529 additions and 271 deletions
@ -0,0 +1,278 @@
@@ -0,0 +1,278 @@
|
||||
/** |
||||
* 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); |
||||
} |
||||
} |
||||
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
/** |
||||
* 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'); |
||||
} |
||||
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
/** |
||||
* 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