Browse Source

update transfer workflow

Nostr-Signature: 5d6d6909666a881f88f240389d30f5bedd36dba5d69a9d24dca86557b0098867 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc d13caca8b3e1009469e28c352bdfacf5eb78e2e9f5ac80c8511a9e2c6c5ac7031b83374d2d91b93b8018b5a3402e3e9c7114332da89ee2cb039f64aa3207f3f4
main
Silberengel 3 weeks ago
parent
commit
1aef275c11
  1. 1
      nostr/commit-signatures.jsonl
  2. 18
      src/app.css
  3. 233
      src/lib/components/TransferNotification.svelte
  4. 64
      src/routes/+layout.svelte
  5. 11
      src/routes/api-docs/+page.svelte
  6. 27
      src/routes/api/repos/[npub]/[repo]/fork/+server.ts
  7. 30
      src/routes/api/repos/[npub]/[repo]/settings/+server.ts
  8. 38
      src/routes/api/repos/[npub]/[repo]/transfer/+server.ts
  9. 126
      src/routes/api/transfers/pending/+server.ts
  10. 79
      src/routes/repos/+page.svelte
  11. 116
      src/routes/signup/+page.svelte

1
nostr/commit-signatures.jsonl

@ -6,3 +6,4 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771519913,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix swagger"]],"content":"Signed commit: fix swagger","id":"c0eb40be64306e1b11eba642ad357fd877776f50c8e64867cff845b92851c60e","sig":"194bedf752e25da9924bcf16b55eec0b29ae5da8bcd3f2a646e072f08d2d8f499269d42fafe6c056effb22840be0101c243aa1852b41899a73b242e6d6ad2932"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771519913,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix swagger"]],"content":"Signed commit: fix swagger","id":"c0eb40be64306e1b11eba642ad357fd877776f50c8e64867cff845b92851c60e","sig":"194bedf752e25da9924bcf16b55eec0b29ae5da8bcd3f2a646e072f08d2d8f499269d42fafe6c056effb22840be0101c243aa1852b41899a73b242e6d6ad2932"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771520422,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","correct icons"]],"content":"Signed commit: correct icons","id":"3d630436d21542aa097b574829ba03f9700db4d707f3d7065bc24000321d0ba2","sig":"6e345bb8ca6fef352400dd10a801d1f41b8798b7a0307eba9af84ea3b4045235b50510905ab2cc9cbdd2894b56a0d1524560a9347c137f39cf756c43ca72c326"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771520422,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","correct icons"]],"content":"Signed commit: correct icons","id":"3d630436d21542aa097b574829ba03f9700db4d707f3d7065bc24000321d0ba2","sig":"6e345bb8ca6fef352400dd10a801d1f41b8798b7a0307eba9af84ea3b4045235b50510905ab2cc9cbdd2894b56a0d1524560a9347c137f39cf756c43ca72c326"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771520523,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix contrast"]],"content":"Signed commit: fix contrast","id":"210177972a67b45a8c56494f2423987ffd30fc5594c539ed6a9f23c0f0992d21","sig":"c3122ebc0055f5a7145d394b9461b811b6e37a7423493d62a6debf7078c006435352e2e2a4259fce6a8a13486bdd137e2e7a49bfdf512a37a73d0c36d405ff2f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771520523,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix contrast"]],"content":"Signed commit: fix contrast","id":"210177972a67b45a8c56494f2423987ffd30fc5594c539ed6a9f23c0f0992d21","sig":"c3122ebc0055f5a7145d394b9461b811b6e37a7423493d62a6debf7078c006435352e2e2a4259fce6a8a13486bdd137e2e7a49bfdf512a37a73d0c36d405ff2f"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771522633,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","adjusting api for themes"]],"content":"Signed commit: adjusting api for themes","id":"c6125da849827ef6481eed3588231630470289db0176066fc9c1e044f839976b","sig":"7a943b493af9d7108a26fb3bad8166e58ba2ed08eb6c24c178775387620601e6a130ce8a0f344a79e637fc4e75ed2e6d308a242101b14bdb38ccb901c09ff13f"}

18
src/app.css

