From 802d5953b803efac23cc420147a693593667dc1b Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 20 Feb 2026 21:11:38 +0100 Subject: [PATCH] restrict repos to announced events Nostr-Signature: d7ee36680a38fac493b27fba26d6e1c496dee9a3099db68a4352f7709a41e860 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 071cc8031940590785e5566a45159e5324e36e8a06023282ab1d50b608902d3b06d95efc03d0a4da861a88f12381f7b64999c09a49dfe5f36fbd8ec6aefd8aeb --- nostr/commit-signatures.jsonl | 1 + nostr/events-kind-1.jsonl | 19 - src/lib/services/git/file-manager.ts | 39 +- src/lib/services/git/repo-manager.ts | 431 +++++++++++------- src/lib/services/nostr/fork-count-service.ts | 16 +- .../api/repos/[npub]/[repo]/clone/+server.ts | 10 +- .../api/repos/[npub]/[repo]/fork/+server.ts | 43 +- .../repos/[npub]/[repo]/validate/+server.ts | 130 ++++++ .../api/repos/[npub]/[repo]/verify/+server.ts | 41 +- src/routes/repos/[npub]/[repo]/+page.svelte | 23 +- 10 files changed, 518 insertions(+), 235 deletions(-) delete mode 100644 nostr/events-kind-1.jsonl create mode 100644 src/routes/api/repos/[npub]/[repo]/validate/+server.ts diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 58e6588..752fa40 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -23,3 +23,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771612082,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","harmonize user.name and user.email\nadd settings menu\nautosave OFF 10 min"]],"content":"Signed commit: harmonize user.name and user.email\nadd settings menu\nautosave OFF 10 min","id":"80834df600e5ad22f44fc26880333d28054895b7b5fde984921fab008a27ce6d","sig":"41991d089d26e3f90094dcebd1dee7504c59cadd0ea2f4dfe8693106d9000a528157fb905aec9001e0b8f3ef9e8590557f3df6961106859775d9416b546a44c0"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771612354,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove theme button from bar"]],"content":"Signed commit: remove theme button from bar","id":"fc758a0681c072108b196911bbeee6d49df1efe635d5d78427b7874be4d6e657","sig":"6c0e991e960a29c623c936ab2a31478a85907780eda692c035762deabc740ca0a76df113f5ce853a6d839b023e2b483ce2d7686c40b91c4cea5f32945799a31f"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771614223,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix websocket problems\nhandle replaceable events correctly\nfix css for docs"]],"content":"Signed commit: fix websocket problems\nhandle replaceable events correctly\nfix css for docs","id":"88c007de2bd48c32c879b9950f0908270b009c6341a97b1c0164982648beb3d9","sig":"c9250a23d38671a5b1c0d3389e003931222385ca9591b9b332585c8c639e2af2a7b2e8cac9c1ca5bd47df19b330622b1a1874e586f112fa84a4a7aa4347c7456"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771615631,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","handle new repo creation"]],"content":"Signed commit: handle new repo creation","id":"59bc1c664590bcbe3e05c4151154590aa1ca4399e2a48d64e94bb960e6056265","sig":"ae666597fc46256915abeec93be97c5d9559eaef90aa65208740f32fe4b00531a51ba432ed9a2089a7ec860ac1dc9a7a4a5d8e84db2a7ae433dd5c668f0b5035"} diff --git a/nostr/events-kind-1.jsonl b/nostr/events-kind-1.jsonl deleted file mode 100644 index 63d1347..0000000 --- a/nostr/events-kind-1.jsonl +++ /dev/null @@ -1,19 +0,0 @@ -{"kind":1,"created_at":1771510311,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"0975c5cb65ed1189c9979026c13b93d3c2a40a21eb0bf43f6ca8bdccf7f7712f","sig":"e3309b47df9ccba4ce46722399d8d670abd8e847cf7d940188c67d5e8aee9e7d8498c9fb984174667c9ee71086fbf5a97377c713bee76f109fd212d478d88d75"} -{"kind":1,"created_at":1771510473,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"34181f7b81d8b7861b7a560862835103325debb8c29ef77985a2f0e56d0c5262","sig":"38a5c7fd3d294bee72bc9c3b81d13ff6e578cb9c0ebf892df37f8123174c854b4b823ae6fd7b3b877db9b7d6232361117c6a6f487016850cbe83a89508cbb524"} -{"kind":1,"created_at":1771510682,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"5e1268762622dc55284555b29b5615d85daac2d4cd501be4e350b5b67131f24b","sig":"ef3b6aff41ccf54631d0f4df3b82822bc7d2448b0b5a4a0b341407068750348a510ec89f9f93de5134598655adb8d061d1ae16fa129f66c2c9b7d8ef164e89b8"} -{"kind":1,"created_at":1771511465,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"56a1cb556357f061c6fc0796b2e94adcc7d7765150788a08c71757a267be3350","sig":"6c6c5cfbdcf5d992c690960928a1498bd0d778370d27bed72bac5c543cf53966a13eeeb2c1d61998f877076745f3defecf0f91eed84bcdbd2fa7ee0f24b9840c"} -{"kind":1,"created_at":1771511472,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"0abdba9ee73550f9c72b001977b07a71883100b73761f685575b090f41fb51bc","sig":"25a1f5849e94b33e06e2f90740efe92d4a8338b275d0ff6ad723e51018b2bbeeb16dd7da8c30b38f6205d3836aaf1006cdd21a88d73dfea5a78ab91f88cf0d33"} -{"kind":1,"created_at":1771511567,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"b7d48b0da85b481a8b3511988112a3cc55b32676e5e54f73f11d4204d1867ddd","sig":"675f23a0fab00bb89c37f35171e314d8550917c871309ef29529866bab2d64ecfbfcea5f2e3e84ff9819a44b1ecbb98624479ec9012c059dc4b72ca25c8f1bd0"} -{"kind":1,"created_at":1771512222,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"be185e7ee45a6651cba2586c1dbea1f82e1c656175a58619e9f252e997c66cd6","sig":"adf7cd122f65dbe0a505d35d536ca488d39367ad0bdbb154c73b909182183e0e207b6be470e79fb2ea72a0834b74dcc96697c7c7845726cdab06c6f8bf5cc5ce"} -{"kind":1,"created_at":1771512397,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"f8198dba67d77e0ea99edcc54164a3b05c624588643189626fb54a66073971dd","sig":"f240f5c6cb0497010e6da38cb0771c1b161e10c6126ba4e47820e2838b273fedf91bbd91558e172e3a1be94e868a75ff4e5139aac66d25220ab4083cbe9bda63"} -{"kind":1,"created_at":1771512543,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"56b95e20d0d89c4e04f2ce970d982a3cf4eb843c7c494d5c3363d13474eae98f","sig":"46d0d0de245ecc98b54da628bd850369d06a16a9ef7f48cdada086192e0e3e7453e2bf98244b21ab78d1446478f442c3697c87d025713c79555c7332a8158d3d"} -{"kind":1,"created_at":1771512613,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"559c879a68c63bf1257950b8bdc118b8b30647c192292a044afa1efe48497d4e","sig":"b7ddfaef7f7630583c84b6c2b17768b9899dd44c54482d8bca9a11a78e9ede78e02cce3bc119754951d8c962c24b5a2481977f7ebb9f1a720c3d1f9f225c0111"} -{"kind":1,"created_at":1771512886,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"e733144129fbe2cabbfee223d3b908d7411bcbe2a43cb8e60e77a14b5035aa0a","sig":"1bc994286b401b6ee5fc91ce5819048b5b5980ced9519cafee53c99011f1bcc6c47072eb548005ec87131d49cd13a2dbeca333230fff778ab266badc231f2c33"} -{"kind":1,"created_at":1771512960,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"cafa43b36d5301c1cfc744f9b7e01a95cd695c43c0d54f1a6fc2aef81befb115","sig":"3a5f95f21bae716556697d12710f28f73f1dad27b6be48ae1f6d12f7983c03fa1aa1adbdafe96dbf0b716621443deb686bb5f9775319ad132b55859a258d8533"} -{"kind":1,"created_at":1771513111,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"ea0d419686ffcd3cdb41f16c184abd4d83bb8ee24452828bac8fda4b4df2ba80","sig":"96292550f3d375d906a495fd84f9465c059296aacaede47cd4dadee26c9a74b627c996c608f8d00a5c0238b6c744319f2920f1afd55d07dde0abf662c598aa82"} -{"kind":1,"created_at":1771513431,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"d8584d4c8acf886a8d6bbd4426364e4a4aeefcfb6cb0a6ed1424c1dfb1faabc8","sig":"246f2c15c26ce811a6ecda27d3a48e6e82a27de232752187795aca46b01ae158e1d24e2902fe65201f47224b16905d71abdd20ff5846fd0fd6ae8f1ecf6bc52e"} -{"kind":1,"created_at":1771513536,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"70eff91c6f1842c2e8de975ee8286c2da9cbf1c00d61a886c37340ed2511912b","sig":"9a20af5c64b6092f44f58a8e2814a3a79da5fe9770397927e86b3f8fa100a24061388274530014dcbc26b4672c33a88792cd0a52c6926ef08e11ccef7d0fcffe"} -{"kind":1,"created_at":1771513628,"tags":[["client","gitrepublic-cli"]],"content":"Published from gitrepublic CLI.","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"8b5565bf6d15f8e0ed551fd42fe1ae3c7edf02a6053d1b52aad379c63d609b27","sig":"ac513f9262e69c5b51a92efa4ebd49806f3af8db2621cb4aaf97a9b4c6bb270e8ea8d2f20e03471b3834433699e68477ce7e7fc7e8787137440f38656c5314f7"} -{"kind":1,"created_at":1771514350,"tags":[["client","gitrepublic-cli"]],"content":"After refactor","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"fb1dc615ba763665868d9c99ee1c3dc332e4628e527879b74c80e4ef567a4c6f","sig":"f17c7815431b16d0273524d37e66f0ccf93c7107cd931fe8fe3c2ab3a3421cb82c07c165efeccea5bfa927ff000087ab503ee4669f1324a3acee74b2c51786e7"} -{"kind":1,"created_at":1771514466,"tags":[["client","gitrepublic-cli"]],"content":"After refactor","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"55c904f2a2b03363f456cb3fcacf3a3e37fe42119228f1c221bcf45ff55746fc","sig":"40a7840007c4b4d538be853f22ae431296bd01929d25a3110508a6f601648c7a4b1f60cebf77bd58632edaf903a47f736ef145ae83d40947532636215f3edee3"} -{"kind":1,"created_at":1771514648,"tags":[["client","gitrepublic-cli"]],"content":"After refactor","pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","id":"1f450a0aa75cdfded2452febd6b389a22e18fc55054a67a7792ade5fbccdf821","sig":"b1eab9ad0a04694c465c22141b28d841badec8bf220a1fb2bd1dcfb24e47d1fbdd1eaa46d8916c7c9d7c703e5e44dd117092c7ae6949d4a7e2736711112958dd"} diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index 581f112..5d06a09 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -1623,8 +1623,6 @@ export class FileManager { */ async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise { try { - const { VERIFICATION_FILE_PATH } = await import('../nostr/repo-verification.js'); - if (!this.repoExists(npub, repoName)) { return null; } @@ -1632,25 +1630,44 @@ export class FileManager { const repoPath = this.getRepoPath(npub, repoName); const git: SimpleGit = simpleGit(repoPath); - // Get git log for the announcement file, most recent first + // Get git log for nostr/repo-events.jsonl, most recent first // Use --all to check all branches, --reverse to get chronological order - const logOutput = await git.raw(['log', '--all', '--format=%H', '--reverse', '--', VERIFICATION_FILE_PATH]); + const logOutput = await git.raw(['log', '--all', '--format=%H', '--reverse', '--', 'nostr/repo-events.jsonl']); const commitHashes = logOutput.trim().split('\n').filter(Boolean); if (commitHashes.length === 0) { - return null; // No announcement file in repo + return null; // No announcement in repo } - // Get the most recent announcement file content (last commit in the list) + // Get the most recent repo-events.jsonl content (last commit in the list) const mostRecentCommit = commitHashes[commitHashes.length - 1]; - const announcementFile = await this.getFileContent(npub, repoName, VERIFICATION_FILE_PATH, mostRecentCommit); + const repoEventsFile = await this.getFileContent(npub, repoName, 'nostr/repo-events.jsonl', mostRecentCommit); - // Parse the announcement event from the file - let announcementEvent: any; + // Parse the repo-events.jsonl file and find the most recent announcement + let announcementEvent: any = null; + let latestTimestamp = 0; try { - announcementEvent = JSON.parse(announcementFile.content); + const lines = repoEventsFile.content.trim().split('\n').filter(Boolean); + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'announcement' && entry.event && entry.timestamp) { + if (entry.timestamp > latestTimestamp) { + latestTimestamp = entry.timestamp; + announcementEvent = entry.event; + } + } + } catch { + // Skip invalid lines + continue; + } + } } catch (parseError) { - logger.warn({ error: parseError, npub, repoName, commit: mostRecentCommit }, 'Failed to parse announcement file JSON'); + logger.warn({ error: parseError, npub, repoName, commit: mostRecentCommit }, 'Failed to parse repo-events.jsonl'); + return null; + } + + if (!announcementEvent) { return null; } diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index 82e7e0d..e92126d 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -3,13 +3,13 @@ * Handles repo provisioning, syncing, and NIP-34 integration */ -import { existsSync, mkdirSync, writeFileSync, statSync } from 'fs'; +import { existsSync, mkdirSync, writeFileSync, statSync, readFileSync } from 'fs'; import { join } from 'path'; -import { readdir } from 'fs/promises'; +import { readdir, readFile } from 'fs/promises'; import { spawn } from 'child_process'; import type { NostrEvent } from '../../types/nostr.js'; import { GIT_DOMAIN } from '../../config.js'; -import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js'; +import { validateAnnouncementEvent } from '../nostr/repo-verification.js'; import simpleGit, { type SimpleGit } from 'simple-git'; import logger from '../logger.js'; import { shouldUseTor, getTorProxy } from '../../utils/tor.js'; @@ -138,14 +138,21 @@ export class RepoManager { await this.syncFromRemotes(repoPath.fullPath, otherUrls); } + // Validate announcement event before proceeding + const { validateAnnouncementEvent } = await import('../nostr/repo-verification.js'); + const validation = validateAnnouncementEvent(event, repoPath.repoName); + if (!validation.valid) { + throw new Error(`Invalid announcement event: ${validation.error}`); + } + // Create bare repository if it doesn't exist if (isNewRepo) { // Use simple-git to create bare repo (safer than exec) const git = simpleGit(); await git.init(['--bare', repoPath.fullPath]); - // Create announcement file and self-transfer event in the repository - await this.createVerificationFile(repoPath.fullPath, event, selfTransferEvent); + // Ensure announcement event is saved to nostr/repo-events.jsonl in the repository + await this.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent); // If there are other clone URLs, sync from them after creating the repo if (otherUrls.length > 0) { @@ -154,12 +161,26 @@ export class RepoManager { // No external URLs - this is a brand new repo, create initial branch and README await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event); } - } else if (isExistingRepo && selfTransferEvent) { - // For existing repos, we might want to add the self-transfer event - // But we should be careful not to overwrite existing history - // For now, we'll just ensure the announcement file exists - // The self-transfer event should already be published to relays - logger.info({ repoPath: repoPath.fullPath }, 'Existing repo - self-transfer event should be published to relays'); + } else { + // For existing repos, check if announcement exists in repo + // If not, try to fetch from relays and save it + const hasAnnouncement = await this.hasAnnouncementInRepoFile(repoPath.fullPath); + if (!hasAnnouncement) { + // Try to fetch from relays + const fetchedEvent = await this.fetchAnnouncementFromRelays(event.pubkey, repoPath.repoName); + if (fetchedEvent) { + // Save fetched announcement to repo + await this.ensureAnnouncementInRepo(repoPath.fullPath, fetchedEvent, selfTransferEvent); + } else { + // Announcement not found in repo or relays - this is a problem + logger.warn({ repoPath: repoPath.fullPath }, 'Existing repo has no announcement in repo or on relays'); + } + } + + if (selfTransferEvent) { + // Ensure self-transfer event is also saved + await this.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent); + } } } @@ -203,39 +224,6 @@ You can use this read-me file to explain the purpose of this repo to everyone wh Your commits will all be signed by your Nostr keys and saved to the event files in the ./nostr folder. `; - // Generate announcement file content - const { generateVerificationFile, VERIFICATION_FILE_PATH } = await import('../nostr/repo-verification.js'); - - // Try to get announcement file content from client-signed event (kind 1642) first - let announcementFileContent: string | null = null; - try { - const { NostrClient } = await import('../nostr/nostr-client.js'); - const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - - // Look for a kind 1642 event that references this announcement - const announcementFileEvents = await nostrClient.fetchEvents([ - { - kinds: [1642], // Announcement file event kind - authors: [announcementEvent.pubkey], - '#e': [announcementEvent.id], // References this announcement - limit: 1 - } - ]); - - if (announcementFileEvents.length > 0) { - // Extract announcement file content from the client-signed event - announcementFileContent = announcementFileEvents[0].content; - logger.info({ repoPath, announcementFileEventId: announcementFileEvents[0].id }, 'Using client-signed announcement file for initial commit'); - } - } catch (err) { - logger.debug({ error: err, repoPath }, 'Failed to fetch announcement file event, will generate from announcement event'); - } - - // If client didn't provide announcement file, generate it from the announcement event - if (!announcementFileContent) { - announcementFileContent = generateVerificationFile(announcementEvent, announcementEvent.pubkey); - } - // Use FileManager to create the initial branch and files const { FileManager } = await import('./file-manager.js'); const fileManager = new FileManager(this.repoRoot); @@ -257,29 +245,29 @@ Your commits will all be signed by your Nostr keys and saved to the event files await fileManager.createBranch(npub, repoName, defaultBranch, undefined); } - // Create both README.md and announcement file in the initial commit + // Create both README.md and announcement in the initial commit // We'll use a worktree to write both files and commit them together const workDir = await fileManager.getWorktree(repoPath, defaultBranch, npub, repoName); - const { writeFile: writeFileFs, mkdir } = await import('fs/promises'); + const { writeFile: writeFileFs } = await import('fs/promises'); const { join } = await import('path'); // Write README.md const readmePath = join(workDir, 'README.md'); await writeFileFs(readmePath, readmeContent, 'utf-8'); - // Write announcement file - const announcementPath = join(workDir, VERIFICATION_FILE_PATH); - await writeFileFs(announcementPath, announcementFileContent, 'utf-8'); - - // Save repo announcement event to nostr/repo-events.jsonl (standard file for easy analysis) - await this.saveRepoEventToWorktree(workDir, announcementEvent, 'announcement'); + // Save repo announcement event to nostr/repo-events.jsonl (only if not already present) + const announcementSaved = await this.saveRepoEventToWorktree(workDir, announcementEvent, 'announcement', true); - // Stage both files + // Stage files const workGit = simpleGit(workDir); - await workGit.add(['README.md', VERIFICATION_FILE_PATH]); + const filesToAdd: string[] = ['README.md']; + if (announcementSaved) { + filesToAdd.push('nostr/repo-events.jsonl'); + } + await workGit.add(filesToAdd); - // Commit both files together - const commitResult = await workGit.commit('Initial commit', ['README.md', VERIFICATION_FILE_PATH], { + // Commit files together + const commitResult = await workGit.commit('Initial commit', filesToAdd, { '--author': `${authorName} <${authorEmail}>` }); @@ -651,17 +639,41 @@ Your commits will all be signed by your Nostr keys and saved to the event files npub: string, repoName: string, announcementEvent?: NostrEvent - ): Promise { + ): Promise<{ success: boolean; needsAnnouncement?: boolean; announcement?: NostrEvent }> { const repoPath = join(this.repoRoot, npub, `${repoName}.git`); - // If repo already exists, no need to fetch + // If repo already exists, check if it has an announcement if (existsSync(repoPath)) { - return true; + const hasAnnouncement = await this.hasAnnouncementInRepoFile(repoPath); + if (hasAnnouncement) { + return { success: true }; + } + + // Repo exists but no announcement - try to fetch from relays + const { requireNpubHex: requireNpubHexUtil } = await import('../../utils/npub-utils.js'); + const repoOwnerPubkey = requireNpubHexUtil(npub); + const fetchedAnnouncement = await this.fetchAnnouncementFromRelays(repoOwnerPubkey, repoName); + if (fetchedAnnouncement) { + // Save fetched announcement to repo + await this.ensureAnnouncementInRepo(repoPath, fetchedAnnouncement); + return { success: true, announcement: fetchedAnnouncement }; + } + + // Repo exists but no announcement found - needs announcement + return { success: false, needsAnnouncement: true }; } - // If no announcement provided, we can't fetch (caller should provide it) + // If no announcement provided, try to fetch from relays if (!announcementEvent) { - return false; + const { requireNpubHex: requireNpubHexUtil } = await import('../../utils/npub-utils.js'); + const repoOwnerPubkey = requireNpubHexUtil(npub); + const fetchedAnnouncement = await this.fetchAnnouncementFromRelays(repoOwnerPubkey, repoName); + if (fetchedAnnouncement) { + announcementEvent = fetchedAnnouncement; + } else { + // No announcement found - needs announcement + return { success: false, needsAnnouncement: true }; + } } // Check if repository is public @@ -680,7 +692,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files pubkey: announcementEvent.pubkey.slice(0, 16) + '...', level: userLevel?.level || 'none' }, 'Skipping on-demand repo fetch: private repo requires owner with unlimited access'); - return false; + return { success: false, needsAnnouncement: false }; } } else { logger.info({ @@ -717,7 +729,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files if (remoteUrls.length === 0) { logger.warn({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found for on-demand fetch'); - return false; + return { success: false, needsAnnouncement: false }; } logger.debug({ npub, repoName, cloneUrls, remoteUrls, isPublic }, 'On-demand fetch details'); @@ -798,16 +810,16 @@ Your commits will all be signed by your Nostr keys and saved to the event files throw new Error('Repository clone completed but repository path does not exist'); } - // Create announcement file with the signed announcement event (non-blocking - repo is usable without it) + // Ensure announcement is saved to nostr/repo-events.jsonl (non-blocking - repo is usable without it) try { - await this.createVerificationFile(repoPath, announcementEvent); + await this.ensureAnnouncementInRepo(repoPath, announcementEvent); } catch (verifyError) { // Announcement file creation is optional - log but don't fail - logger.warn({ error: verifyError, npub, repoName }, 'Failed to create announcement file, but repository is usable'); + logger.warn({ error: verifyError, npub, repoName }, 'Failed to ensure announcement in repo, but repository is usable'); } logger.info({ npub, repoName }, 'Successfully fetched repository on-demand'); - return true; + return { success: true, announcement: announcementEvent }; } catch (error) { const sanitizedError = sanitizeError(error); logger.error({ @@ -818,7 +830,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files isPublic, remoteUrls }, 'Failed to fetch repository on-demand'); - return false; + return { success: false, needsAnnouncement: false }; } } @@ -887,10 +899,10 @@ Your commits will all be signed by your Nostr keys and saved to the event files } /** - * Create announcement file and self-transfer event in a new repository - * The announcement file contains the full signed announcement event JSON, proving ownership + * Ensure announcement event is saved to nostr/repo-events.jsonl in the repository + * Only saves if not already present (avoids redundant entries) */ - private async createVerificationFile(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent): Promise { + private async ensureAnnouncementInRepo(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent): Promise { try { // Create a temporary working directory const repoName = this.parseRepoPathForName(repoPath)?.repoName || 'temp'; @@ -907,102 +919,63 @@ Your commits will all be signed by your Nostr keys and saved to the event files const git: SimpleGit = simpleGit(); await git.clone(repoPath, workDir); - // Extract announcement file content from client-signed event (kind 1642) - // The client creates and signs this event separately, with an 'e' tag pointing to the announcement - // The content is just the full announcement event JSON - simpler than a custom verification format - let announcementFileContent: string | null = null; + // Check if announcement already exists in nostr/repo-events.jsonl + const hasAnnouncement = await this.hasAnnouncementInRepo(workDir, event.id); - try { - const { NostrClient } = await import('../nostr/nostr-client.js'); - const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); - const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - - // Look for a kind 1642 event that references this announcement - const announcementFileEvents = await nostrClient.fetchEvents([ - { - kinds: [1642], // Announcement file event kind - authors: [event.pubkey], - '#e': [event.id], // References this announcement - limit: 1 - } - ]); - - if (announcementFileEvents.length > 0) { - // Extract announcement file content from the client-signed event - announcementFileContent = announcementFileEvents[0].content; - logger.info({ repoPath, announcementFileEventId: announcementFileEvents[0].id }, 'Using client-signed announcement file'); + const filesToAdd: string[] = []; + + // Only save announcement if not already present + if (!hasAnnouncement) { + const saved = await this.saveRepoEventToWorktree(workDir, event, 'announcement', false); + if (saved) { + filesToAdd.push('nostr/repo-events.jsonl'); + logger.info({ repoPath, eventId: event.id }, 'Saved announcement to nostr/repo-events.jsonl'); } - } catch (err) { - logger.warn({ error: err, repoPath }, 'Failed to fetch announcement file event, generating server-side'); + } else { + logger.debug({ repoPath, eventId: event.id }, 'Announcement already exists in repo, skipping'); } - // If client didn't provide announcement file, generate it from the announcement event - if (!announcementFileContent) { - announcementFileContent = generateVerificationFile(event, event.pubkey); - } - - // Write announcement file (contains the full signed announcement event JSON) - const announcementPath = join(workDir, VERIFICATION_FILE_PATH); - writeFileSync(announcementPath, announcementFileContent, 'utf-8'); - - // Save repo events to nostr/repo-events.jsonl (standard file for easy analysis) - await this.saveRepoEventToWorktree(workDir, event, 'announcement'); + // Save transfer event if provided if (selfTransferEvent) { - await this.saveRepoEventToWorktree(workDir, selfTransferEvent, 'transfer'); + const saved = await this.saveRepoEventToWorktree(workDir, selfTransferEvent, 'transfer', false); + if (saved) { + if (!filesToAdd.includes('nostr/repo-events.jsonl')) { + filesToAdd.push('nostr/repo-events.jsonl'); + } + } } - // If self-transfer event is provided, include it in the commit - const filesToAdd = [VERIFICATION_FILE_PATH]; - if (selfTransferEvent) { - const selfTransferPath = join(workDir, '.nostr-ownership-transfer'); - const isTemplate = !selfTransferEvent.sig || !selfTransferEvent.id; + // Only commit if we added files + if (filesToAdd.length > 0) { + const workGit: SimpleGit = simpleGit(workDir); + await workGit.add(filesToAdd); - const selfTransferContent = JSON.stringify({ - eventId: selfTransferEvent.id || '(unsigned - needs owner signature)', - pubkey: selfTransferEvent.pubkey, - signature: selfTransferEvent.sig || '(unsigned - needs owner signature)', - timestamp: selfTransferEvent.created_at, - kind: selfTransferEvent.kind, - content: selfTransferEvent.content, - tags: selfTransferEvent.tags, - ...(isTemplate ? { - _note: 'This is a template. The owner must sign and publish this event to relays for it to be valid.', - _instructions: 'To publish: 1. Sign this event with your private key, 2. Publish to relays using your Nostr client' - } : {}) - }, null, 2) + '\n'; - writeFileSync(selfTransferPath, selfTransferContent, 'utf-8'); - filesToAdd.push('.nostr-ownership-transfer'); - } - - // Commit the verification file and self-transfer event - const workGit: SimpleGit = simpleGit(workDir); - await workGit.add(filesToAdd); - - // Use the event timestamp for commit date - const commitDate = new Date(event.created_at * 1000).toISOString(); - const commitMessage = selfTransferEvent - ? 'Add Nostr repository announcement and initial ownership proof' - : 'Add Nostr repository announcement'; - - // Note: Initial commits are unsigned. The repository owner can sign their own commits - // when they make changes. The server should never sign commits on behalf of users. - - await workGit.commit(commitMessage, filesToAdd, { - '--author': `Nostr <${event.pubkey}@nostr>`, - '--date': commitDate - }); + // Use the event timestamp for commit date + const commitDate = new Date(event.created_at * 1000).toISOString(); + const commitMessage = selfTransferEvent + ? 'Add Nostr repository announcement and initial ownership proof' + : 'Add Nostr repository announcement'; + + // Note: Initial commits are unsigned. The repository owner can sign their own commits + // when they make changes. The server should never sign commits on behalf of users. + + await workGit.commit(commitMessage, filesToAdd, { + '--author': `Nostr <${event.pubkey}@nostr>`, + '--date': commitDate + }); - // Push back to bare repo - await workGit.push(['origin', 'main']).catch(async () => { - // If main branch doesn't exist, create it - await workGit.checkout(['-b', 'main']); - await workGit.push(['origin', 'main']); - }); + // Push back to bare repo + await workGit.push(['origin', 'main']).catch(async () => { + // If main branch doesn't exist, create it + await workGit.checkout(['-b', 'main']); + await workGit.push(['origin', 'main']); + }); + } // Clean up await rm(workDir, { recursive: true, force: true }); } catch (error) { - logger.error({ error, repoPath }, 'Failed to create announcement file'); + logger.error({ error, repoPath }, 'Failed to ensure announcement in repo'); // Don't throw - announcement file creation is important but shouldn't block provisioning } } @@ -1016,18 +989,149 @@ Your commits will all be signed by your Nostr keys and saved to the event files return { repoName: match[1] }; } + /** + * Check if an announcement event already exists in nostr/repo-events.jsonl + */ + private async hasAnnouncementInRepo(worktreePath: string, eventId?: string): Promise { + try { + const jsonlFile = join(worktreePath, 'nostr', 'repo-events.jsonl'); + if (!existsSync(jsonlFile)) { + return false; + } + + const content = await readFile(jsonlFile, 'utf-8'); + const lines = content.trim().split('\n').filter(Boolean); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'announcement' && entry.event) { + // If eventId provided, check for exact match + if (eventId) { + if (entry.event.id === eventId) { + return true; + } + } else { + // Just check if any announcement exists + return true; + } + } + } catch { + // Skip invalid lines + continue; + } + } + + return false; + } catch (err) { + logger.debug({ error: err, worktreePath }, 'Failed to check for announcement in repo'); + return false; + } + } + + /** + * Read announcement event from nostr/repo-events.jsonl + */ + private async getAnnouncementFromRepo(worktreePath: string): Promise { + try { + const jsonlFile = join(worktreePath, 'nostr', 'repo-events.jsonl'); + if (!existsSync(jsonlFile)) { + return null; + } + + const content = await readFile(jsonlFile, 'utf-8'); + const lines = content.trim().split('\n').filter(Boolean); + + // Find the most recent announcement event + let latestAnnouncement: NostrEvent | null = null; + let latestTimestamp = 0; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'announcement' && entry.event && entry.timestamp) { + if (entry.timestamp > latestTimestamp) { + latestTimestamp = entry.timestamp; + latestAnnouncement = entry.event; + } + } + } catch { + // Skip invalid lines + continue; + } + } + + return latestAnnouncement; + } catch (err) { + logger.debug({ error: err, worktreePath }, 'Failed to read announcement from repo'); + return null; + } + } + + /** + * Fetch announcement from relays and validate it + */ + private async fetchAnnouncementFromRelays( + repoOwnerPubkey: string, + repoName: string + ): Promise { + try { + const { NostrClient } = await import('../nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); + const { KIND } = await import('../../types/nostr.js'); + + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repoName], + limit: 1 + } + ]); + + if (events.length === 0) { + return null; + } + + const event = events[0]; + + // Validate the event + const validation = validateAnnouncementEvent(event, repoName); + if (!validation.valid) { + logger.warn({ error: validation.error, repoName }, 'Fetched announcement failed validation'); + return null; + } + + return event; + } catch (err) { + logger.debug({ error: err, repoOwnerPubkey, repoName }, 'Failed to fetch announcement from relays'); + return null; + } + } + /** * Save a repo event (announcement or transfer) to nostr/repo-events.jsonl + * Only saves if not already present (for announcements) * This provides a standard location for all repo-related Nostr events for easy analysis */ private async saveRepoEventToWorktree( worktreePath: string, event: NostrEvent, - eventType: 'announcement' | 'transfer' - ): Promise { + eventType: 'announcement' | 'transfer', + skipIfExists: boolean = true + ): Promise { try { + // For announcements, check if already exists + if (eventType === 'announcement' && skipIfExists) { + const exists = await this.hasAnnouncementInRepo(worktreePath, event.id); + if (exists) { + logger.debug({ eventId: event.id, worktreePath }, 'Announcement already exists in repo, skipping'); + return false; + } + } + const { mkdir, writeFile } = await import('fs/promises'); - const { join } = await import('path'); // Create nostr directory in worktree const nostrDir = join(worktreePath, 'nostr'); @@ -1041,17 +1145,19 @@ Your commits will all be signed by your Nostr keys and saved to the event files event }) + '\n'; await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); + return true; } catch (err) { logger.debug({ error: err, worktreePath, eventType }, 'Failed to save repo event to nostr/repo-events.jsonl'); // Don't throw - this is a nice-to-have feature + return false; } } /** - * Check if a repository already has an announcement file + * Check if a repository already has an announcement in nostr/repo-events.jsonl * Used to determine if this is a truly new repo or an existing one being added */ - async hasVerificationFile(repoPath: string): Promise { + async hasAnnouncementInRepoFile(repoPath: string): Promise { if (!this.repoExists(repoPath)) { return false; } @@ -1068,15 +1174,14 @@ Your commits will all be signed by your Nostr keys and saved to the event files } await mkdir(workDir, { recursive: true }); - // Try to clone and check for verification file + // Try to clone and check for announcement in nostr/repo-events.jsonl await git.clone(repoPath, workDir); - const verificationPath = join(workDir, VERIFICATION_FILE_PATH); - const hasFile = existsSync(verificationPath); + const hasAnnouncement = await this.hasAnnouncementInRepo(workDir, undefined); // Clean up await rm(workDir, { recursive: true, force: true }); - return hasFile; + return hasAnnouncement; } catch { // If we can't check, assume it doesn't have one return false; diff --git a/src/lib/services/nostr/fork-count-service.ts b/src/lib/services/nostr/fork-count-service.ts index 6af0837..de7710c 100644 --- a/src/lib/services/nostr/fork-count-service.ts +++ b/src/lib/services/nostr/fork-count-service.ts @@ -32,16 +32,22 @@ export class ForkCountService { try { // Find all repo announcements that reference this repo as a fork const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${originalRepoName}`; - const forkEvents = await this.nostrClient.fetchEvents([ + + // Fetch all repo announcements and filter for forks manually + // (NostrFilter doesn't support '#fork' tag, so we fetch all and filter) + const allRepoEvents = await this.nostrClient.fetchEvents([ { kinds: [KIND.REPO_ANNOUNCEMENT], - '#a': [repoTag], - limit: 1000 // Reasonable limit for fork count + limit: 1000 } ]); - // Filter for actual forks (have 'a' tag matching the original repo) - const forks = forkEvents.filter(event => { + // Filter for actual forks (have 'fork' tag or legacy 'a' tag matching the original repo) + const forks = allRepoEvents.filter(event => { + // Check for new standardized fork tag format: ['fork', '30617:pubkey:d-tag'] + const forkTag = event.tags.find(t => t[0] === 'fork' && t[1] === repoTag); + if (forkTag) return true; + // Legacy support: check for 'a' tag const aTag = event.tags.find(t => t[0] === 'a' && t[1] === repoTag); return aTag !== undefined; }); diff --git a/src/routes/api/repos/[npub]/[repo]/clone/+server.ts b/src/routes/api/repos/[npub]/[repo]/clone/+server.ts index ba69e54..f9df866 100644 --- a/src/routes/api/repos/[npub]/[repo]/clone/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/clone/+server.ts @@ -77,9 +77,15 @@ export const POST: RequestHandler = async (event) => { const announcementEvent = events[0]; // Attempt to clone the repository - const cloned = await repoManager.fetchRepoOnDemand(npub, repo, announcementEvent); + const result = await repoManager.fetchRepoOnDemand(npub, repo, announcementEvent); - if (!cloned) { + if (!result.success) { + if (result.needsAnnouncement) { + throw handleValidationError( + 'Repository announcement is required. Please provide an announcement event or create one.', + { operation: 'cloneRepo', npub, repo } + ); + } throw handleApiError( new Error('Failed to clone repository from remote URLs'), { operation: 'cloneRepo', npub, repo }, diff --git a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts index 7a76cbb..7c6b231 100644 --- a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts @@ -243,14 +243,15 @@ export const POST: RequestHandler = async ({ params, request }) => { } // Build fork announcement tags + // Use standardized fork tag: ['fork', '30617:pubkey:d-tag'] + const originalRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`; const tags: string[][] = [ ['d', forkRepoName], ['name', `${originalName} (fork)`], ['description', `Fork of ${originalName}${originalDescription ? `: ${originalDescription}` : ''}`], ['clone', ...forkCloneUrls], ['relays', ...DEFAULT_NOSTR_RELAYS], - ['t', 'fork'], // Mark as fork - ['a', `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`], // Reference to original repo + ['fork', originalRepoTag], // Standardized fork tag format ['p', originalOwnerPubkey], // Original owner ]; @@ -362,26 +363,36 @@ 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) + // Save fork announcement to repo (offline papertrail) in nostr/repo-events.jsonl 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, + // Get worktree to save to repo-events.jsonl + const defaultBranch = await fileManager.getDefaultBranch(userNpub, forkRepoName).catch(() => 'main'); + const repoPath = fileManager.getRepoPath(userNpub, forkRepoName); + const workDir = await fileManager.getWorktree(repoPath, defaultBranch, userNpub, forkRepoName); + + // Save to repo-events.jsonl + await fileManager.saveRepoEventToWorktree(workDir, signedForkAnnouncement as NostrEvent, 'announcement').catch(err => { + logger.debug({ error: err }, 'Failed to save fork announcement to repo-events.jsonl'); + }); + + // Stage and commit the file + const workGit = simpleGit(workDir); + await workGit.add(['nostr/repo-events.jsonl']); + await workGit.commit( `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'); + ['nostr/repo-events.jsonl'], + { + '--author': `Nostr <${userPubkeyHex}@nostr>` + } + ); + + // Clean up worktree + await fileManager.removeWorktree(repoPath, workDir).catch(err => { + logger.debug({ error: err }, 'Failed to remove worktree after saving fork announcement'); }); } } catch (err) { diff --git a/src/routes/api/repos/[npub]/[repo]/validate/+server.ts b/src/routes/api/repos/[npub]/[repo]/validate/+server.ts new file mode 100644 index 0000000..6f1c065 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/validate/+server.ts @@ -0,0 +1,130 @@ +/** + * API endpoint for validating repository announcements + * Checks if repo has valid announcement in nostr/repo-events.jsonl and on relays + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; +import type { RepoRequestContext } from '$lib/utils/api-context.js'; +import { fileManager, nostrClient } from '$lib/services/service-registry.js'; +import { validateAnnouncementEvent } from '$lib/services/nostr/repo-verification.js'; +import { KIND } from '$lib/types/nostr.js'; +import { requireNpubHex } from '$lib/utils/npub-utils.js'; +import type { NostrEvent } from '$lib/types/nostr.js'; +import logger from '$lib/services/logger.js'; + +/** + * GET - Validate repository announcement + * Checks: + * - Announcement exists in repo (nostr/repo-events.jsonl) + * - Announcement exists on relays + * - Announcement signature is valid + * - Announcement matches repo (d-tag matches repo name) + */ +export const GET: RequestHandler = createRepoGetHandler( + async (context: RepoRequestContext) => { + const { npub, repo } = context; + const repoOwnerPubkey = requireNpubHex(npub); + + // Check if repo exists + if (!fileManager.repoExists(npub, repo)) { + return json({ + valid: false, + error: 'Repository not found', + inRepo: false, + onRelays: false + }); + } + + let inRepo = false; + let onRelays = false; + let repoAnnouncement: NostrEvent | null = null; + let relayAnnouncement: NostrEvent | null = null; + let validationError: string | undefined; + + // Check announcement in repo (nostr/repo-events.jsonl) + try { + const repoEventsFile = await fileManager.getFileContent(npub, repo, 'nostr/repo-events.jsonl', 'HEAD'); + const lines = repoEventsFile.content.trim().split('\n').filter(Boolean); + let latestTimestamp = 0; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'announcement' && entry.event && entry.timestamp) { + if (entry.timestamp > latestTimestamp) { + latestTimestamp = entry.timestamp; + repoAnnouncement = entry.event; + } + } + } catch { + continue; + } + } + + if (repoAnnouncement) { + inRepo = true; + } + } catch (err) { + logger.debug({ error: err, npub, repo }, 'Failed to read announcement from repo'); + } + + // Check announcement on relays + try { + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repo], + limit: 1 + } + ]); + + if (events.length > 0) { + relayAnnouncement = events[0]; + onRelays = true; + } + } catch (err) { + logger.debug({ error: err, npub, repo }, 'Failed to fetch announcement from relays'); + } + + // Use repo announcement if available, otherwise use relay announcement + const announcement = repoAnnouncement || relayAnnouncement; + + if (!announcement) { + return json({ + valid: false, + error: 'No announcement found in repo or on relays', + inRepo: false, + onRelays: false + }); + } + + // Validate the announcement + const validation = validateAnnouncementEvent(announcement, repo); + if (!validation.valid) { + validationError = validation.error; + } + + // Check if announcements match (if both exist) + let announcementsMatch = true; + if (repoAnnouncement && relayAnnouncement) { + announcementsMatch = repoAnnouncement.id === relayAnnouncement.id; + } + + return json({ + valid: validation.valid && announcementsMatch, + error: validationError || (announcementsMatch ? undefined : 'Announcements in repo and on relays do not match'), + inRepo, + onRelays, + announcementsMatch: repoAnnouncement && relayAnnouncement ? announcementsMatch : undefined, + announcementId: announcement.id, + announcementPubkey: announcement.pubkey, + announcementCreatedAt: announcement.created_at, + repoAnnouncementId: repoAnnouncement?.id, + relayAnnouncementId: relayAnnouncement?.id + }); + }, + { operation: 'validateRepo', requireRepoAccess: false } // Validation is public +); diff --git a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts index 8da348d..c833b58 100644 --- a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts @@ -6,7 +6,8 @@ import { json, error } from '@sveltejs/kit'; // @ts-ignore - SvelteKit generates this type import type { RequestHandler } from './$types'; import { fileManager } from '$lib/services/service-registry.js'; -import { verifyRepositoryOwnership, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js'; +import { verifyRepositoryOwnership } from '$lib/services/nostr/repo-verification.js'; +import type { NostrEvent } from '$lib/types/nostr.js'; import { nostrClient } from '$lib/services/service-registry.js'; import { KIND } from '$lib/types/nostr.js'; import { existsSync } from 'fs'; @@ -72,13 +73,37 @@ export const GET: RequestHandler = createRepoGetHandler( localOwner = await fileManager.getCurrentOwnerFromRepo(context.npub, context.repo); if (localOwner) { - // Verify the announcement file matches the announcement event + // Verify the announcement in nostr/repo-events.jsonl matches the announcement event try { - const announcementFile = await fileManager.getFileContent(context.npub, context.repo, VERIFICATION_FILE_PATH, 'HEAD'); - const verification = verifyRepositoryOwnership(announcement, announcementFile.content); - localVerified = verification.valid; - if (!verification.valid) { - localError = verification.error; + const repoEventsFile = await fileManager.getFileContent(context.npub, context.repo, 'nostr/repo-events.jsonl', 'HEAD'); + // Parse repo-events.jsonl and find the most recent announcement + const lines = repoEventsFile.content.trim().split('\n').filter(Boolean); + let repoAnnouncement: NostrEvent | null = null; + let latestTimestamp = 0; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + if (entry.type === 'announcement' && entry.event && entry.timestamp) { + if (entry.timestamp > latestTimestamp) { + latestTimestamp = entry.timestamp; + repoAnnouncement = entry.event; + } + } + } catch { + continue; + } + } + + if (repoAnnouncement) { + const verification = verifyRepositoryOwnership(announcement, JSON.stringify(repoAnnouncement)); + localVerified = verification.valid; + if (!verification.valid) { + localError = verification.error; + } + } else { + localVerified = false; + localError = 'No announcement found in nostr/repo-events.jsonl'; } } catch (err) { localVerified = false; @@ -86,7 +111,7 @@ export const GET: RequestHandler = createRepoGetHandler( } } else { localVerified = false; - localError = 'No announcement file found in repository'; + localError = 'No announcement found in repository'; } } catch (err) { localVerified = false; diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 4309af3..3668782 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -16,7 +16,7 @@ import { nip19 } from 'nostr-tools'; import { userStore } from '$lib/stores/user-store.js'; import { settingsStore } from '$lib/services/settings-store.js'; - import { generateVerificationFile, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js'; + // Note: Announcements are now stored in nostr/repo-events.jsonl, not .nostr-announcement import type { NostrEvent } from '$lib/types/nostr.js'; import { hasUnlimitedAccess } from '$lib/utils/user-access.js'; import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js'; @@ -1777,9 +1777,9 @@ } } - async function generateVerificationFileForRepo() { + async function generateAnnouncementFileForRepo() { if (!pageData.repoOwnerPubkey || !userPubkeyHex) { - error = 'Unable to generate verification file: missing repository or user information'; + error = 'Unable to generate announcement file: missing repository or user information'; return; } @@ -1801,11 +1801,12 @@ } const announcement = events[0] as NostrEvent; - verificationFileContent = generateVerificationFile(announcement, pageData.repoOwnerPubkey); + // Generate announcement event JSON (for download/reference) + verificationFileContent = JSON.stringify(announcement, null, 2) + '\n'; showVerificationDialog = true; } catch (err) { - console.error('Failed to generate verification file:', err); - error = `Failed to generate verification file: ${err instanceof Error ? err.message : String(err)}`; + console.error('Failed to generate announcement file:', err); + error = `Failed to generate announcement file: ${err instanceof Error ? err.message : String(err)}`; } } @@ -1901,7 +1902,7 @@ const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = VERIFICATION_FILE_PATH; + a.download = 'announcement-event.json'; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -3227,7 +3228,7 @@ {#if pageData.repoOwnerPubkey && userPubkeyHex === pageData.repoOwnerPubkey} {#if verificationStatus?.verified !== true}