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.
310 lines
12 KiB
310 lines
12 KiB
/** |
|
* Service for checking repository maintainer permissions |
|
* Based on NIP-34 repository announcements |
|
*/ |
|
|
|
import { NostrClient } from './nostr-client.js'; |
|
import { KIND } from '../../types/nostr.js'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { nip19 } from 'nostr-tools'; |
|
import { OwnershipTransferService } from './ownership-transfer-service.js'; |
|
import type { Logger } from '../../types/logger.js'; |
|
import { isPrivateRepo as checkIsPrivateRepo } from '../../utils/repo-privacy.js'; |
|
|
|
// Lazy logger import to avoid initialization order issues |
|
let loggerCache: Logger | null = null; |
|
let loggerPromise: Promise<Logger> | null = null; |
|
|
|
const getLogger = async (): Promise<Logger> => { |
|
if (loggerCache) return loggerCache; |
|
if (!loggerPromise) { |
|
loggerPromise = import('../logger.js').then(m => { |
|
loggerCache = m.default; |
|
return loggerCache!; |
|
}).catch(() => { |
|
// Fallback console logger |
|
loggerCache = { |
|
info: (...args: unknown[]) => console.log('[INFO]', ...args), |
|
error: (...args: unknown[]) => console.error('[ERROR]', ...args), |
|
warn: (...args: unknown[]) => console.warn('[WARN]', ...args), |
|
debug: (...args: unknown[]) => console.debug('[DEBUG]', ...args), |
|
trace: (...args: unknown[]) => console.trace('[TRACE]', ...args), |
|
fatal: (...args: unknown[]) => console.error('[FATAL]', ...args) |
|
} as Logger; |
|
return loggerCache!; |
|
}); |
|
} |
|
return loggerPromise; |
|
}; |
|
|
|
export interface RepoPrivacyInfo { |
|
isPrivate: boolean; |
|
owner: string; |
|
maintainers: string[]; |
|
contributors: string[]; |
|
} |
|
|
|
export class MaintainerService { |
|
private nostrClient: NostrClient; |
|
private ownershipTransferService: OwnershipTransferService; |
|
private cache: Map<string, { maintainers: string[]; contributors: string[]; owner: string; timestamp: number; isPrivate: boolean }> = new Map(); |
|
private cacheTTL = 5 * 60 * 1000; // 5 minutes |
|
|
|
constructor(relays: string[]) { |
|
this.nostrClient = new NostrClient(relays); |
|
this.ownershipTransferService = new OwnershipTransferService(relays); |
|
} |
|
|
|
/** |
|
* Check if a repository is private |
|
* Uses shared utility to avoid code duplication |
|
*/ |
|
private isPrivateRepo(announcement: NostrEvent): boolean { |
|
return checkIsPrivateRepo(announcement); |
|
} |
|
|
|
/** |
|
* Get maintainers, contributors, and privacy info for a repository from NIP-34 announcement |
|
*/ |
|
async getMaintainers(repoOwnerPubkey: string, repoId: string): Promise<{ owner: string; maintainers: string[]; contributors: string[]; isPrivate: boolean }> { |
|
const cacheKey = `${repoOwnerPubkey}:${repoId}`; |
|
const cached = this.cache.get(cacheKey); |
|
|
|
// Return cached if still valid |
|
if (cached && Date.now() - cached.timestamp < this.cacheTTL) { |
|
return { owner: cached.owner, maintainers: cached.maintainers, contributors: cached.contributors, isPrivate: cached.isPrivate }; |
|
} |
|
|
|
try { |
|
// Fetch the repository announcement |
|
const events = await this.nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [repoOwnerPubkey], |
|
'#d': [repoId], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length === 0) { |
|
// If no announcement found, only the owner is a maintainer, and repo is public by default |
|
const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey], contributors: [], isPrivate: false }; |
|
this.cache.set(cacheKey, { ...result, timestamp: Date.now() }); |
|
return result; |
|
} |
|
|
|
const announcement = events[0]; |
|
|
|
// Check if repo is private |
|
const isPrivate = this.isPrivateRepo(announcement); |
|
|
|
// Get current owner from the most recent announcement file in the repo |
|
// Ownership is determined by what's checked into the git repository, not Nostr events |
|
const { nip19 } = await import('nostr-tools'); |
|
const npub = nip19.npubEncode(announcement.pubkey); |
|
const { fileManager } = await import('../../services/service-registry.js'); |
|
const currentOwner = await fileManager.getCurrentOwnerFromRepo(npub, repoId) || announcement.pubkey; |
|
|
|
const maintainers: string[] = [currentOwner]; // Current owner is always a maintainer |
|
const contributors: string[] = []; // Contributors can view but not modify |
|
|
|
// Extract maintainers from tags |
|
// Maintainers tag format: ['maintainers', 'pubkey1', 'pubkey2', 'pubkey3', ...] |
|
for (const tag of announcement.tags) { |
|
if (tag[0] === 'maintainers') { |
|
// Iterate through all maintainers in the tag (skip index 0 which is 'maintainers') |
|
for (let i = 1; i < tag.length; i++) { |
|
const maintainerValue = tag[i]; |
|
if (!maintainerValue || typeof maintainerValue !== 'string') { |
|
continue; |
|
} |
|
|
|
// Maintainers can be npub or hex pubkey |
|
let pubkey = maintainerValue; |
|
try { |
|
// Try to decode if it's an npub |
|
const decoded = nip19.decode(pubkey); |
|
if (decoded.type === 'npub') { |
|
pubkey = decoded.data as string; |
|
} |
|
} catch { |
|
// Assume it's already a hex pubkey |
|
} |
|
|
|
// Add maintainer if it's valid and not already in the list (case-insensitive check) |
|
if (pubkey && !maintainers.some(m => m.toLowerCase() === pubkey.toLowerCase())) { |
|
maintainers.push(pubkey); |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Extract contributors from tags |
|
// Contributors tag format: ['contributors', 'pubkey1', 'pubkey2', 'pubkey3', ...] |
|
for (const tag of announcement.tags) { |
|
if (tag[0] === 'contributors') { |
|
// Iterate through all contributors in the tag (skip index 0 which is 'contributors') |
|
for (let i = 1; i < tag.length; i++) { |
|
const contributorValue = tag[i]; |
|
if (!contributorValue || typeof contributorValue !== 'string') { |
|
continue; |
|
} |
|
|
|
// Contributors can be npub or hex pubkey |
|
let pubkey = contributorValue; |
|
try { |
|
// Try to decode if it's an npub |
|
const decoded = nip19.decode(pubkey); |
|
if (decoded.type === 'npub') { |
|
pubkey = decoded.data as string; |
|
} |
|
} catch { |
|
// Assume it's already a hex pubkey |
|
} |
|
|
|
// Add contributor if it's valid and not already in the list (case-insensitive check) |
|
// Also ensure they're not already a maintainer |
|
if (pubkey && |
|
!contributors.some(c => c.toLowerCase() === pubkey.toLowerCase()) && |
|
!maintainers.some(m => m.toLowerCase() === pubkey.toLowerCase())) { |
|
contributors.push(pubkey); |
|
} |
|
} |
|
} |
|
} |
|
|
|
const result = { owner: currentOwner, maintainers, contributors, isPrivate }; |
|
this.cache.set(cacheKey, { ...result, timestamp: Date.now() }); |
|
return result; |
|
} catch (error) { |
|
const logger = await getLogger(); |
|
logger.error({ error, repoOwnerPubkey, repoId }, 'Error fetching maintainers'); |
|
// Fallback: only owner is maintainer, repo is public by default |
|
const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey], contributors: [], isPrivate: false }; |
|
this.cache.set(cacheKey, { ...result, timestamp: Date.now() }); |
|
return result; |
|
} |
|
} |
|
|
|
/** |
|
* Check if a user is a maintainer of a repository |
|
*/ |
|
async isMaintainer(userPubkey: string, repoOwnerPubkey: string, repoId: string): Promise<boolean> { |
|
const { maintainers } = await this.getMaintainers(repoOwnerPubkey, repoId); |
|
return maintainers.includes(userPubkey); |
|
} |
|
|
|
/** |
|
* Check if a user can view a repository |
|
* Public repos: anyone can view |
|
* Private repos: only owners, maintainers, and contributors can view |
|
*/ |
|
async canView(userPubkey: string | null, repoOwnerPubkey: string, repoId: string): Promise<boolean> { |
|
const { isPrivate, maintainers, contributors, owner } = await this.getMaintainers(repoOwnerPubkey, repoId); |
|
const logger = await getLogger(); |
|
|
|
logger.debug({ |
|
isPrivate, |
|
repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...', |
|
currentOwner: owner.substring(0, 16) + '...', |
|
repoId, |
|
userPubkey: userPubkey ? userPubkey.substring(0, 16) + '...' : null, |
|
maintainerCount: maintainers.length, |
|
contributorCount: contributors.length |
|
}, 'canView check'); |
|
|
|
// Public repos are viewable by anyone |
|
if (!isPrivate) { |
|
logger.debug({ repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...', repoId }, 'Access granted: repo is public'); |
|
return true; |
|
} |
|
|
|
// Private repos require authentication |
|
if (!userPubkey) { |
|
logger.debug({ repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...', repoId }, 'Access denied: no user pubkey provided for private repo'); |
|
return false; |
|
} |
|
|
|
// Convert userPubkey to hex if needed |
|
let userPubkeyHex = userPubkey; |
|
try { |
|
const decoded = nip19.decode(userPubkey); |
|
if (decoded.type === 'npub') { |
|
userPubkeyHex = decoded.data as string; |
|
} |
|
} catch { |
|
// Assume it's already a hex pubkey |
|
} |
|
|
|
// Normalize to lowercase for comparison |
|
userPubkeyHex = userPubkeyHex.toLowerCase(); |
|
const normalizedMaintainers = maintainers.map(m => m.toLowerCase()); |
|
const normalizedContributors = contributors.map(c => c.toLowerCase()); |
|
const normalizedOwner = owner.toLowerCase(); |
|
|
|
logger.debug({ |
|
userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', |
|
normalizedOwner: normalizedOwner.substring(0, 16) + '...', |
|
maintainers: normalizedMaintainers.map(m => m.substring(0, 16) + '...'), |
|
contributors: normalizedContributors.map(c => c.substring(0, 16) + '...') |
|
}, 'Comparing pubkeys'); |
|
|
|
// Check if user is in maintainers list, contributors list, OR is the current owner |
|
const hasAccess = normalizedMaintainers.includes(userPubkeyHex) || |
|
normalizedContributors.includes(userPubkeyHex) || |
|
userPubkeyHex === normalizedOwner; |
|
|
|
if (!hasAccess) { |
|
logger.debug({ |
|
userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', |
|
currentOwner: normalizedOwner.substring(0, 16) + '...', |
|
repoId, |
|
maintainers: normalizedMaintainers.map(m => m.substring(0, 16) + '...'), |
|
contributors: normalizedContributors.map(c => c.substring(0, 16) + '...') |
|
}, 'Access denied: user not in maintainers/contributors list and not current owner'); |
|
} else { |
|
logger.debug({ |
|
userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', |
|
currentOwner: normalizedOwner.substring(0, 16) + '...', |
|
repoId |
|
}, 'Access granted: user is maintainer, contributor, or current owner'); |
|
} |
|
|
|
// Check if user is owner, maintainer, or contributor |
|
return hasAccess; |
|
} |
|
|
|
/** |
|
* Get privacy info for a repository |
|
*/ |
|
async getPrivacyInfo(repoOwnerPubkey: string, repoId: string): Promise<RepoPrivacyInfo> { |
|
const { owner, maintainers, contributors, isPrivate } = await this.getMaintainers(repoOwnerPubkey, repoId); |
|
return { isPrivate, owner, maintainers, contributors }; |
|
} |
|
|
|
/** |
|
* Check if a user is a contributor (can view but not modify) |
|
*/ |
|
async isContributor(userPubkey: string, repoOwnerPubkey: string, repoId: string): Promise<boolean> { |
|
const { contributors } = await this.getMaintainers(repoOwnerPubkey, repoId); |
|
// Convert userPubkey to hex if needed |
|
let userPubkeyHex = userPubkey; |
|
try { |
|
const decoded = nip19.decode(userPubkey); |
|
if (decoded.type === 'npub') { |
|
userPubkeyHex = decoded.data as string; |
|
} |
|
} catch { |
|
// Assume it's already a hex pubkey |
|
} |
|
return contributors.some(c => c.toLowerCase() === userPubkeyHex.toLowerCase()); |
|
} |
|
|
|
/** |
|
* Clear cache for a repository (useful after maintainer changes) |
|
*/ |
|
clearCache(repoOwnerPubkey: string, repoId: string): void { |
|
const cacheKey = `${repoOwnerPubkey}:${repoId}`; |
|
this.cache.delete(cacheKey); |
|
} |
|
}
|
|
|