Browse Source

restrict repos to announced events

Nostr-Signature: d7ee36680a38fac493b27fba26d6e1c496dee9a3099db68a4352f7709a41e860 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 071cc8031940590785e5566a45159e5324e36e8a06023282ab1d50b608902d3b06d95efc03d0a4da861a88f12381f7b64999c09a49dfe5f36fbd8ec6aefd8aeb
main
Silberengel 3 weeks ago
parent
commit
802d5953b8
  1. 1
      nostr/commit-signatures.jsonl
  2. 19
      nostr/events-kind-1.jsonl
  3. 39
      src/lib/services/git/file-manager.ts
  4. 431
      src/lib/services/git/repo-manager.ts
  5. 16
      src/lib/services/nostr/fork-count-service.ts
  6. 10
      src/routes/api/repos/[npub]/[repo]/clone/+server.ts
  7. 43
      src/routes/api/repos/[npub]/[repo]/fork/+server.ts
  8. 130
      src/routes/api/repos/[npub]/[repo]/validate/+server.ts
  9. 41
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  10. 23
      src/routes/repos/[npub]/[repo]/+page.svelte

1
nostr/commit-signatures.jsonl

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

19
nostr/events-kind-1.jsonl

@ -1,19 +0,0 @@ @@ -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"}

39
src/lib/services/git/file-manager.ts

@ -1623,8 +1623,6 @@ export class FileManager { @@ -1623,8 +1623,6 @@ export class FileManager {
*/
async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise<string | null> {
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 { @@ -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;
}

431
src/lib/services/git/repo-manager.ts

@ -3,13 +3,13 @@ @@ -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 { @@ -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 { @@ -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 @@ -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 @@ -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 @@ -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<boolean> {
): 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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<void> {
private async ensureAnnouncementInRepo(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent): Promise<void> {
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 @@ -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 @@ -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<boolean> {
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<NostrEvent | null> {
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<NostrEvent | null> {
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<void> {
eventType: 'announcement' | 'transfer',
skipIfExists: boolean = true
): Promise<boolean> {
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 @@ -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<boolean> {
async hasAnnouncementInRepoFile(repoPath: string): Promise<boolean> {
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 @@ -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;

16
src/lib/services/nostr/fork-count-service.ts

@ -32,16 +32,22 @@ export class ForkCountService { @@ -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;
});

10
src/routes/api/repos/[npub]/[repo]/clone/+server.ts

@ -77,9 +77,15 @@ export const POST: RequestHandler = async (event) => { @@ -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 },

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

@ -243,14 +243,15 @@ export const POST: RequestHandler = async ({ params, request }) => { @@ -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 }) => { @@ -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) {

130
src/routes/api/repos/[npub]/[repo]/validate/+server.ts

@ -0,0 +1,130 @@ @@ -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
);

41
src/routes/api/repos/[npub]/[repo]/verify/+server.ts

@ -6,7 +6,8 @@ import { json, error } from '@sveltejs/kit'; @@ -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( @@ -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( @@ -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;

23
src/routes/repos/[npub]/[repo]/+page.svelte

@ -16,7 +16,7 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -3227,7 +3228,7 @@
{#if pageData.repoOwnerPubkey && userPubkeyHex === pageData.repoOwnerPubkey}
{#if verificationStatus?.verified !== true}
<button
onclick={() => { generateVerificationFileForRepo(); showRepoMenu = false; }}
onclick={() => { generateAnnouncementFileForRepo(); showRepoMenu = false; }}
class="repo-menu-item"
title="Generate verification file"
>
@ -4615,12 +4616,12 @@ @@ -4615,12 +4616,12 @@
</div>
<div class="modal-body">
<p class="verification-instructions">
Create a file named <code>{VERIFICATION_FILE_PATH}</code> in the root of your git repository and paste the content below into it.
Then commit and push the file to your repository.
The announcement event should be saved to <code>nostr/repo-events.jsonl</code> in your repository.
You can download the announcement event JSON below for reference.
</p>
<div class="verification-file-content">
<div class="file-header">
<span class="filename">{VERIFICATION_FILE_PATH}</span>
<span class="filename">announcement-event.json</span>
<div class="file-actions">
<button onclick={copyVerificationToClipboard} class="copy-button">Copy</button>
<button onclick={downloadVerificationFile} class="download-button">Download</button>

Loading…
Cancel
Save