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. 425
      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 @@
{"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":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":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":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 @@
{"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 {
*/ */
async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise<string | null> { async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise<string | null> {
try { try {
const { VERIFICATION_FILE_PATH } = await import('../nostr/repo-verification.js');
if (!this.repoExists(npub, repoName)) { if (!this.repoExists(npub, repoName)) {
return null; return null;
} }
@ -1632,25 +1630,44 @@ export class FileManager {
const repoPath = this.getRepoPath(npub, repoName); const repoPath = this.getRepoPath(npub, repoName);
const git: SimpleGit = simpleGit(repoPath); 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 // 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); const commitHashes = logOutput.trim().split('\n').filter(Boolean);
if (commitHashes.length === 0) { 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 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 // Parse the repo-events.jsonl file and find the most recent announcement
let announcementEvent: any; let announcementEvent: any = null;
let latestTimestamp = 0;
try { 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) { } 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; return null;
} }

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

@ -3,13 +3,13 @@
* Handles repo provisioning, syncing, and NIP-34 integration * 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 { join } from 'path';
import { readdir } from 'fs/promises'; import { readdir, readFile } from 'fs/promises';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { GIT_DOMAIN } from '../../config.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 simpleGit, { type SimpleGit } from 'simple-git';
import logger from '../logger.js'; import logger from '../logger.js';
import { shouldUseTor, getTorProxy } from '../../utils/tor.js'; import { shouldUseTor, getTorProxy } from '../../utils/tor.js';
@ -138,14 +138,21 @@ export class RepoManager {
await this.syncFromRemotes(repoPath.fullPath, otherUrls); 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 // Create bare repository if it doesn't exist
if (isNewRepo) { if (isNewRepo) {
// Use simple-git to create bare repo (safer than exec) // Use simple-git to create bare repo (safer than exec)
const git = simpleGit(); const git = simpleGit();
await git.init(['--bare', repoPath.fullPath]); await git.init(['--bare', repoPath.fullPath]);
// Create announcement file and self-transfer event in the repository // Ensure announcement event is saved to nostr/repo-events.jsonl in the repository
await this.createVerificationFile(repoPath.fullPath, event, selfTransferEvent); await this.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent);
// If there are other clone URLs, sync from them after creating the repo // If there are other clone URLs, sync from them after creating the repo
if (otherUrls.length > 0) { 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 // No external URLs - this is a brand new repo, create initial branch and README
await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event); await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event);
} }
} else if (isExistingRepo && selfTransferEvent) { } else {
// For existing repos, we might want to add the self-transfer event // For existing repos, check if announcement exists in repo
// But we should be careful not to overwrite existing history // If not, try to fetch from relays and save it
// For now, we'll just ensure the announcement file exists const hasAnnouncement = await this.hasAnnouncementInRepoFile(repoPath.fullPath);
// The self-transfer event should already be published to relays if (!hasAnnouncement) {
logger.info({ repoPath: repoPath.fullPath }, 'Existing repo - self-transfer event should be published to relays'); // 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. 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 // Use FileManager to create the initial branch and files
const { FileManager } = await import('./file-manager.js'); const { FileManager } = await import('./file-manager.js');
const fileManager = new FileManager(this.repoRoot); 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); 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 // We'll use a worktree to write both files and commit them together
const workDir = await fileManager.getWorktree(repoPath, defaultBranch, npub, repoName); 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'); const { join } = await import('path');
// Write README.md // Write README.md
const readmePath = join(workDir, 'README.md'); const readmePath = join(workDir, 'README.md');
await writeFileFs(readmePath, readmeContent, 'utf-8'); await writeFileFs(readmePath, readmeContent, 'utf-8');
// Write announcement file // Save repo announcement event to nostr/repo-events.jsonl (only if not already present)
const announcementPath = join(workDir, VERIFICATION_FILE_PATH); const announcementSaved = await this.saveRepoEventToWorktree(workDir, announcementEvent, 'announcement', true);
await writeFileFs(announcementPath, announcementFileContent, 'utf-8');
// Save repo announcement event to nostr/repo-events.jsonl (standard file for easy analysis) // Stage files
await this.saveRepoEventToWorktree(workDir, announcementEvent, 'announcement');
// Stage both files
const workGit = simpleGit(workDir); 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 // Commit files together
const commitResult = await workGit.commit('Initial commit', ['README.md', VERIFICATION_FILE_PATH], { const commitResult = await workGit.commit('Initial commit', filesToAdd, {
'--author': `${authorName} <${authorEmail}>` '--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, npub: string,
repoName: string, repoName: string,
announcementEvent?: NostrEvent announcementEvent?: NostrEvent
): Promise<boolean> { ): Promise<{ success: boolean; needsAnnouncement?: boolean; announcement?: NostrEvent }> {
const repoPath = join(this.repoRoot, npub, `${repoName}.git`); 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)) { 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) { 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 // 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) + '...', pubkey: announcementEvent.pubkey.slice(0, 16) + '...',
level: userLevel?.level || 'none' level: userLevel?.level || 'none'
}, 'Skipping on-demand repo fetch: private repo requires owner with unlimited access'); }, 'Skipping on-demand repo fetch: private repo requires owner with unlimited access');
return false; return { success: false, needsAnnouncement: false };
} }
} else { } else {
logger.info({ 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) { if (remoteUrls.length === 0) {
logger.warn({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found for on-demand fetch'); 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'); 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'); 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 { try {
await this.createVerificationFile(repoPath, announcementEvent); await this.ensureAnnouncementInRepo(repoPath, announcementEvent);
} catch (verifyError) { } catch (verifyError) {
// Announcement file creation is optional - log but don't fail // 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'); logger.info({ npub, repoName }, 'Successfully fetched repository on-demand');
return true; return { success: true, announcement: announcementEvent };
} catch (error) { } catch (error) {
const sanitizedError = sanitizeError(error); const sanitizedError = sanitizeError(error);
logger.error({ logger.error({
@ -818,7 +830,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files
isPublic, isPublic,
remoteUrls remoteUrls
}, 'Failed to fetch repository on-demand'); }, '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 * Ensure announcement event is saved to nostr/repo-events.jsonl in the repository
* The announcement file contains the full signed announcement event JSON, proving ownership * 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 { try {
// Create a temporary working directory // Create a temporary working directory
const repoName = this.parseRepoPathForName(repoPath)?.repoName || 'temp'; 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(); const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir); await git.clone(repoPath, workDir);
// Extract announcement file content from client-signed event (kind 1642) // Check if announcement already exists in nostr/repo-events.jsonl
// The client creates and signs this event separately, with an 'e' tag pointing to the announcement const hasAnnouncement = await this.hasAnnouncementInRepo(workDir, event.id);
// The content is just the full announcement event JSON - simpler than a custom verification format
let announcementFileContent: string | null = null;
try { const filesToAdd: string[] = [];
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) { // Only save announcement if not already present
// Extract announcement file content from the client-signed event if (!hasAnnouncement) {
announcementFileContent = announcementFileEvents[0].content; const saved = await this.saveRepoEventToWorktree(workDir, event, 'announcement', false);
logger.info({ repoPath, announcementFileEventId: announcementFileEvents[0].id }, 'Using client-signed announcement file'); if (saved) {
filesToAdd.push('nostr/repo-events.jsonl');
logger.info({ repoPath, eventId: event.id }, 'Saved announcement to nostr/repo-events.jsonl');
} }
} catch (err) { } else {
logger.warn({ error: err, repoPath }, 'Failed to fetch announcement file event, generating server-side'); 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');
if (selfTransferEvent) {
await this.saveRepoEventToWorktree(workDir, selfTransferEvent, 'transfer');
} }
// If self-transfer event is provided, include it in the commit // Save transfer event if provided
const filesToAdd = [VERIFICATION_FILE_PATH];
if (selfTransferEvent) { if (selfTransferEvent) {
const selfTransferPath = join(workDir, '.nostr-ownership-transfer'); const saved = await this.saveRepoEventToWorktree(workDir, selfTransferEvent, 'transfer', false);
const isTemplate = !selfTransferEvent.sig || !selfTransferEvent.id; if (saved) {
if (!filesToAdd.includes('nostr/repo-events.jsonl')) {
const selfTransferContent = JSON.stringify({ filesToAdd.push('nostr/repo-events.jsonl');
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 // Only commit if we added files
const workGit: SimpleGit = simpleGit(workDir); if (filesToAdd.length > 0) {
await workGit.add(filesToAdd); const workGit: SimpleGit = simpleGit(workDir);
await workGit.add(filesToAdd);
// Use the event timestamp for commit date // Use the event timestamp for commit date
const commitDate = new Date(event.created_at * 1000).toISOString(); const commitDate = new Date(event.created_at * 1000).toISOString();
const commitMessage = selfTransferEvent const commitMessage = selfTransferEvent
? 'Add Nostr repository announcement and initial ownership proof' ? 'Add Nostr repository announcement and initial ownership proof'
: 'Add Nostr repository announcement'; : 'Add Nostr repository announcement';
// Note: Initial commits are unsigned. The repository owner can sign their own commits // 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. // when they make changes. The server should never sign commits on behalf of users.
await workGit.commit(commitMessage, filesToAdd, { await workGit.commit(commitMessage, filesToAdd, {
'--author': `Nostr <${event.pubkey}@nostr>`, '--author': `Nostr <${event.pubkey}@nostr>`,
'--date': commitDate '--date': commitDate
}); });
// Push back to bare repo // Push back to bare repo
await workGit.push(['origin', 'main']).catch(async () => { await workGit.push(['origin', 'main']).catch(async () => {
// If main branch doesn't exist, create it // If main branch doesn't exist, create it
await workGit.checkout(['-b', 'main']); await workGit.checkout(['-b', 'main']);
await workGit.push(['origin', 'main']); await workGit.push(['origin', 'main']);
}); });
}
// Clean up // Clean up
await rm(workDir, { recursive: true, force: true }); await rm(workDir, { recursive: true, force: true });
} catch (error) { } 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 // 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] }; 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 * 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 * This provides a standard location for all repo-related Nostr events for easy analysis
*/ */
private async saveRepoEventToWorktree( private async saveRepoEventToWorktree(
worktreePath: string, worktreePath: string,
event: NostrEvent, event: NostrEvent,
eventType: 'announcement' | 'transfer' eventType: 'announcement' | 'transfer',
): Promise<void> { skipIfExists: boolean = true
): Promise<boolean> {
try { 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 { mkdir, writeFile } = await import('fs/promises');
const { join } = await import('path');
// Create nostr directory in worktree // Create nostr directory in worktree
const nostrDir = join(worktreePath, 'nostr'); 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 event
}) + '\n'; }) + '\n';
await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' });
return true;
} catch (err) { } catch (err) {
logger.debug({ error: err, worktreePath, eventType }, 'Failed to save repo event to nostr/repo-events.jsonl'); 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 // 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 * 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)) { if (!this.repoExists(repoPath)) {
return false; 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 }); 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); await git.clone(repoPath, workDir);
const verificationPath = join(workDir, VERIFICATION_FILE_PATH); const hasAnnouncement = await this.hasAnnouncementInRepo(workDir, undefined);
const hasFile = existsSync(verificationPath);
// Clean up // Clean up
await rm(workDir, { recursive: true, force: true }); await rm(workDir, { recursive: true, force: true });
return hasFile; return hasAnnouncement;
} catch { } catch {
// If we can't check, assume it doesn't have one // If we can't check, assume it doesn't have one
return false; return false;

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

@ -32,16 +32,22 @@ export class ForkCountService {
try { try {
// Find all repo announcements that reference this repo as a fork // Find all repo announcements that reference this repo as a fork
const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${originalRepoName}`; 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], kinds: [KIND.REPO_ANNOUNCEMENT],
'#a': [repoTag], limit: 1000
limit: 1000 // Reasonable limit for fork count
} }
]); ]);
// Filter for actual forks (have 'a' tag matching the original repo) // Filter for actual forks (have 'fork' tag or legacy 'a' tag matching the original repo)
const forks = forkEvents.filter(event => { 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); const aTag = event.tags.find(t => t[0] === 'a' && t[1] === repoTag);
return aTag !== undefined; return aTag !== undefined;
}); });

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

@ -77,9 +77,15 @@ export const POST: RequestHandler = async (event) => {
const announcementEvent = events[0]; const announcementEvent = events[0];
// Attempt to clone the repository // 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( throw handleApiError(
new Error('Failed to clone repository from remote URLs'), new Error('Failed to clone repository from remote URLs'),
{ operation: 'cloneRepo', npub, repo }, { 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 }) => {
} }
// Build fork announcement tags // Build fork announcement tags
// Use standardized fork tag: ['fork', '30617:pubkey:d-tag']
const originalRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`;
const tags: string[][] = [ const tags: string[][] = [
['d', forkRepoName], ['d', forkRepoName],
['name', `${originalName} (fork)`], ['name', `${originalName} (fork)`],
['description', `Fork of ${originalName}${originalDescription ? `: ${originalDescription}` : ''}`], ['description', `Fork of ${originalName}${originalDescription ? `: ${originalDescription}` : ''}`],
['clone', ...forkCloneUrls], ['clone', ...forkCloneUrls],
['relays', ...DEFAULT_NOSTR_RELAYS], ['relays', ...DEFAULT_NOSTR_RELAYS],
['t', 'fork'], // Mark as fork ['fork', originalRepoTag], // Standardized fork tag format
['a', `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`], // Reference to original repo
['p', originalOwnerPubkey], // Original owner ['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...'); logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Provisioning fork repository...');
await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent, false); await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent, false);
// Save fork announcement to repo (offline papertrail) // Save fork announcement to repo (offline papertrail) in nostr/repo-events.jsonl
try { try {
const { generateVerificationFile, VERIFICATION_FILE_PATH } = await import('$lib/services/nostr/repo-verification.js');
const { fileManager } = await import('$lib/services/service-registry.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) // Save to repo if it exists locally (should exist after provisioning)
if (fileManager.repoExists(userNpub, forkRepoName)) { if (fileManager.repoExists(userNpub, forkRepoName)) {
await fileManager.writeFile( // Get worktree to save to repo-events.jsonl
userNpub, const defaultBranch = await fileManager.getDefaultBranch(userNpub, forkRepoName).catch(() => 'main');
forkRepoName, const repoPath = fileManager.getRepoPath(userNpub, forkRepoName);
VERIFICATION_FILE_PATH, const workDir = await fileManager.getWorktree(repoPath, defaultBranch, userNpub, forkRepoName);
announcementFileContent,
// 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)}...`, `Add fork repository announcement: ${signedForkAnnouncement.id.slice(0, 16)}...`,
'Nostr', ['nostr/repo-events.jsonl'],
`${userPubkeyHex}@nostr`, {
'main' '--author': `Nostr <${userPubkeyHex}@nostr>`
).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');
// Clean up worktree
await fileManager.removeWorktree(repoPath, workDir).catch(err => {
logger.debug({ error: err }, 'Failed to remove worktree after saving fork announcement');
}); });
} }
} catch (err) { } catch (err) {

130
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
);

41
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 // @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { fileManager } from '$lib/services/service-registry.js'; 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 { nostrClient } from '$lib/services/service-registry.js';
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
@ -72,13 +73,37 @@ export const GET: RequestHandler = createRepoGetHandler(
localOwner = await fileManager.getCurrentOwnerFromRepo(context.npub, context.repo); localOwner = await fileManager.getCurrentOwnerFromRepo(context.npub, context.repo);
if (localOwner) { if (localOwner) {
// Verify the announcement file matches the announcement event // Verify the announcement in nostr/repo-events.jsonl matches the announcement event
try { try {
const announcementFile = await fileManager.getFileContent(context.npub, context.repo, VERIFICATION_FILE_PATH, 'HEAD'); const repoEventsFile = await fileManager.getFileContent(context.npub, context.repo, 'nostr/repo-events.jsonl', 'HEAD');
const verification = verifyRepositoryOwnership(announcement, announcementFile.content); // Parse repo-events.jsonl and find the most recent announcement
localVerified = verification.valid; const lines = repoEventsFile.content.trim().split('\n').filter(Boolean);
if (!verification.valid) { let repoAnnouncement: NostrEvent | null = null;
localError = verification.error; 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) { } catch (err) {
localVerified = false; localVerified = false;
@ -86,7 +111,7 @@ export const GET: RequestHandler = createRepoGetHandler(
} }
} else { } else {
localVerified = false; localVerified = false;
localError = 'No announcement file found in repository'; localError = 'No announcement found in repository';
} }
} catch (err) { } catch (err) {
localVerified = false; localVerified = false;

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

@ -16,7 +16,7 @@
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { userStore } from '$lib/stores/user-store.js'; import { userStore } from '$lib/stores/user-store.js';
import { settingsStore } from '$lib/services/settings-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 type { NostrEvent } from '$lib/types/nostr.js';
import { hasUnlimitedAccess } from '$lib/utils/user-access.js'; import { hasUnlimitedAccess } from '$lib/utils/user-access.js';
import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js'; import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js';
@ -1777,9 +1777,9 @@
} }
} }
async function generateVerificationFileForRepo() { async function generateAnnouncementFileForRepo() {
if (!pageData.repoOwnerPubkey || !userPubkeyHex) { 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; return;
} }
@ -1801,11 +1801,12 @@
} }
const announcement = events[0] as NostrEvent; 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; showVerificationDialog = true;
} catch (err) { } catch (err) {
console.error('Failed to generate verification file:', err); console.error('Failed to generate announcement file:', err);
error = `Failed to generate verification file: ${err instanceof Error ? err.message : String(err)}`; error = `Failed to generate announcement file: ${err instanceof Error ? err.message : String(err)}`;
} }
} }
@ -1901,7 +1902,7 @@
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = VERIFICATION_FILE_PATH; a.download = 'announcement-event.json';
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
@ -3227,7 +3228,7 @@
{#if pageData.repoOwnerPubkey && userPubkeyHex === pageData.repoOwnerPubkey} {#if pageData.repoOwnerPubkey && userPubkeyHex === pageData.repoOwnerPubkey}
{#if verificationStatus?.verified !== true} {#if verificationStatus?.verified !== true}
<button <button
onclick={() => { generateVerificationFileForRepo(); showRepoMenu = false; }} onclick={() => { generateAnnouncementFileForRepo(); showRepoMenu = false; }}
class="repo-menu-item" class="repo-menu-item"
title="Generate verification file" title="Generate verification file"
> >
@ -4615,12 +4616,12 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="verification-instructions"> <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. The announcement event should be saved to <code>nostr/repo-events.jsonl</code> in your repository.
Then commit and push the file to your repository. You can download the announcement event JSON below for reference.
</p> </p>
<div class="verification-file-content"> <div class="verification-file-content">
<div class="file-header"> <div class="file-header">
<span class="filename">{VERIFICATION_FILE_PATH}</span> <span class="filename">announcement-event.json</span>
<div class="file-actions"> <div class="file-actions">
<button onclick={copyVerificationToClipboard} class="copy-button">Copy</button> <button onclick={copyVerificationToClipboard} class="copy-button">Copy</button>
<button onclick={downloadVerificationFile} class="download-button">Download</button> <button onclick={downloadVerificationFile} class="download-button">Download</button>

Loading…
Cancel
Save