diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 2a43279..3828f12 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -80,3 +80,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771999938,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","verify button for cloned repos"]],"content":"Signed commit: verify button for cloned repos","id":"4710ea5de6287e00b5da9a6d7cd6568901e3db45a71476b56dc83ec39b8be73d","sig":"7613ca0847af4eb1fd3f52ef0f59c8f6316ba75605085da8eb0a64ced6fe43897d6af26b84d218155ab61ab8e1b42cbc2a686f2eab9572734fb7d911961d3e85"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772000347,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","added status to patches\nrenamed chat-relay to project-relay"]],"content":"Signed commit: added status to patches\nrenamed chat-relay to project-relay","id":"3c717ed3935bf95a70a0e9ffbe655728d325f72e8cbeb3d38da37b1b6e1304a2","sig":"952584bfe718362864fdf117bb4c4b042dbea9fe2307bca2f94a9004394bb6fdb3f4f4acd6714bcfdb32453a9d09d24e2c97f512bc1b06e1ba3cd50556f67b6e"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772002202,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","improving commit signing and verification"]],"content":"Signed commit: improving commit signing and verification","id":"c149ee64445a63b9a471d1866df86d702fe3fead1049a8e3272ea76a25f11094","sig":"f0745d02cb1b2ac012feb5e38cd4917eb9af48338eb13626aedae6ce73025758b2debe6874c5af3a4e252241405fdaa91042a031fa56c4fe0257c978d23babb2"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772003001,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix local cloning"]],"content":"Signed commit: fix local cloning","id":"0e7b4f06276988a2caf1c8fa9f6ba4a1cb683033c0714cc88699e3a4bda67d68","sig":"3c46ff9412a72f3ca39d216d6bd2eee7b9f70331fe8c0d557ee8339be4c05d03fe949e3aaef6e29126d4174b9f6d10de9e605273918106b9d40bc81cfaa1d290"} diff --git a/src/lib/services/nostr/issues-service.ts b/src/lib/services/nostr/issues-service.ts index bb69858..250a050 100644 --- a/src/lib/services/nostr/issues-service.ts +++ b/src/lib/services/nostr/issues-service.ts @@ -159,7 +159,8 @@ export class IssuesService { issueAuthor: string, repoOwnerPubkey: string, repoId: string, - status: 'open' | 'closed' | 'resolved' | 'draft' + status: 'open' | 'closed' | 'resolved' | 'draft', + relays?: string[] ): Promise { const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); @@ -194,8 +195,14 @@ export class IssuesService { pubkey: '' }); - const result = await this.nostrClient.publishEvent(event, this.relays); - if (result.failed.length > 0 && result.success.length === 0) { + const targetRelays = relays || this.relays; + // If relays array is empty, don't publish (private visibility) + const result = targetRelays.length > 0 + ? await this.nostrClient.publishEvent(event, targetRelays) + : { success: [], failed: [] }; + + // Only throw error if we tried to publish and all failed + if (targetRelays.length > 0 && result.failed.length > 0 && result.success.length === 0) { throw new Error('Failed to publish status update to all relays'); } diff --git a/src/lib/services/nostr/prs-service.ts b/src/lib/services/nostr/prs-service.ts index c2e821b..d4ae660 100644 --- a/src/lib/services/nostr/prs-service.ts +++ b/src/lib/services/nostr/prs-service.ts @@ -150,7 +150,8 @@ export class PRsService { repoOwnerPubkey: string, repoId: string, status: 'open' | 'merged' | 'closed' | 'draft', - mergeCommitId?: string + mergeCommitId?: string, + relays?: string[] ): Promise { const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); @@ -193,8 +194,14 @@ export class PRsService { pubkey: '' }); - const result = await this.nostrClient.publishEvent(event, this.relays); - if (result.failed.length > 0 && result.success.length === 0) { + const targetRelays = relays || this.relays; + // If relays array is empty, don't publish (private visibility) + const result = targetRelays.length > 0 + ? await this.nostrClient.publishEvent(event, targetRelays) + : { success: [], failed: [] }; + + // Only throw error if we tried to publish and all failed + if (targetRelays.length > 0 && result.failed.length > 0 && result.success.length === 0) { throw new Error('Failed to publish status update to all relays'); } diff --git a/src/lib/utils/repo-privacy.ts b/src/lib/utils/repo-privacy.ts index 25a0f1d..db73903 100644 --- a/src/lib/utils/repo-privacy.ts +++ b/src/lib/utils/repo-privacy.ts @@ -5,6 +5,7 @@ import { nip19 } from 'nostr-tools'; import { DEFAULT_NOSTR_RELAYS } from '../config.js'; import type { NostrEvent } from '../types/nostr.js'; +import { isPrivateRepo as checkVisibility } from './repo-visibility.js'; // Lazy initialization to avoid initialization order issues let maintainerServiceInstance: import('../services/nostr/maintainer-service.js').MaintainerService | null = null; @@ -19,24 +20,13 @@ const getMaintainerService = async (): Promise t[0] === 'private' && t[1] === 'true'); - if (privateTag) return true; - - // Check for ["private"] tag (just the tag name, no value) - const privateTagOnly = announcement.tags.find(t => t[0] === 'private' && (!t[1] || t[1] === '')); - if (privateTagOnly) return true; - - // Check for ["t", "private"] tag (topic tag) - const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private'); - if (topicTag) return true; - - return false; + // Use the new visibility system + return checkVisibility(announcement); } /** diff --git a/src/lib/utils/repo-visibility.ts b/src/lib/utils/repo-visibility.ts new file mode 100644 index 0000000..d51b861 --- /dev/null +++ b/src/lib/utils/repo-visibility.ts @@ -0,0 +1,87 @@ +/** + * Repository visibility and relay publishing utilities + * + * Visibility levels: + * - public: Repo is public, events published to all relays + project-relay + * - unlisted: Repo is public, events published to project-relay only + * - restricted: Repo is private, events published to project-relay only + * - private: Repo is private, events not published to relays (git-only, stored in repo) + */ + +import type { NostrEvent } from '../types/nostr.js'; +import { DEFAULT_NOSTR_RELAYS } from '../config.js'; + +export type VisibilityLevel = 'public' | 'unlisted' | 'restricted' | 'private'; + +/** + * Get visibility level from repository announcement + * Defaults to 'public' if not specified + */ +export function getVisibility(announcement: NostrEvent): VisibilityLevel { + const visibilityTag = announcement.tags.find(t => t[0] === 'visibility' && t[1]); + if (visibilityTag && visibilityTag[1]) { + const level = visibilityTag[1].toLowerCase(); + if (['public', 'unlisted', 'restricted', 'private'].includes(level)) { + return level as VisibilityLevel; + } + } + + // Default to public if not specified + return 'public'; +} + +/** + * Get project-relay URLs from repository announcement + */ +export function getProjectRelays(announcement: NostrEvent): string[] { + return announcement.tags + .filter(t => t[0] === 'project-relay') + .flatMap(t => t.slice(1)) + .filter(url => url && typeof url === 'string') as string[]; +} + +/** + * Determine which relays to publish events to based on visibility + * + * @param announcement - Repository announcement event + * @returns Array of relay URLs to publish to (empty array means no relay publishing) + */ +export function getRelaysForEventPublishing(announcement: NostrEvent): string[] { + const visibility = getVisibility(announcement); + const projectRelays = getProjectRelays(announcement); + + switch (visibility) { + case 'public': + // Publish to all default relays + project relays + return [...new Set([...DEFAULT_NOSTR_RELAYS, ...projectRelays])]; + + case 'unlisted': + case 'restricted': + // Publish to project relays only + return projectRelays; + + case 'private': + // No relay publishing - git-only + return []; + + default: + // Fallback to public behavior + return [...new Set([...DEFAULT_NOSTR_RELAYS, ...projectRelays])]; + } +} + +/** + * Check if repository is private (restricted or private visibility) + */ +export function isPrivateRepo(announcement: NostrEvent): boolean { + const visibility = getVisibility(announcement); + return visibility === 'restricted' || visibility === 'private'; +} + +/** + * Check if repository is discoverable (public or unlisted) + */ +export function isDiscoverableRepo(announcement: NostrEvent): boolean { + const visibility = getVisibility(announcement); + return visibility === 'public' || visibility === 'unlisted'; +} diff --git a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts index 72447b2..79cba7a 100644 --- a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts @@ -8,6 +8,7 @@ import { DEFAULT_NOSTR_RELAYS, combineRelays, getGitUrl } from '$lib/config.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { KIND, type NostrEvent } from '$lib/types/nostr.js'; +import { getVisibility, getProjectRelays } from '$lib/utils/repo-visibility.js'; import { nip19 } from 'nostr-tools'; import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; @@ -235,6 +236,10 @@ export const POST: RequestHandler = async ({ params, request }) => { return error(400, 'Cannot create fork with only localhost. The original repository must have at least one public clone URL, or you need to configure a Tor .onion address.'); } + // Preserve visibility and project-relay from original repo + const originalVisibility = getVisibility(originalAnnouncement); + const originalProjectRelays = getProjectRelays(originalAnnouncement); + // Build fork announcement tags // Use standardized fork tag: ['fork', '30617:pubkey:d-tag'] const originalRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`; @@ -247,6 +252,16 @@ export const POST: RequestHandler = async ({ params, request }) => { ['fork', originalRepoTag], // Standardized fork tag format ['p', originalOwnerPubkey], // Original owner ]; + + // Preserve visibility from original repo (defaults to public if not set) + if (originalVisibility !== 'public') { + tags.push(['visibility', originalVisibility]); + } + + // Preserve project-relay tags from original repo + for (const relay of originalProjectRelays) { + tags.push(['project-relay', relay]); + } // Add earliest unique commit if available if (earliestCommit) { diff --git a/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts index 7bccd6d..61e1f4c 100644 --- a/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts @@ -10,6 +10,9 @@ import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { verifyEvent } from 'nostr-tools'; import type { NostrEvent } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js'; +import { getRelaysForEventPublishing } from '$lib/utils/repo-visibility.js'; +import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; +import { eventCache } from '$lib/services/nostr/event-cache.js'; import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; @@ -110,13 +113,31 @@ export const POST: RequestHandler = withRepoValidation( throw handleValidationError('Invalid event signature', { operation: 'createHighlight', npub: repoContext.npub, repo: repoContext.repo }); } - // Get user's relays and publish + // Get repository announcement to determine visibility and relay publishing + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoContext.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, repoContext.repo); + + // Determine which relays to publish to based on visibility + const visibilityRelays = announcement ? getRelaysForEventPublishing(announcement) : DEFAULT_NOSTR_RELAYS; + + // For highlights/comments, also include user's relays if visibility allows publishing + let relaysToPublish: string[] = []; + if (visibilityRelays.length > 0) { + try { + const { outbox } = await getUserRelays(userPubkey, nostrClient); + relaysToPublish = combineRelays([...visibilityRelays, ...outbox]); + } catch { + // If user relays fail, use visibility relays only + relaysToPublish = visibilityRelays; + } + } + + // Publish the event to relays (empty array means no relay publishing, but event is still saved to repo) let result; try { - const { outbox } = await getUserRelays(userPubkey, nostrClient); - const combinedRelays = combineRelays(outbox); - - result = await nostrClient.publishEvent(highlightEvent as NostrEvent, combinedRelays); + result = relaysToPublish.length > 0 + ? await nostrClient.publishEvent(highlightEvent as NostrEvent, relaysToPublish) + : { success: [], failed: [] }; } catch (err) { // Log error but don't fail - some relays may have succeeded logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo, eventId: highlightEvent.id }, 'Error publishing highlight event, some relays may have succeeded'); diff --git a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts index f669ca7..5bce030 100644 --- a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts @@ -17,6 +17,7 @@ import { verifyEvent } from 'nostr-tools'; import { validatePubkey } from '$lib/utils/input-validation.js'; import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; import { eventCache } from '$lib/services/nostr/event-cache.js'; +import { getRelaysForEventPublishing } from '$lib/utils/repo-visibility.js'; export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext) => { @@ -107,8 +108,17 @@ export const POST: RequestHandler = withRepoValidation( throw handleValidationError('Invalid event: missing required fields, invalid format, or invalid signature', { operation: 'createIssue', npub: repoContext.npub, repo: repoContext.repo }); } - // Publish the event to relays - const result = await nostrClient.publishEvent(issueEvent, DEFAULT_NOSTR_RELAYS); + // Get repository announcement to determine visibility and relay publishing + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoContext.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, repoContext.repo); + + // Determine which relays to publish to based on visibility + const relaysToPublish = announcement ? getRelaysForEventPublishing(announcement) : DEFAULT_NOSTR_RELAYS; + + // Publish the event to relays (empty array means no relay publishing, but event is still saved to repo) + const result = relaysToPublish.length > 0 + ? await nostrClient.publishEvent(issueEvent, relaysToPublish) + : { success: [], failed: [] }; if (result.failed.length > 0 && result.success.length === 0) { throw handleApiError(new Error('Failed to publish issue to all relays'), { operation: 'createIssue', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish issue to all relays'); @@ -190,13 +200,21 @@ export const PATCH: RequestHandler = withRepoValidation( throw handleApiError(new Error('Only repository owners, maintainers, or issue authors can update issue status'), { operation: 'updateIssueStatus', npub: repoContext.npub, repo: repoContext.repo }, 'Unauthorized'); } - // Update issue status + // Get repository announcement to determine visibility and relay publishing + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoContext.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, repoContext.repo); + + // Determine which relays to publish to based on visibility + const relaysToPublish = announcement ? getRelaysForEventPublishing(announcement) : DEFAULT_NOSTR_RELAYS; + + // Update issue status with visibility-based relays const statusEvent = await issuesService.updateIssueStatus( issueId, issueAuthor, repoContext.repoOwnerPubkey, repoContext.repo, - status + status, + relaysToPublish ); return json({ success: true, event: statusEvent }); diff --git a/src/routes/api/repos/[npub]/[repo]/patches/+server.ts b/src/routes/api/repos/[npub]/[repo]/patches/+server.ts index 601fed2..6e17eb1 100644 --- a/src/routes/api/repos/[npub]/[repo]/patches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/patches/+server.ts @@ -13,6 +13,9 @@ import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.j import logger from '$lib/services/logger.js'; import { KIND, type NostrEvent } from '$lib/types/nostr.js'; import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; +import { getRelaysForEventPublishing } from '$lib/utils/repo-visibility.js'; +import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; +import { eventCache } from '$lib/services/nostr/event-cache.js'; function getRepoAddress(repoOwnerPubkey: string, repoId: string): string { return `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repoId}`; @@ -91,8 +94,17 @@ export const POST: RequestHandler = withRepoValidation( throw handleValidationError('Invalid event: missing signature or ID', { operation: 'createPatch', npub: repoContext.npub, repo: repoContext.repo }); } - // Publish the event to relays - const result = await nostrClient.publishEvent(patchEvent, DEFAULT_NOSTR_RELAYS); + // Get repository announcement to determine visibility and relay publishing + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoContext.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, repoContext.repo); + + // Determine which relays to publish to based on visibility + const relaysToPublish = announcement ? getRelaysForEventPublishing(announcement) : DEFAULT_NOSTR_RELAYS; + + // Publish the event to relays (empty array means no relay publishing, but event is still saved to repo) + const result = relaysToPublish.length > 0 + ? await nostrClient.publishEvent(patchEvent, relaysToPublish) + : { success: [], failed: [] }; if (result.failed.length > 0 && result.success.length === 0) { throw handleApiError(new Error('Failed to publish patch to all relays'), { operation: 'createPatch', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish patch to all relays'); @@ -169,8 +181,17 @@ export const PATCH: RequestHandler = withRepoValidation( pubkey: '' }); - // Publish status event - const result = await nostrClient.publishEvent(statusEvent, DEFAULT_NOSTR_RELAYS); + // Get repository announcement to determine visibility and relay publishing + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoContext.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, repoContext.repo); + + // Determine which relays to publish to based on visibility + const relaysToPublish = announcement ? getRelaysForEventPublishing(announcement) : DEFAULT_NOSTR_RELAYS; + + // Publish status event (empty array means no relay publishing, but event is still saved to repo) + const result = relaysToPublish.length > 0 + ? await nostrClient.publishEvent(statusEvent, relaysToPublish) + : { success: [], failed: [] }; if (result.failed.length > 0 && result.success.length === 0) { throw handleApiError(new Error('Failed to publish status event to all relays'), { operation: 'updatePatchStatus', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish status event'); diff --git a/src/routes/api/repos/[npub]/[repo]/prs/+server.ts b/src/routes/api/repos/[npub]/[repo]/prs/+server.ts index e85b64d..9209314 100644 --- a/src/routes/api/repos/[npub]/[repo]/prs/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/prs/+server.ts @@ -12,6 +12,9 @@ import { handleValidationError, handleApiError } from '$lib/utils/error-handler. import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js'; import logger from '$lib/services/logger.js'; +import { getRelaysForEventPublishing } from '$lib/utils/repo-visibility.js'; +import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; +import { eventCache } from '$lib/services/nostr/event-cache.js'; export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext) => { @@ -35,8 +38,17 @@ export const POST: RequestHandler = withRepoValidation( throw handleValidationError('Invalid event: missing signature or ID', { operation: 'createPR', npub: repoContext.npub, repo: repoContext.repo }); } - // Publish the event to relays - const result = await nostrClient.publishEvent(prEvent, DEFAULT_NOSTR_RELAYS); + // Get repository announcement to determine visibility and relay publishing + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoContext.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, repoContext.repo); + + // Determine which relays to publish to based on visibility + const relaysToPublish = announcement ? getRelaysForEventPublishing(announcement) : DEFAULT_NOSTR_RELAYS; + + // Publish the event to relays (empty array means no relay publishing, but event is still saved to repo) + const result = relaysToPublish.length > 0 + ? await nostrClient.publishEvent(prEvent, relaysToPublish) + : { success: [], failed: [] }; if (result.failed.length > 0 && result.success.length === 0) { throw handleApiError(new Error('Failed to publish pull request to all relays'), { operation: 'createPR', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish pull request to all relays'); @@ -74,14 +86,22 @@ export const PATCH: RequestHandler = withRepoValidation( throw handleApiError(new Error('Only repository owners and maintainers can update PR status'), { operation: 'updatePRStatus', npub: repoContext.npub, repo: repoContext.repo }, 'Unauthorized'); } - // Update PR status + // Get repository announcement to determine visibility and relay publishing + const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoContext.repoOwnerPubkey, eventCache); + const announcement = findRepoAnnouncement(allEvents, repoContext.repo); + + // Determine which relays to publish to based on visibility + const relaysToPublish = announcement ? getRelaysForEventPublishing(announcement) : DEFAULT_NOSTR_RELAYS; + + // Update PR status with visibility-based relays const statusEvent = await prsService.updatePRStatus( prId, prAuthor, repoContext.repoOwnerPubkey, repoContext.repo, status, - mergeCommitId + mergeCommitId, + relaysToPublish ); return json({ success: true, event: statusEvent }); diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte index b856226..eb98722 100644 --- a/src/routes/signup/+page.svelte +++ b/src/routes/signup/+page.svelte @@ -30,7 +30,8 @@ let imageUrl = $state(''); let bannerUrl = $state(''); let earliestCommit = $state(''); - let isPrivate = $state(false); + let visibility = $state<'public' | 'unlisted' | 'restricted' | 'private'>('public'); + let projectRelays = $state(['']); let isFork = $state(false); let forkOriginalRepo = $state(''); // Original repo identifier: npub/repo, naddr, or 30617:owner:repo format let addClientTag = $state(true); // Add ["client", "gitrepublic-web"] tag @@ -261,11 +262,30 @@ // Check if client tag exists addClientTag = !event.tags.some(t => t[0] === 'client' && t[1] === 'gitrepublic-web'); + // Read visibility tag (defaults to 'public') + const visibilityTag = event.tags.find(t => t[0] === 'visibility' && t[1]); + if (visibilityTag && visibilityTag[1]) { + const vis = visibilityTag[1].toLowerCase(); + if (['public', 'unlisted', 'restricted', 'private'].includes(vis)) { + visibility = vis as typeof visibility; + } + } + + // Read project-relay tags + const projectRelayTags = event.tags.filter(t => t[0] === 'project-relay'); + if (projectRelayTags.length > 0) { + projectRelays = projectRelayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string'); + if (projectRelays.length === 0) projectRelays = ['']; + } + + // Backward compatibility: check for old private tag const isPrivateTag = event.tags.find(t => (t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private') ); - if (isPrivateTag) isPrivate = true; + if (isPrivateTag && !visibilityTag) { + visibility = 'restricted'; // Map old private to restricted + } // Set existing repo ref for updating existingRepoRef = event.id; @@ -490,11 +510,30 @@ // Check if client tag exists addClientTag = !event.tags.some(t => t[0] === 'client' && t[1] === 'gitrepublic-web'); + // Read visibility tag (defaults to 'public') + const visibilityTag = event.tags.find(t => t[0] === 'visibility' && t[1]); + if (visibilityTag && visibilityTag[1]) { + const vis = visibilityTag[1].toLowerCase(); + if (['public', 'unlisted', 'restricted', 'private'].includes(vis)) { + visibility = vis as typeof visibility; + } + } + + // Read project-relay tags + const projectRelayTags = event.tags.filter(t => t[0] === 'project-relay'); + if (projectRelayTags.length > 0) { + projectRelays = projectRelayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string'); + if (projectRelays.length === 0) projectRelays = ['']; + } + + // Backward compatibility: check for old private tag const isPrivateTag = event.tags.find(t => (t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private') ); - if (isPrivateTag) isPrivate = true; + if (isPrivateTag && !visibilityTag) { + visibility = 'restricted'; // Map old private to restricted + } // Set existing repo ref for updating existingRepoRef = event.id; @@ -964,18 +1003,32 @@ } } - // Filter private repos + // Filter repos by visibility const filteredPrivateEvents = await Promise.all( filteredEvents.map(async (event): Promise => { - const isPrivate = event.tags.some(t => - (t[0] === 'private' && t[1] === 'true') || - (t[0] === 't' && t[1] === 'private') - ); + // Check visibility tag + const visibilityTag = event.tags.find(t => t[0] === 'visibility' && t[1]); + let repoVisibility: 'public' | 'unlisted' | 'restricted' | 'private' = 'public'; + if (visibilityTag && visibilityTag[1]) { + const vis = visibilityTag[1].toLowerCase(); + if (['public', 'unlisted', 'restricted', 'private'].includes(vis)) { + repoVisibility = vis as typeof repoVisibility; + } + } + + // Backward compatibility: check for old private tag + if (!visibilityTag) { + const isPrivate = event.tags.some(t => + (t[0] === 'private' && t[1] === 'true') || + (t[0] === 't' && t[1] === 'private') + ); + if (isPrivate) repoVisibility = 'restricted'; + } - // Public repos are always visible - if (!isPrivate) return event; + // Public and unlisted repos are always visible + if (repoVisibility === 'public' || repoVisibility === 'unlisted') return event; - // Private repos: only show if user is owner + // Restricted and private repos: only show if user is owner if (userPubkeyHex && event.pubkey === userPubkeyHex) { return event; } @@ -1257,13 +1310,32 @@ const descTag = event.tags.find(t => t[0] === 'description')?.[1] || ''; const imageTag = event.tags.find(t => t[0] === 'image')?.[1] || ''; const bannerTag = event.tags.find(t => t[0] === 'banner')?.[1] || ''; + // Read visibility tag (defaults to 'public') + const visibilityTag = event.tags.find(t => t[0] === 'visibility' && t[1]); + if (visibilityTag && visibilityTag[1]) { + const vis = visibilityTag[1].toLowerCase(); + if (['public', 'unlisted', 'restricted', 'private'].includes(vis)) { + visibility = vis as typeof visibility; + } + } + + // Read project-relay tags + const projectRelayTags = event.tags.filter(t => t[0] === 'project-relay'); + if (projectRelayTags.length > 0) { + projectRelays = projectRelayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string'); + if (projectRelays.length === 0) projectRelays = ['']; + } + + // Backward compatibility: check for old private tag const privateTag = event.tags.find(t => (t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private')); + if (privateTag && !visibilityTag) { + visibility = 'restricted'; // Map old private to restricted + } repoName = nameTag || dTag; description = descTag; imageUrl = imageTag; bannerUrl = bannerTag; - isPrivate = !!privateTag; // Extract clone URLs - handle both formats: separate tags and multiple values in one tag const urls: string[] = []; @@ -2008,9 +2080,25 @@ } } - // Add private tag if enabled - if (isPrivate) { - eventTags.push(['private', 'true']); + // Add visibility tag + if (visibility !== 'public') { + eventTags.push(['visibility', visibility]); + } + + // Add project-relay tags (required for unlisted/restricted, optional for others) + const normalizedProjectRelays = projectRelays + .map(r => r.trim()) + .filter(r => r && (r.startsWith('ws://') || r.startsWith('wss://'))); + + for (const relay of normalizedProjectRelays) { + eventTags.push(['project-relay', relay]); + } + + // Warn if unlisted/restricted but no project-relay + if ((visibility === 'unlisted' || visibility === 'restricted') && normalizedProjectRelays.length === 0) { + error = 'Project relay is required for unlisted and restricted repositories. Please add at least one project-relay.'; + loading = false; + return; } // Remove any existing client tags (from other clients) and ensure only our client tag exists @@ -2858,19 +2946,79 @@ {/if}
-
+ {#if visibility === 'unlisted' || visibility === 'restricted' || visibility === 'private'} +
+ + {#each projectRelays as projectRelay, index} +
+ { + projectRelays[index] = e.currentTarget.value; + projectRelays = [...projectRelays]; + }} + placeholder="wss://relay.example.com" + disabled={loading} + required={visibility === 'unlisted' || visibility === 'restricted'} + /> + {#if projectRelays.length > 1} + + {/if} +
+ {/each} + +
+ {/if} +