Browse Source

increase granularity of repo and event visbility

Nostr-Signature: 1d96ac54006360066d403209f6893faffec0f8f389ea99af73447a017d5ff03a 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 44c53034e91ef444368a5034e3a12024bf893f3e518eef903aecbbb453e612f5f198601daf7b9d8da3bc48ca77ec4d18795f111211c3bc32ed4b6c0707a7a905
main
Silberengel 3 weeks ago
parent
commit
07684e434e
  1. 1
      nostr/commit-signatures.jsonl
  2. 13
      src/lib/services/nostr/issues-service.ts
  3. 13
      src/lib/services/nostr/prs-service.ts
  4. 18
      src/lib/utils/repo-privacy.ts
  5. 87
      src/lib/utils/repo-visibility.ts
  6. 15
      src/routes/api/repos/[npub]/[repo]/fork/+server.ts
  7. 31
      src/routes/api/repos/[npub]/[repo]/highlights/+server.ts
  8. 26
      src/routes/api/repos/[npub]/[repo]/issues/+server.ts
  9. 29
      src/routes/api/repos/[npub]/[repo]/patches/+server.ts
  10. 28
      src/routes/api/repos/[npub]/[repo]/prs/+server.ts
  11. 198
      src/routes/signup/+page.svelte

1
nostr/commit-signatures.jsonl

@ -80,3 +80,4 @@ @@ -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"}

13
src/lib/services/nostr/issues-service.ts

@ -159,7 +159,8 @@ export class IssuesService { @@ -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<StatusEvent> {
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId);
@ -194,8 +195,14 @@ export class IssuesService { @@ -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');
}

13
src/lib/services/nostr/prs-service.ts

@ -150,7 +150,8 @@ export class PRsService { @@ -150,7 +150,8 @@ export class PRsService {
repoOwnerPubkey: string,
repoId: string,
status: 'open' | 'merged' | 'closed' | 'draft',
mergeCommitId?: string
mergeCommitId?: string,
relays?: string[]
): Promise<StatusEvent> {
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId);
@ -193,8 +194,14 @@ export class PRsService { @@ -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');
}

18
src/lib/utils/repo-privacy.ts

@ -5,6 +5,7 @@ @@ -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<import('../services/nostr/maintai @@ -19,24 +20,13 @@ const getMaintainerService = async (): Promise<import('../services/nostr/maintai
/**
* Check if a repository is private based on announcement event
* A repo is private if it has a tag ["private"], ["private", "true"], or ["t", "private"]
* Uses the new visibility system: restricted or private visibility means private repo
*
* This is a shared utility to avoid code duplication across services.
*/
export function isPrivateRepo(announcement: NostrEvent): boolean {
// Check for ["private", "true"] tag
const privateTag = announcement.tags.find(t => 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);
}
/**

87
src/lib/utils/repo-visibility.ts

@ -0,0 +1,87 @@ @@ -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';
}

15
src/routes/api/repos/[npub]/[repo]/fork/+server.ts

@ -8,6 +8,7 @@ import { DEFAULT_NOSTR_RELAYS, combineRelays, getGitUrl } from '$lib/config.js'; @@ -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 }) => { @@ -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 }) => { @@ -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) {

31
src/routes/api/repos/[npub]/[repo]/highlights/+server.ts

@ -10,6 +10,9 @@ import { getUserRelays } from '$lib/services/nostr/user-relays.js'; @@ -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( @@ -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');

26
src/routes/api/repos/[npub]/[repo]/issues/+server.ts

@ -17,6 +17,7 @@ import { verifyEvent } from 'nostr-tools'; @@ -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( @@ -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( @@ -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 });

29
src/routes/api/repos/[npub]/[repo]/patches/+server.ts

@ -13,6 +13,9 @@ import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.j @@ -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( @@ -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( @@ -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');

28
src/routes/api/repos/[npub]/[repo]/prs/+server.ts

@ -12,6 +12,9 @@ import { handleValidationError, handleApiError } from '$lib/utils/error-handler. @@ -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( @@ -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( @@ -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 });

198
src/routes/signup/+page.svelte

@ -30,7 +30,8 @@ @@ -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<string[]>(['']);
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 @@ @@ -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 @@ @@ -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 @@ @@ -964,18 +1003,32 @@
}
}
// Filter private repos
// Filter repos by visibility
const filteredPrivateEvents = await Promise.all(
filteredEvents.map(async (event): Promise<NostrEvent | null> => {
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 @@ @@ -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 @@ @@ -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 @@ @@ -2858,19 +2946,79 @@
{/if}
<div class="form-group">
<label class="checkbox-label">
<input
type="checkbox"
bind:checked={isPrivate}
disabled={loading}
/>
<div>
<span>Private Repository</span>
<small>Private repositories are hidden from public listings and can only be accessed by the owner and maintainers. Git clone/fetch operations require authentication.</small>
</div>
<label for="visibility">
Repository Visibility *
<small>
<strong>Public:</strong> Repository and events are published to all relays and project relay.<br/>
<strong>Unlisted:</strong> Repository is public but events are only published to project relay.<br/>
<strong>Restricted:</strong> Repository is private, events are only published to project relay.<br/>
<strong>Private:</strong> Repository is private, events are not published to relays (git-only).
</small>
</label>
<select
id="visibility"
bind:value={visibility}
disabled={loading}
required
>
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="restricted">Restricted</option>
<option value="private">Private</option>
</select>
</div>
{#if visibility === 'unlisted' || visibility === 'restricted' || visibility === 'private'}
<div class="form-group">
<label>
Project Relay(s) {#if visibility === 'unlisted' || visibility === 'restricted'}*{/if}
<small>
{#if visibility === 'unlisted' || visibility === 'restricted'}
Required for unlisted and restricted repositories. Events will be published to these relays only.
{:else}
Optional for private repositories. If provided, events will be published to these relays (otherwise git-only).
{/if}
</small>
</label>
{#each projectRelays as projectRelay, index}
<div class="input-group">
<input
type="text"
value={projectRelay}
oninput={(e) => {
projectRelays[index] = e.currentTarget.value;
projectRelays = [...projectRelays];
}}
placeholder="wss://relay.example.com"
disabled={loading}
required={visibility === 'unlisted' || visibility === 'restricted'}
/>
{#if projectRelays.length > 1}
<button
type="button"
onclick={() => {
projectRelays = projectRelays.filter((_, i) => i !== index);
}}
disabled={loading}
>
Remove
</button>
{/if}
</div>
{/each}
<button
type="button"
onclick={() => {
projectRelays = [...projectRelays, ''];
}}
disabled={loading}
class="add-button"
>
+ Add Project Relay
</button>
</div>
{/if}
<div class="form-group">
<label class="checkbox-label">
<input

Loading…
Cancel
Save