@ -1030,6 +1030,24 @@ button.theme-option.active img.theme-icon-option,
max-width: 200px; max-width: 200px;
} }
.repo-badge.transferred {
opacity: 0.6;
filter: grayscale(0.5);
position: relative;
}
.repo-badge.transferred:hover {
opacity: 0.8;
filter: grayscale(0.3);
}
.transferred-badge {
font-size: 0.8rem;
color: var(--text-secondary);
margin-left: 0.25rem;
opacity: 0.7;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.my-repos-section { .my-repos-section {
padding: 1rem; padding: 1rem;

233
src/lib/components/TransferNotification.svelte

@ -0,0 +1,233 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js';
const dispatch = createEventDispatcher();
type TransferData = {
eventId: string;
fromPubkey: string;
toPubkey: string;
repoTag: string;
repoName: string;
originalOwner: string;
timestamp: number;
createdAt: string;
event: NostrEvent;
};
let { transfer }: { transfer: TransferData } = $props();
let closing = $state(false);
function handleCompleteTransfer() {
// Parse repo info from repoTag (kind:pubkey:repo)
const currentTransfer = transfer;
const parts = currentTransfer.repoTag.split(':');
if (parts.length < 3) {
alert('Invalid repository tag format');
return;
}
const originalOwnerPubkey = parts[1];
const repoName = parts[2];
// Convert original owner pubkey to npub
let originalOwnerNpub: string;
try {
originalOwnerNpub = nip19.npubEncode(originalOwnerPubkey);
} catch {
alert('Invalid owner pubkey format');
return;
}
// Navigate to signup page with transfer data
const params = new URLSearchParams({
transfer: 'true',
transferEventId: currentTransfer.eventId,
originalOwner: originalOwnerNpub,
repo: repoName,
repoTag: currentTransfer.repoTag
});
goto(`/signup?${params.toString()}`);
}
function handleDismiss() {
closing = true;
const currentTransfer = transfer;
dispatch('dismiss', { eventId: currentTransfer.eventId });
setTimeout(() => {
// Component will be removed by parent
}, 300);
}
// Format timestamp
const formattedDate = $derived(new Date(transfer.createdAt).toLocaleDateString());
</script>
<div class="transfer-notification" class:closing>
<div class="notification-content">
<div class="notification-header">
<h3>Repository Ownership Transfer</h3>
<button class="close-button" onclick={handleDismiss} aria-label="Dismiss">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="notification-body">
<p>
You have been named as the new owner of the repository: <strong>{transfer.repoName}</strong>
</p>
<p class="notification-details">
Transfer initiated on {formattedDate}
</p>
<p class="notification-instruction">
Please complete the transfer by publishing a new repo announcement.
</p>
</div>
<div class="notification-actions">
<button class="button-primary" onclick={handleCompleteTransfer}>
Complete Transfer
</button>
<button class="button-secondary" onclick={handleDismiss}>
Dismiss
</button>
</div>
</div>
</div>
<style>
.transfer-notification {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
max-width: 500px;
background: var(--bg-primary, #fff);
border: 2px solid var(--border-color, #ddd);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease-out;
transition: opacity 0.3s ease-out, transform 0.3s ease-out;
}
.transfer-notification.closing {
opacity: 0;
transform: translateX(100%);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.notification-content {
padding: 20px;
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.notification-header h3 {
margin: 0;
font-size: 1.2em;
color: var(--text-primary, #333);
}
.close-button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--text-secondary, #666);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
}
.close-button:hover {
background-color: var(--bg-secondary, #f0f0f0);
}
.notification-body {
margin-bottom: 16px;
}
.notification-body p {
margin: 8px 0;
color: var(--text-primary, #333);
line-height: 1.5;
}
.notification-details {
font-size: 0.9em;
color: var(--text-secondary, #666);
}
.notification-instruction {
font-weight: 500;
color: var(--text-primary, #333);
}
.notification-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.button-primary,
.button-secondary {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
font-weight: 500;
transition: background-color 0.2s, transform 0.1s;
}
.button-primary {
background-color: var(--primary-color, #007bff);
color: white;
}
.button-primary:hover {
background-color: var(--primary-hover, #0056b3);
transform: translateY(-1px);
}
.button-secondary {
background-color: var(--bg-secondary, #f0f0f0);
color: var(--text-primary, #333);
}
.button-secondary:hover {
background-color: var(--bg-tertiary, #e0e0e0);
}
@media (max-width: 600px) {
.transfer-notification {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
}
}
</style>

64
src/routes/+layout.svelte

@ -5,6 +5,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import NavBar from '$lib/components/NavBar.svelte'; import NavBar from '$lib/components/NavBar.svelte';
import TransferNotification from '$lib/components/TransferNotification.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { determineUserLevel, decodePubkey } from '$lib/services/nostr/user-level-service.js'; import { determineUserLevel, decodePubkey } from '$lib/services/nostr/user-level-service.js';
@ -20,6 +21,21 @@
// User level checking state // User level checking state
let checkingUserLevel = $state(false); let checkingUserLevel = $state(false);
// Transfer notification state
type PendingTransfer = {
eventId: string;
fromPubkey: string;
toPubkey: string;
repoTag: string;
repoName: string;
originalOwner: string;
timestamp: number;
createdAt: string;
event: any;
};
let pendingTransfers = $state<PendingTransfer[]>([]);
let dismissedTransfers = $state<Set<string>>(new Set());
onMount(() => { onMount(() => {
// Only run client-side code // Only run client-side code
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@ -111,8 +127,10 @@
); );
// Update activity if user is logged in // Update activity if user is logged in
if (levelResult.userPubkey) { if (levelResult.userPubkey && levelResult.userPubkeyHex) {
updateActivity(); updateActivity();
// Check for pending transfers
checkPendingTransfers(levelResult.userPubkeyHex);
} }
} catch (err) { } catch (err) {
console.error('Failed to check user level:', err); console.error('Failed to check user level:', err);
@ -124,6 +142,33 @@
} }
} }
async function checkPendingTransfers(userPubkeyHex: string) {
try {
const response = await fetch('/api/transfers/pending', {
headers: {
'X-User-Pubkey': userPubkeyHex
}
});
if (response.ok) {
const data = await response.json();
if (data.pendingTransfers && data.pendingTransfers.length > 0) {
// Filter out dismissed transfers
pendingTransfers = data.pendingTransfers.filter(
(t: { eventId: string }) => !dismissedTransfers.has(t.eventId)
);
}
}
} catch (err) {
console.error('Failed to check for pending transfers:', err);
}
}
function dismissTransfer(eventId: string) {
dismissedTransfers.add(eventId);
pendingTransfers = pendingTransfers.filter(t => t.eventId !== eventId);
}
function applyTheme() { function applyTheme() {
// Remove all theme attributes first // Remove all theme attributes first
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
@ -164,12 +209,29 @@
// Subscribe to user store // Subscribe to user store
const userState = $derived($userStore); const userState = $derived($userStore);
// Check for transfers when user logs in
$effect(() => {
const currentUser = $userStore;
if (currentUser.userPubkeyHex && !checkingUserLevel) {
checkPendingTransfers(currentUser.userPubkeyHex);
} else if (!currentUser.userPubkeyHex) {
// Clear transfers when user logs out
pendingTransfers = [];
dismissedTransfers.clear();
}
});
</script> </script>
{#if !isSplashPage} {#if !isSplashPage}
<NavBar /> <NavBar />
{/if} {/if}
<!-- Transfer notifications -->
{#each pendingTransfers as transfer (transfer.eventId)}
<TransferNotification {transfer} on:dismiss={(e) => dismissTransfer(e.detail.eventId)} />
{/each}
{#if !isSplashPage && checkingUserLevel} {#if !isSplashPage && checkingUserLevel}
<div class="user-level-check"> <div class="user-level-check">
<div class="check-message"> <div class="check-message">

11
src/routes/api-docs/+page.svelte

@ -240,6 +240,14 @@
margin: 0 !important; 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 */ /* Scheme container and filter */
:global(.swagger-ui .scheme-container), :global(.swagger-ui .scheme-container),
:global(.swagger-ui .filter-container) { :global(.swagger-ui .filter-container) {
@ -247,7 +255,8 @@
border: 1px solid var(--border-color) !important; border: 1px solid var(--border-color) !important;
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 1rem; padding: 1rem;
margin-bottom: 2rem; margin-top: 1.5rem;
margin-bottom: 1.5rem;
} }
:global(.swagger-ui .scheme-container label), :global(.swagger-ui .scheme-container label),

27
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...'); logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Provisioning fork repository...');
await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent, false); 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({ logger.info({
operation: 'fork', operation: 'fork',
originalRepo: `${npub}/${repo}`, originalRepo: `${npub}/${repo}`,

30
src/routes/api/repos/[npub]/[repo]/settings/+server.ts

@ -4,7 +4,7 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; 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 { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { KIND } from '$lib/types/nostr.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 { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.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 * GET - Get repository settings
@ -188,6 +191,31 @@ export const POST: RequestHandler = withRepoValidation(
throw error(500, 'Failed to publish updated announcement to relays'); 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 }); return json({ success: true, event: signedEvent });
}, },
{ operation: 'updateSettings', requireRepoAccess: false } // Override to check owner instead { operation: 'updateSettings', requireRepoAccess: false } // Override to check owner instead

38
src/routes/api/repos/[npub]/[repo]/transfer/+server.ts

@ -4,16 +4,17 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; 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 { combineRelays } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.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 type { NostrEvent } from '$lib/types/nostr.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js'; import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.js'; import type { RepoRequestContext } from '$lib/utils/api-context.js';
import type { RequestEvent } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit';
import { handleApiError, handleValidationError, handleAuthorizationError } from '$lib/utils/error-handler.js'; import { handleApiError, handleValidationError, handleAuthorizationError } from '$lib/utils/error-handler.js';
import logger from '$lib/services/logger.js';
/** /**
* GET - Get current owner and transfer history * 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'); 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 // Clear cache so new owner is recognized immediately
ownershipTransferService.clearCache(repoContext.repoOwnerPubkey, repoContext.repo); ownershipTransferService.clearCache(repoContext.repoOwnerPubkey, repoContext.repo);
@ -126,7 +156,9 @@ export const POST: RequestHandler = withRepoValidation(
success: true, success: true,
event: transferEvent, event: transferEvent,
published: result, 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 { operation: 'transferOwnership', requireRepoAccess: false } // Override to check owner instead

126
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' });
}
};

79
src/routes/repos/+page.svelte

@ -31,7 +31,8 @@
let deletingRepo = $state<{ npub: string; repo: string } | null>(null); let deletingRepo = $state<{ npub: string; repo: string } | null>(null);
// User's own repositories (where they are owner or maintainer) // User's own repositories (where they are owner or maintainer)
let myRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string }>>([]); // Also includes repos they transferred away (marked as transferred)
let myRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string; transferred?: boolean; currentOwner?: string }>>([]);
let loadingMyRepos = $state(false); let loadingMyRepos = $state(false);
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; 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 // Sync with userStore - if userStore says logged out, clear local state
$effect(() => { $effect(() => {
const currentUser = $userStore; const currentUser = $userStore;
@ -202,7 +210,7 @@
loadingMyRepos = true; loadingMyRepos = true;
try { try {
// Fetch all repos where user is owner // Fetch all repos where user is current owner
const ownerRepos = await nostrClient.fetchEvents([ const ownerRepos = await nostrClient.fetchEvents([
{ {
kinds: [KIND.REPO_ANNOUNCEMENT], 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) { for (const event of ownerRepos) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) continue; if (!dTag) continue;
@ -222,13 +231,64 @@
repos.push({ repos.push({
event, event,
npub, npub,
repoName: dTag repoName: dTag,
transferred: false
}); });
} catch (err) { } catch (err) {
console.warn('Failed to encode npub for repo:', 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) // Sort by created_at descending (newest first)
repos.sort((a, b) => b.event.created_at - a.event.created_at); repos.sort((a, b) => b.event.created_at - a.event.created_at);
@ -538,13 +598,22 @@
{#each myRepos as item} {#each myRepos as item}
{@const repo = item.event} {@const repo = item.event}
{@const repoImage = getRepoImage(repo)} {@const repoImage = getRepoImage(repo)}
<a href="/repos/{item.npub}/{item.repoName}" class="repo-badge"> {@const isTransferred = item.transferred || false}
<a
href="/repos/{item.npub}/{item.repoName}"
class="repo-badge"
class:transferred={isTransferred}
title={isTransferred ? 'Transferred to another owner' : ''}
>
{#if repoImage} {#if repoImage}
<img src={repoImage} alt={getRepoName(repo)} class="repo-badge-image" /> <img src={repoImage} alt={getRepoName(repo)} class="repo-badge-image" />
{:else} {:else}
<img src="/icons/package.svg" alt="Repository" class="repo-badge-icon" /> <img src="/icons/package.svg" alt="Repository" class="repo-badge-icon" />
{/if} {/if}
<span class="repo-badge-name">{getRepoName(repo)}</span> <span class="repo-badge-name">{getRepoName(repo)}</span>
{#if isTransferred}
<span class="transferred-badge" title="Transferred"></span>
{/if}
</a> </a>
{/each} {/each}
</div> </div>

116
src/routes/signup/+page.svelte

@ -58,12 +58,110 @@
onMount(async () => { onMount(async () => {
nip07Available = isNIP07Available(); 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 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 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 // Pre-fill repo name
repoName = repoParam; repoName = repoParam;
@ -1478,9 +1576,19 @@
// Redirect to the newly created repository page // Redirect to the newly created repository page
// Use invalidateAll to ensure the repos list refreshes // Use invalidateAll to ensure the repos list refreshes
const userNpub = nip19.npubEncode(pubkey); 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(() => { setTimeout(() => {
// Invalidate all caches and redirect // 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); }, 2000);
} else { } else {
// Show detailed error information // Show detailed error information

Loading…
Cancel
Save