diff --git a/src/routes/api-docs/+page.svelte b/src/routes/api-docs/+page.svelte
index fef1de3..b1803c5 100644
--- a/src/routes/api-docs/+page.svelte
+++ b/src/routes/api-docs/+page.svelte
@@ -240,6 +240,14 @@
margin: 0 !important;
}
+ /* Schemes section - server URL row */
+ :global(.swagger-ui .schemes) {
+ margin-top: 1.5rem !important;
+ margin-bottom: 1.5rem !important;
+ padding-top: 1rem !important;
+ padding-bottom: 1rem !important;
+ }
+
/* Scheme container and filter */
:global(.swagger-ui .scheme-container),
:global(.swagger-ui .filter-container) {
@@ -247,7 +255,8 @@
border: 1px solid var(--border-color) !important;
border-radius: 0.375rem;
padding: 1rem;
- margin-bottom: 2rem;
+ margin-top: 1.5rem;
+ margin-bottom: 1.5rem;
}
:global(.swagger-ui .scheme-container label),
diff --git a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts
index 476201b..7a76cbb 100644
--- a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts
+++ b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts
@@ -362,6 +362,33 @@ export const POST: RequestHandler = async ({ params, request }) => {
logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Provisioning fork repository...');
await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent, false);
+ // Save fork announcement to repo (offline papertrail)
+ try {
+ const { generateVerificationFile, VERIFICATION_FILE_PATH } = await import('$lib/services/nostr/repo-verification.js');
+ const { fileManager } = await import('$lib/services/service-registry.js');
+ const announcementFileContent = generateVerificationFile(signedForkAnnouncement, userPubkeyHex);
+
+ // Save to repo if it exists locally (should exist after provisioning)
+ if (fileManager.repoExists(userNpub, forkRepoName)) {
+ await fileManager.writeFile(
+ userNpub,
+ forkRepoName,
+ VERIFICATION_FILE_PATH,
+ announcementFileContent,
+ `Add fork repository announcement: ${signedForkAnnouncement.id.slice(0, 16)}...`,
+ 'Nostr',
+ `${userPubkeyHex}@nostr`,
+ 'main'
+ ).catch(err => {
+ // Log but don't fail - publishing to relays is more important
+ logger.warn({ error: err, npub: userNpub, repo: forkRepoName }, 'Failed to save fork announcement to repo');
+ });
+ }
+ } catch (err) {
+ // Log but don't fail - publishing to relays is more important
+ logger.warn({ error: err, npub: userNpub, repo: forkRepoName }, 'Failed to save fork announcement to repo');
+ }
+
logger.info({
operation: 'fork',
originalRepo: `${npub}/${repo}`,
diff --git a/src/routes/api/repos/[npub]/[repo]/settings/+server.ts b/src/routes/api/repos/[npub]/[repo]/settings/+server.ts
index 5420ab2..b6649df 100644
--- a/src/routes/api/repos/[npub]/[repo]/settings/+server.ts
+++ b/src/routes/api/repos/[npub]/[repo]/settings/+server.ts
@@ -4,7 +4,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
-import { nostrClient, maintainerService, ownershipTransferService } from '$lib/services/service-registry.js';
+import { nostrClient, maintainerService, ownershipTransferService, fileManager } from '$lib/services/service-registry.js';
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { KIND } from '$lib/types/nostr.js';
@@ -12,6 +12,9 @@ import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js';
+import { generateVerificationFile, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js';
+import { nip19 } from 'nostr-tools';
+import logger from '$lib/services/logger.js';
/**
* GET - Get repository settings
@@ -188,6 +191,31 @@ export const POST: RequestHandler = withRepoValidation(
throw error(500, 'Failed to publish updated announcement to relays');
}
+ // Save updated announcement to repo (offline papertrail)
+ try {
+ const announcementFileContent = generateVerificationFile(signedEvent, currentOwner);
+
+ // Save to repo if it exists locally
+ if (fileManager.repoExists(repoContext.npub, repoContext.repo)) {
+ await fileManager.writeFile(
+ repoContext.npub,
+ repoContext.repo,
+ VERIFICATION_FILE_PATH,
+ announcementFileContent,
+ `Update repository announcement: ${signedEvent.id.slice(0, 16)}...`,
+ 'Nostr',
+ `${currentOwner}@nostr`,
+ 'main'
+ ).catch(err => {
+ // Log but don't fail - publishing to relays is more important
+ logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save updated announcement to repo');
+ });
+ }
+ } catch (err) {
+ // Log but don't fail - publishing to relays is more important
+ logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save updated announcement to repo');
+ }
+
return json({ success: true, event: signedEvent });
},
{ operation: 'updateSettings', requireRepoAccess: false } // Override to check owner instead
diff --git a/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts b/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts
index 09c5d4c..d3abd0e 100644
--- a/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts
+++ b/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts
@@ -4,16 +4,17 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
-import { ownershipTransferService, nostrClient } from '$lib/services/service-registry.js';
+import { ownershipTransferService, nostrClient, fileManager } from '$lib/services/service-registry.js';
import { combineRelays } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
-import { verifyEvent } from 'nostr-tools';
+import { verifyEvent, nip19 } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.js';
import type { RequestEvent } from '@sveltejs/kit';
import { handleApiError, handleValidationError, handleAuthorizationError } from '$lib/utils/error-handler.js';
+import logger from '$lib/services/logger.js';
/**
* GET - Get current owner and transfer history
@@ -119,6 +120,35 @@ export const POST: RequestHandler = withRepoValidation(
throw handleApiError(new Error('Failed to publish transfer event to any relays'), { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish transfer event to any relays');
}
+ // Save transfer event to repo (offline papertrail - step 1 requirement)
+ try {
+ const transferEventContent = JSON.stringify(transferEvent, null, 2) + '\n';
+ // Use consistent filename pattern: .nostr-ownership-transfer-{eventId}.json
+ const transferFileName = `.nostr-ownership-transfer-${transferEvent.id}.json`;
+
+ // Save to repo if it exists locally
+ if (fileManager.repoExists(repoContext.npub, repoContext.repo)) {
+ await fileManager.writeFile(
+ repoContext.npub,
+ repoContext.repo,
+ transferFileName,
+ transferEventContent,
+ `Add ownership transfer event: ${transferEvent.id.slice(0, 16)}...`,
+ 'Nostr',
+ `${requestContext.userPubkeyHex}@nostr`,
+ 'main'
+ ).catch(err => {
+ // Log but don't fail - publishing to relays is more important
+ logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save transfer event to repo');
+ });
+ } else {
+ logger.debug({ npub: repoContext.npub, repo: repoContext.repo }, 'Repo does not exist locally, skipping transfer event save to repo');
+ }
+ } catch (err) {
+ // Log but don't fail - publishing to relays is more important
+ logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save transfer event to repo');
+ }
+
// Clear cache so new owner is recognized immediately
ownershipTransferService.clearCache(repoContext.repoOwnerPubkey, repoContext.repo);
@@ -126,7 +156,9 @@ export const POST: RequestHandler = withRepoValidation(
success: true,
event: transferEvent,
published: result,
- message: 'Ownership transfer initiated successfully'
+ message: 'Ownership transfer initiated successfully',
+ // Signal to client that page should refresh
+ refresh: true
});
},
{ operation: 'transferOwnership', requireRepoAccess: false } // Override to check owner instead
diff --git a/src/routes/api/transfers/pending/+server.ts b/src/routes/api/transfers/pending/+server.ts
new file mode 100644
index 0000000..ad2e2f0
--- /dev/null
+++ b/src/routes/api/transfers/pending/+server.ts
@@ -0,0 +1,126 @@
+/**
+ * API endpoint to check for pending ownership transfers for the logged-in user
+ */
+
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { nostrClient } from '$lib/services/service-registry.js';
+import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js';
+import { KIND } from '$lib/types/nostr.js';
+import type { NostrEvent } from '$lib/types/nostr.js';
+import { verifyEvent } from 'nostr-tools';
+import { getUserRelays } from '$lib/services/nostr/user-relays.js';
+import logger from '$lib/services/logger.js';
+
+export const GET: RequestHandler = async ({ request }) => {
+ const userPubkeyHex = request.headers.get('X-User-Pubkey');
+
+ if (!userPubkeyHex) {
+ return json({ pendingTransfers: [] });
+ }
+
+ try {
+ // Get user's relays for comprehensive search
+ const { inbox, outbox } = await getUserRelays(userPubkeyHex, nostrClient);
+ // Combine user relays with default and search relays
+ const userRelays = [...inbox, ...outbox];
+ const allRelays = [...new Set([...userRelays, ...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])];
+
+ // Create a new client with all relays for comprehensive search
+ const { NostrClient } = await import('$lib/services/nostr/nostr-client.js');
+ const searchClient = new NostrClient(allRelays);
+
+ // Search for transfer events where this user is the new owner (p tag)
+ const transferEvents = await searchClient.fetchEvents([
+ {
+ kinds: [KIND.OWNERSHIP_TRANSFER],
+ '#p': [userPubkeyHex],
+ limit: 100
+ }
+ ]);
+
+ // Filter for valid, non-self-transfer events that haven't been completed
+ const pendingTransfers: Array<{
+ eventId: string;
+ fromPubkey: string;
+ toPubkey: string;
+ repoTag: string;
+ repoName: string;
+ originalOwner: string;
+ timestamp: number;
+ createdAt: string;
+ event: NostrEvent;
+ }> = [];
+
+ for (const event of transferEvents) {
+ // Verify event signature
+ if (!verifyEvent(event)) {
+ continue;
+ }
+
+ // Skip self-transfers
+ if (event.pubkey === userPubkeyHex) {
+ continue;
+ }
+
+ // Extract repo tag
+ const aTag = event.tags.find(t => t[0] === 'a');
+ if (!aTag || !aTag[1]) {
+ continue;
+ }
+
+ // Parse repo tag: kind:pubkey:repo
+ const repoTag = aTag[1];
+ const parts = repoTag.split(':');
+ if (parts.length < 3) {
+ continue;
+ }
+
+ const originalOwner = parts[1];
+ const repoName = parts[2];
+
+ // Extract new owner (p tag)
+ const pTag = event.tags.find(t => t[0] === 'p');
+ if (!pTag || !pTag[1] || pTag[1] !== userPubkeyHex) {
+ continue;
+ }
+
+ // Check if transfer is already completed by checking for a newer repo announcement from the new owner
+ // This is a simple check - if there's a newer announcement from the new owner for this repo, transfer is complete
+ const newerAnnouncements = await searchClient.fetchEvents([
+ {
+ kinds: [KIND.REPO_ANNOUNCEMENT],
+ authors: [userPubkeyHex],
+ '#d': [repoName],
+ since: event.created_at,
+ limit: 1
+ }
+ ]);
+
+ // If there's a newer announcement from the new owner, transfer is complete
+ if (newerAnnouncements.length > 0) {
+ continue;
+ }
+
+ pendingTransfers.push({
+ eventId: event.id,
+ fromPubkey: event.pubkey,
+ toPubkey: userPubkeyHex,
+ repoTag,
+ repoName,
+ originalOwner,
+ timestamp: event.created_at,
+ createdAt: new Date(event.created_at * 1000).toISOString(),
+ event
+ });
+ }
+
+ // Sort by timestamp (newest first)
+ pendingTransfers.sort((a, b) => b.timestamp - a.timestamp);
+
+ return json({ pendingTransfers });
+ } catch (err) {
+ logger.error({ error: err, userPubkeyHex }, 'Error checking for pending transfers');
+ return json({ pendingTransfers: [], error: 'Failed to check for pending transfers' });
+ }
+};
diff --git a/src/routes/repos/+page.svelte b/src/routes/repos/+page.svelte
index 35665ba..f8e69e4 100644
--- a/src/routes/repos/+page.svelte
+++ b/src/routes/repos/+page.svelte
@@ -31,7 +31,8 @@
let deletingRepo = $state<{ npub: string; repo: string } | null>(null);
// User's own repositories (where they are owner or maintainer)
- let myRepos = $state
>([]);
+ // Also includes repos they transferred away (marked as transferred)
+ let myRepos = $state>([]);
let loadingMyRepos = $state(false);
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
@@ -60,6 +61,13 @@
}
});
+ // Reload my repos when navigating to repos page (e.g., after transfer)
+ $effect(() => {
+ if ($page.url.pathname === '/repos' && userPubkeyHex) {
+ loadMyRepos().catch(err => console.warn('Failed to reload my repos on page navigation:', err));
+ }
+ });
+
// Sync with userStore - if userStore says logged out, clear local state
$effect(() => {
const currentUser = $userStore;
@@ -202,7 +210,7 @@
loadingMyRepos = true;
try {
- // Fetch all repos where user is owner
+ // Fetch all repos where user is current owner
const ownerRepos = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
@@ -211,8 +219,9 @@
}
]);
- const repos: Array<{ event: NostrEvent; npub: string; repoName: string }> = [];
+ const repos: Array<{ event: NostrEvent; npub: string; repoName: string; transferred?: boolean; currentOwner?: string }> = [];
+ // Add repos where user is current owner
for (const event of ownerRepos) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) continue;
@@ -222,13 +231,64 @@
repos.push({
event,
npub,
- repoName: dTag
+ repoName: dTag,
+ transferred: false
});
} catch (err) {
console.warn('Failed to encode npub for repo:', err);
}
}
+ // Fetch repos that were transferred FROM this user (where they were original owner)
+ // Search for transfer events where this user is the 'from' pubkey
+ const { OwnershipTransferService } = await import('$lib/services/nostr/ownership-transfer-service.js');
+ const ownershipService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
+
+ // Get all repos where user was original owner
+ const originalOwnerRepos = await nostrClient.fetchEvents([
+ {
+ kinds: [KIND.REPO_ANNOUNCEMENT],
+ authors: [userPubkeyHex],
+ limit: 100
+ }
+ ]);
+
+ for (const originalEvent of originalOwnerRepos) {
+ const dTag = originalEvent.tags.find(t => t[0] === 'd')?.[1];
+ if (!dTag) continue;
+
+ // Check current owner
+ const currentOwner = await ownershipService.getCurrentOwner(userPubkeyHex, dTag);
+
+ // If current owner is different, this repo was transferred
+ if (currentOwner !== userPubkeyHex) {
+ // Fetch the current announcement from the new owner
+ const currentAnnouncements = await nostrClient.fetchEvents([
+ {
+ kinds: [KIND.REPO_ANNOUNCEMENT],
+ authors: [currentOwner],
+ '#d': [dTag],
+ limit: 1
+ }
+ ]);
+
+ if (currentAnnouncements.length > 0) {
+ try {
+ const npub = nip19.npubEncode(userPubkeyHex); // Original owner npub
+ repos.push({
+ event: currentAnnouncements[0], // Use current announcement
+ npub,
+ repoName: dTag,
+ transferred: true,
+ currentOwner
+ });
+ } catch (err) {
+ console.warn('Failed to encode npub for transferred repo:', err);
+ }
+ }
+ }
+ }
+
// Sort by created_at descending (newest first)
repos.sort((a, b) => b.event.created_at - a.event.created_at);
@@ -538,13 +598,22 @@
{#each myRepos as item}
{@const repo = item.event}
{@const repoImage = getRepoImage(repo)}
-
+ {@const isTransferred = item.transferred || false}
+
{#if repoImage}
{:else}
{/if}
{getRepoName(repo)}
+ {#if isTransferred}
+ ↗
+ {/if}
{/each}
diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte
index 811e4e9..4de76e8 100644
--- a/src/routes/signup/+page.svelte
+++ b/src/routes/signup/+page.svelte
@@ -58,12 +58,110 @@
onMount(async () => {
nip07Available = isNIP07Available();
- // Check for query params to pre-fill form (for registering local clones)
+ // Check for query params to pre-fill form (for registering local clones or transfers)
const urlParams = $page.url.searchParams;
- const npubParam = urlParams.get('npub');
+ const transferParam = urlParams.get('transfer');
+ const transferEventId = urlParams.get('transferEventId');
+ const originalOwnerParam = urlParams.get('originalOwner');
const repoParam = urlParams.get('repo');
+ const repoTagParam = urlParams.get('repoTag');
+ const npubParam = urlParams.get('npub') || originalOwnerParam;
- if (npubParam && repoParam) {
+ // Handle transfer flow (step 4)
+ if (transferParam === 'true' && originalOwnerParam && repoParam && repoTagParam) {
+ try {
+ // Fetch the original repo announcement to preload data
+ const decoded = nip19.decode(originalOwnerParam);
+ if (decoded.type === 'npub') {
+ const pubkey = decoded.data as string;
+ const events = await nostrClient.fetchEvents([
+ {
+ kinds: [KIND.REPO_ANNOUNCEMENT],
+ authors: [pubkey],
+ '#d': [repoParam],
+ limit: 1
+ }
+ ]);
+
+ if (events.length > 0) {
+ const event = events[0];
+
+ // Pre-fill repo name
+ repoName = repoParam;
+
+ // Pre-fill description
+ const descTag = event.tags.find(t => t[0] === 'description')?.[1];
+ if (descTag) description = descTag;
+
+ // Pre-fill clone URLs (add current domain URL)
+ const existingCloneUrls = event.tags
+ .filter(t => t[0] === 'clone')
+ .flatMap(t => t.slice(1))
+ .filter(url => url && typeof url === 'string');
+
+ const gitDomain = $page.data.gitDomain || 'localhost:6543';
+ const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https';
+ const currentDomainUrl = `${protocol}://${gitDomain}/${originalOwnerParam}/${repoParam}.git`;
+
+ // Check if current domain URL already exists
+ const hasCurrentDomain = existingCloneUrls.some(url => url.includes(gitDomain));
+
+ if (!hasCurrentDomain) {
+ cloneUrls = [...existingCloneUrls, currentDomainUrl];
+ } else {
+ cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : [currentDomainUrl];
+ }
+
+ // Pre-fill other fields
+ const nameTag = event.tags.find(t => t[0] === 'name')?.[1];
+ if (nameTag && !repoName) repoName = nameTag;
+
+ const imageTag = event.tags.find(t => t[0] === 'image')?.[1];
+ if (imageTag) imageUrl = imageTag;
+
+ const bannerTag = event.tags.find(t => t[0] === 'banner')?.[1];
+ if (bannerTag) bannerUrl = bannerTag;
+
+ const webTags = event.tags.filter(t => t[0] === 'web');
+ if (webTags.length > 0) {
+ webUrls = webTags.flatMap(t => t.slice(1)).filter(url => url && typeof url === 'string');
+ }
+
+ const maintainerTags = event.tags.filter(t => t[0] === 'maintainers');
+ if (maintainerTags.length > 0) {
+ maintainers = maintainerTags.flatMap(t => t.slice(1)).filter(m => m && typeof m === 'string');
+ }
+
+ const relayTags = event.tags.filter(t => t[0] === 'relays');
+ if (relayTags.length > 0) {
+ relays = relayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string');
+ }
+
+ const isPrivateTag = event.tags.find(t =>
+ (t[0] === 'private' && t[1] === 'true') ||
+ (t[0] === 't' && t[1] === 'private')
+ );
+ if (isPrivateTag) isPrivate = true;
+
+ // Set existing repo ref for updating
+ existingRepoRef = event.id;
+ } else {
+ // No announcement found, just set the clone URL with current domain
+ repoName = repoParam;
+ const gitDomain = $page.data.gitDomain || 'localhost:6543';
+ const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https';
+ cloneUrls = [`${protocol}://${gitDomain}/${originalOwnerParam}/${repoParam}.git`];
+ }
+ }
+ } catch (err) {
+ console.warn('Failed to pre-fill form from transfer data:', err);
+ // Still set basic info
+ repoName = repoParam;
+ const gitDomain = $page.data.gitDomain || 'localhost:6543';
+ const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https';
+ cloneUrls = [`${protocol}://${originalOwnerParam}/${repoParam}.git`];
+ }
+ } else if (npubParam && repoParam) {
// Pre-fill repo name
repoName = repoParam;
@@ -1478,9 +1576,19 @@
// Redirect to the newly created repository page
// Use invalidateAll to ensure the repos list refreshes
const userNpub = nip19.npubEncode(pubkey);
+
+ // Check if this is a transfer completion (from query params)
+ const urlParams = $page.url.searchParams;
+ const isTransfer = urlParams.get('transfer') === 'true';
+
setTimeout(() => {
// Invalidate all caches and redirect
- goto(`/repos/${userNpub}/${dTag}`, { invalidateAll: true, replaceState: false });
+ if (isTransfer) {
+ // After transfer, redirect to repos page to see updated state
+ goto('/repos', { invalidateAll: true, replaceState: false });
+ } else {
+ goto(`/repos/${userNpub}/${dTag}`, { invalidateAll: true, replaceState: false });
+ }
}, 2000);
} else {
// Show detailed error information