From 4465a1074aab46a48bff008dea78548fa0d17137 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 28 Feb 2026 10:01:19 +0100 Subject: [PATCH] api refactor part 2 Nostr-Signature: ece894a60057bba46ebd4ac0dca2aca55ffce05e44671fe07b29516809fc86f6 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 176706a271659834e441ea5eab4bb1480667dad4468fe8315803284f4a183debf595523dd33d0d3cabe0c35013f4a72b9169b5f10afefaf8a82a721d8b0f3b08 --- docs/api-and-cli.md | 90 +- docs/editing-repos.md | 18 +- docs/repo-operations.md | 16 +- nostr/commit-signatures.jsonl | 1 + src/hooks.server.ts | 2 +- src/lib/components/PRDetail.svelte | 7 +- src/routes/api/openapi.json/openapi.json | 2276 ++++++++++++++--- .../api/repos/[npub]/[repo]/fork/+server.ts | 543 ---- .../repos/[npub]/[repo]/transfer/+server.ts | 181 -- .../api/repos/[npub]/[repo]/verify/+server.ts | 343 --- src/routes/repos/[npub]/[repo]/+page.svelte | 9 +- .../[npub]/[repo]/components/DocsTab.svelte | 4 +- .../[repo]/components/DocsViewer.svelte | 2 +- .../dialogs/CreateReleaseDialog.svelte | 2 +- .../repos/[npub]/[repo]/hooks/use-repo-api.ts | 6 +- .../[repo]/services/branch-operations.ts | 2 +- .../[repo]/services/code-search-operations.ts | 5 +- .../[repo]/services/commit-operations.ts | 4 +- .../[npub]/[repo]/services/file-operations.ts | 37 +- .../[npub]/[repo]/services/pr-operations.ts | 2 +- .../[npub]/[repo]/services/repo-operations.ts | 12 +- .../repos/[npub]/[repo]/utils/download.ts | 2 +- .../[npub]/[repo]/utils/file-processing.ts | 2 +- 23 files changed, 2047 insertions(+), 1519 deletions(-) delete mode 100644 src/routes/api/repos/[npub]/[repo]/fork/+server.ts delete mode 100644 src/routes/api/repos/[npub]/[repo]/transfer/+server.ts delete mode 100644 src/routes/api/repos/[npub]/[repo]/verify/+server.ts diff --git a/docs/api-and-cli.md b/docs/api-and-cli.md index 09c469b..01a6e45 100644 --- a/docs/api-and-cli.md +++ b/docs/api-and-cli.md @@ -34,66 +34,80 @@ View interactive documentation at `/api/openapi.json` or use any OpenAPI viewer. #### Repository Management -- `GET /api/repos/list` - List all repositories -- `GET /api/repos/local` - List local repositories +- `GET /api/repos/list?domain={domain}` - List all registered repositories (optionally filter by domain) +- `GET /api/repos/local` - List local repositories (cloned on this server) +- `GET /api/repos/{npub}/{repo}` - Get repository information (with optional `?include=settings,maintainers,access,verification`) +- `PUT /api/repos/{npub}/{repo}` - Replace repository (full update) +- `PATCH /api/repos/{npub}/{repo}` - Partial update repository +- `DELETE /api/repos/{npub}/{repo}` - Delete repository - `GET /api/repos/{npub}/{repo}/settings` - Get repository settings - `POST /api/repos/{npub}/{repo}/settings` - Update repository settings -- `GET /api/repos/{npub}/{repo}/maintainers` - Get maintainers -- `POST /api/repos/{npub}/{repo}/maintainers` - Add maintainer -- `DELETE /api/repos/{npub}/{repo}/maintainers` - Remove maintainer -- `POST /api/repos/{npub}/{repo}/fork` - Fork repository +- `GET /api/repos/{npub}/{repo}/maintainers` - List maintainers +- `POST /api/repos/{npub}/{repo}/maintainers` - Add maintainer (body: `{ maintainer: "npub..." }`) +- `DELETE /api/repos/{npub}/{repo}/maintainers/{npub}` - Remove maintainer +- `GET /api/repos/{npub}/{repo}/forks` - Get fork information +- `POST /api/repos/{npub}/{repo}/forks` - Fork repository - `DELETE /api/repos/{npub}/{repo}/delete` - Delete repository -- `POST /api/repos/{npub}/{repo}/transfer` - Transfer ownership -- `POST /api/repos/{npub}/{repo}/clone` - Clone to server +- `GET /api/repos/{npub}/{repo}/transfers` - Get ownership transfer history +- `POST /api/repos/{npub}/{repo}/transfers` - Transfer ownership +- `POST /api/repos/{npub}/{repo}/clone` - Clone repository to server +- `GET /api/repos/{npub}/{repo}/verification` - Verify repository ownership +- `POST /api/repos/{npub}/{repo}/verification` - Save announcement to repository for verification +- `GET /api/repos/{npub}/{repo}/validate` - Validate repository announcement +- `GET /api/repos/{npub}/{repo}/access` - Get repository access information +- `GET /api/repos/{npub}/{repo}/releases` - List releases +- `POST /api/repos/{npub}/{repo}/releases` - Create release #### File Operations -- `GET /api/repos/{npub}/{repo}/file` - Get file content -- `POST /api/repos/{npub}/{repo}/file` - Create/update/delete file -- `GET /api/repos/{npub}/{repo}/tree` - List files and directories -- `GET /api/repos/{npub}/{repo}/raw` - Get raw file content -- `GET /api/repos/{npub}/{repo}/readme` - Get README content +- `GET /api/repos/{npub}/{repo}/files?path={path}&ref={ref}` - Get file content (JSON format) +- `GET /api/repos/{npub}/{repo}/files?action=tree&path={path}&ref={ref}` - List files and directories +- `GET /api/repos/{npub}/{repo}/files?path={path}&format=raw&ref={ref}` - Get raw file content +- `POST /api/repos/{npub}/{repo}/files?path={path}` - Create file +- `PUT /api/repos/{npub}/{repo}/files?path={path}` - Update file (replace) +- `PATCH /api/repos/{npub}/{repo}/files?path={path}` - Partial update +- `DELETE /api/repos/{npub}/{repo}/files?path={path}` - Delete file +- `GET /api/repos/{npub}/{repo}/readme?ref={ref}` - Get README content #### Git Operations - `GET /api/repos/{npub}/{repo}/branches` - List branches -- `POST /api/repos/{npub}/{repo}/branches` - Create branch +- `POST /api/repos/{npub}/{repo}/branches` - Create branch (requires maintainer auth) +- `GET /api/repos/{npub}/{repo}/branches/default` - Get default branch - `GET /api/repos/{npub}/{repo}/tags` - List tags -- `POST /api/repos/{npub}/{repo}/tags` - Create tag +- `POST /api/repos/{npub}/{repo}/tags` - Create tag (requires maintainer auth) - `GET /api/repos/{npub}/{repo}/commits` - List commits -- `GET /api/repos/{npub}/{repo}/commits/{hash}/verify` - Verify commit signature -- `GET /api/repos/{npub}/{repo}/diff` - Get diff between commits -- `GET /api/repos/{npub}/{repo}/default-branch` - Get default branch -- `POST /api/repos/{npub}/{repo}/default-branch` - Set default branch +- `GET /api/repos/{npub}/{repo}/commits/{hash}/verification` - Verify commit signature +- `GET /api/repos/{npub}/{repo}/diffs?from={from}&to={to}&path={path}` - Get diff between commits +- `GET /api/repos/{npub}/{repo}/archive?format=zip|tar.gz&ref={ref}` - Download repository archive #### Collaboration -- `GET /api/repos/{npub}/{repo}/prs` - List pull requests -- `POST /api/repos/{npub}/{repo}/prs` - Create pull request -- `PATCH /api/repos/{npub}/{repo}/prs` - Update PR status -- `POST /api/repos/{npub}/{repo}/prs/{prId}/merge` - Merge PR +- `GET /api/repos/{npub}/{repo}/pull-requests` - List pull requests +- `POST /api/repos/{npub}/{repo}/pull-requests` - Create pull request +- `GET /api/repos/{npub}/{repo}/pull-requests/{id}` - Get pull request +- `PATCH /api/repos/{npub}/{repo}/pull-requests/{id}` - Update PR status +- `POST /api/repos/{npub}/{repo}/pull-requests/{id}/merge` - Merge PR - `GET /api/repos/{npub}/{repo}/issues` - List issues - `POST /api/repos/{npub}/{repo}/issues` - Create issue -- `PATCH /api/repos/{npub}/{repo}/issues` - Update issue status - `GET /api/repos/{npub}/{repo}/patches` - List patches - `POST /api/repos/{npub}/{repo}/patches` - Create patch -- `PATCH /api/repos/{npub}/{repo}/patches` - Update patch status -- `POST /api/repos/{npub}/{repo}/patches/{patchId}/apply` - Apply patch +- `POST /api/repos/{npub}/{repo}/patches/{id}/application` - Apply patch - `GET /api/repos/{npub}/{repo}/highlights` - List highlights/comments - `POST /api/repos/{npub}/{repo}/highlights` - Create highlight/comment #### Search and Discovery -- `GET /api/search` - Search repositories -- `GET /api/repos/{npub}/{repo}/code-search` - Search code in repository -- `GET /api/code-search` - Global code search -- `GET /api/repos/{npub}/{repo}/clone-urls/reachability` - Check clone URL reachability +- `GET /api/search?type=repos&q={query}` - Search repositories (default) +- `GET /api/search?type=code&q={query}&repo={npub}/{repo}` - Search code (optionally filter by repository) +- `GET /api/repos/{npub}/{repo}/clone-urls` - List clone URLs +- `POST /api/repos/{npub}/{repo}/clone-urls/reachability` - Check clone URL reachability #### User Operations - `GET /api/users/{npub}/profile` - Get user profile - `GET /api/users/{npub}/repos` - Get user's repositories -- `GET /api/user/level` - Get user access level +- `POST /api/user/level` - Verify user access level (relay write access) - `GET /api/user/git-dashboard` - Get git dashboard - `GET /api/user/messaging-preferences` - Get messaging preferences - `POST /api/user/messaging-preferences` - Update messaging preferences @@ -102,6 +116,7 @@ View interactive documentation at `/api/openapi.json` or use any OpenAPI viewer. - `GET /api/config` - Get server configuration - `GET /api/tor/onion` - Get Tor .onion address +- `POST /api/repos/poll` - Trigger repository polling (provisions new repos from Nostr) - `GET /api/transfers/pending` - Get pending ownership transfers #### Git HTTP Backend @@ -120,10 +135,19 @@ curl https://your-domain.com/api/repos/list curl https://your-domain.com/api/repos/{npub}/{repo}/settings # Create file (requires NIP-98 auth) -curl -X POST https://your-domain.com/api/repos/{npub}/{repo}/file \ +curl -X POST "https://your-domain.com/api/repos/{npub}/{repo}/files?path=test.txt" \ -H "Authorization: Nostr " \ -H "Content-Type: application/json" \ - -d '{"path": "test.txt", "content": "Hello", "commitMessage": "Add file", "branch": "main", "action": "write"}' + -d '{"content": "Hello", "commitMessage": "Add file", "branch": "main"}' + +# Get file content +curl "https://your-domain.com/api/repos/{npub}/{repo}/files?path=test.txt&ref=main" + +# List files (tree view) +curl "https://your-domain.com/api/repos/{npub}/{repo}/files?action=tree&ref=main" + +# Get raw file content +curl "https://your-domain.com/api/repos/{npub}/{repo}/files?path=test.txt&format=raw&ref=main" ``` ## Command Line Interface (CLI) diff --git a/docs/editing-repos.md b/docs/editing-repos.md index 7cc106f..7597310 100644 --- a/docs/editing-repos.md +++ b/docs/editing-repos.md @@ -42,9 +42,9 @@ git push origin feature/new-feature ### Default Branch -The default branch (usually `main`) can be changed via: +The default branch (usually `main`) can be viewed via: - **Web Interface**: Repository settings -- **API**: `POST /api/repos/{npub}/{repo}/default-branch` +- **API**: `GET /api/repos/{npub}/{repo}/branches/default` ## File Management @@ -60,7 +60,7 @@ The default branch (usually `main`) can be changed via: #### Via API ```bash -GET /api/repos/{npub}/{repo}/file?path={file-path}&branch={branch} +GET /api/repos/{npub}/{repo}/files?path={file-path}&ref={branch} ``` #### Via CLI @@ -84,13 +84,11 @@ gitrep file get [branch] #### Via API ```bash -POST /api/repos/{npub}/{repo}/file +POST /api/repos/{npub}/{repo}/files?path=file.txt { - "path": "file.txt", "content": "File content", "commitMessage": "Add file", - "branch": "main", - "action": "write" + "branch": "main" } ``` @@ -112,12 +110,10 @@ gitrep file put [file] [message] [branch] #### Via API ```bash -POST /api/repos/{npub}/{repo}/file +DELETE /api/repos/{npub}/{repo}/files?path=file.txt { - "path": "file.txt", "commitMessage": "Remove file", - "branch": "main", - "action": "delete" + "branch": "main" } ``` diff --git a/docs/repo-operations.md b/docs/repo-operations.md index dcf12a4..7a8955f 100644 --- a/docs/repo-operations.md +++ b/docs/repo-operations.md @@ -92,8 +92,22 @@ The transfer process: ### Via CLI +Ownership transfers are done via the API. Use the publish command to create an ownership transfer event: + +```bash +gitrep publish ownership-transfer [--self-transfer] +``` + +Or use the API directly: ```bash -gitrep repos transfer +# Get transfer history +curl https://{domain}/api/repos/{npub}/{repo}/transfers + +# Initiate transfer (requires NIP-98 auth) +curl -X POST https://{domain}/api/repos/{npub}/{repo}/transfers \ + -H "Authorization: Nostr " \ + -H "Content-Type: application/json" \ + -d '{"transferEvent": {...}}' ``` **Important**: Ownership transfers are permanent and create a chain of ownership events. The new owner will have full control. diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 8513375..a165812 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -116,3 +116,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772227102,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fix"]],"content":"Signed commit: bug-fix","id":"0f366a0cc7c003f74e375f40e7c322781746d12829943df1287bf67f36e1330a","sig":"167177ccfeb053cd645e50e7d00450b847ecd65c305165777bcfbe39fd3f48ccc86b57fdd183d2a4b138d94d27d11e4f1c121d702b295d94b9aee0a8dc81a744"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772261455,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix zombie spawning on polling\nmake announcement commits non-blocking on repo provision"]],"content":"Signed commit: fix zombie spawning on polling\nmake announcement commits non-blocking on repo provision","id":"b0da119e7477b46f5d82be831693a92e117f25379476488f19351e2bac8f88b8","sig":"b8ca18e8215a9f5b3fc877ce113936c582353d44f8d03cdccd9f9ee70fb3e6fdd64db7cc6a3ca15339fb21b9ca87ea8471a38b587721a594a189d97cc2964ad9"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772264490,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","polling update"]],"content":"Signed commit: polling update","id":"42c1a2a63a4568c65d82d78701451b3b4363bdf9c8c57e804535b5f3f0d7b6fc","sig":"8e5f32ecb79da876ac41eba04c3b1541b21d039ae50d1b9fefa630d35f31c97dd29af64e4b695742fa7d4eaec17db8f4a066b4db99ce628aed596971975d4a87"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772267611,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor API"]],"content":"Signed commit: refactor API","id":"934f8809638cea0bc7b8158fca959bc60880e0cae9ab8ff653687313adcd2f57","sig":"c9d8e5b821ae8182f8d39599c50fd0a4db6040ead1d8d83730a608a1d94d5078770a6ccbfc525a98691e98fabd9f9d24f0298680fb564c6b76c2f34bed9889b5"} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 7c7383f..deffc5e 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -114,7 +114,7 @@ export const handle: Handle = async ({ event, resolve }) => { let rateLimitType = 'api'; if (url.pathname.startsWith('/api/git/')) { rateLimitType = 'git'; - } else if (url.pathname.startsWith('/api/repos/') && url.pathname.includes('/file')) { + } else if (url.pathname.startsWith('/api/repos/') && url.pathname.includes('/files')) { rateLimitType = 'file'; } else if (url.pathname.startsWith('/api/search')) { rateLimitType = 'search'; diff --git a/src/lib/components/PRDetail.svelte b/src/lib/components/PRDetail.svelte index dfc15c8..572362c 100644 --- a/src/lib/components/PRDetail.svelte +++ b/src/lib/components/PRDetail.svelte @@ -175,7 +175,7 @@ try { // Load diff for the commit const response = await fetch( - `/api/repos/${npub}/${repo}/diff?from=${pr.commitId}^&to=${pr.commitId}` + `/api/repos/${npub}/${repo}/diffs?from=${pr.commitId}^&to=${pr.commitId}` ); if (response.ok) { const data = await response.json(); @@ -356,11 +356,10 @@ error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/prs`, { + const response = await fetch(`/api/repos/${npub}/${repo}/pull-requests/${pr.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - prId: pr.id, prAuthor: pr.author, status }) @@ -397,7 +396,7 @@ error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/prs/${pr.id}/merge`, { + const response = await fetch(`/api/repos/${npub}/${repo}/pull-requests/${pr.id}/merge`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/src/routes/api/openapi.json/openapi.json b/src/routes/api/openapi.json/openapi.json index 5133416..caf5ca5 100644 --- a/src/routes/api/openapi.json/openapi.json +++ b/src/routes/api/openapi.json/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "GitRepublic API", - "version": "1.0.0", + "version": "2.0.0", "description": "Nostr-based git server API with NIP-34 repository announcements. All endpoints use NIP-98 HTTP authentication.", "contact": { "name": "GitCitadel LLC", @@ -329,10 +329,10 @@ } } }, - "/api/repos/{npub}/{repo}/file": { + "/api/repos/{npub}/{repo}/files": { "get": { - "summary": "Get file content", - "description": "Read a file from a repository", + "summary": "Get file content, list files, or get raw file", + "description": "Get file content (JSON), list directory tree, or get raw file content. Use query parameters: 'action=tree' for directory listing, 'format=raw' for raw content, or omit both for JSON file content.", "tags": ["Files"], "security": [{"NIP98": []}], "parameters": [ @@ -340,55 +340,67 @@ "name": "npub", "in": "path", "required": true, - "schema": { - "type": "string" - } + "schema": {"type": "string"} }, { "name": "repo", "in": "path", "required": true, - "schema": { - "type": "string" - } + "schema": {"type": "string"} }, { "name": "path", "in": "query", - "required": true, - "schema": { - "type": "string" - }, - "description": "File path relative to repository root" + "schema": {"type": "string"}, + "description": "File path relative to repository root (required for file content, optional for tree)" + }, + { + "name": "action", + "in": "query", + "schema": {"type": "string", "enum": ["tree"]}, + "description": "Set to 'tree' to list files and directories" + }, + { + "name": "format", + "in": "query", + "schema": {"type": "string", "enum": ["raw", "json"], "default": "json"}, + "description": "Response format: 'raw' for raw file content, 'json' for JSON with metadata" }, { "name": "ref", "in": "query", - "schema": { - "type": "string", - "default": "HEAD" - }, + "schema": {"type": "string", "default": "HEAD"}, "description": "Git reference (branch, tag, or commit)" + }, + { + "name": "recursive", + "in": "query", + "schema": {"type": "boolean", "default": false}, + "description": "For tree action: list files recursively" } ], "responses": { "200": { - "description": "File content", + "description": "File content, directory listing, or raw file", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileContent" + "oneOf": [ + {"$ref": "#/components/schemas/FileContent"}, + {"type": "array", "items": {"type": "object", "properties": {"name": {"type": "string"}, "path": {"type": "string"}, "type": {"type": "string", "enum": ["file", "directory"]}}}} + ] } - } + }, + "text/plain": {"schema": {"type": "string"}}, + "application/javascript": {"schema": {"type": "string"}}, + "text/html": {"schema": {"type": "string"}} } }, "403": { "description": "Repository is private", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } + "schema": {"$ref": "#/components/schemas/Error"} } } }, @@ -396,17 +408,15 @@ "description": "File or repository not found", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } + "schema": {"$ref": "#/components/schemas/Error"} } } } } }, "post": { - "summary": "Create or update file", - "description": "Write a file to a repository. Requires maintainer authentication.", + "summary": "Create file", + "description": "Create a new file in a repository. Requires maintainer authentication.", "tags": ["Files"], "security": [{"NIP98": []}], "parameters": [ @@ -414,17 +424,20 @@ "name": "npub", "in": "path", "required": true, - "schema": { - "type": "string" - } + "schema": {"type": "string"} }, { "name": "repo", "in": "path", "required": true, - "schema": { - "type": "string" - } + "schema": {"type": "string"} + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": {"type": "string"}, + "description": "File path relative to repository root" } ], "requestBody": { @@ -433,39 +446,15 @@ "application/json": { "schema": { "type": "object", - "required": ["path", "content", "commitMessage", "authorName", "authorEmail", "userPubkey"], + "required": ["content", "commitMessage", "authorName", "authorEmail", "userPubkey"], "properties": { - "path": { - "type": "string" - }, - "content": { - "type": "string" - }, - "commitMessage": { - "type": "string" - }, - "authorName": { - "type": "string" - }, - "authorEmail": { - "type": "string" - }, - "branch": { - "type": "string", - "default": "main" - }, - "action": { - "type": "string", - "enum": ["create", "write", "delete"] - }, - "userPubkey": { - "type": "string", - "description": "User's npub or hex pubkey" - }, - "commitSignatureEvent": { - "$ref": "#/components/schemas/NostrEvent", - "description": "Optional pre-signed commit signature event (kind 1640)" - } + "content": {"type": "string"}, + "commitMessage": {"type": "string"}, + "authorName": {"type": "string"}, + "authorEmail": {"type": "string"}, + "branch": {"type": "string", "default": "main"}, + "userPubkey": {"type": "string", "description": "User's npub or hex pubkey"}, + "commitSignatureEvent": {"$ref": "#/components/schemas/NostrEvent", "description": "Optional pre-signed commit signature event (kind 1640)"} } } } @@ -473,33 +462,171 @@ }, "responses": { "200": { - "description": "File saved", + "description": "File created", "content": { "application/json": { "schema": { "type": "object", "properties": { - "success": { - "type": "boolean" - }, - "message": { - "type": "string" - } + "success": {"type": "boolean"}, + "message": {"type": "string"} } } } } }, - "403": { - "description": "Not authorized", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "403": {"description": "Not authorized", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}} + } + }, + "put": { + "summary": "Update file (replace)", + "description": "Replace a file in a repository. Requires maintainer authentication.", + "tags": ["Files"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": {"type": "string"}, + "description": "File path relative to repository root" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["content", "commitMessage", "authorName", "authorEmail", "userPubkey"], + "properties": { + "content": {"type": "string"}, + "commitMessage": {"type": "string"}, + "authorName": {"type": "string"}, + "authorEmail": {"type": "string"}, + "branch": {"type": "string", "default": "main"}, + "userPubkey": {"type": "string"}, + "commitSignatureEvent": {"$ref": "#/components/schemas/NostrEvent"} + } + } + } + } + }, + "responses": { + "200": {"description": "File updated"}, + "403": {"description": "Not authorized", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}} + } + }, + "patch": { + "summary": "Partial update file", + "description": "Partially update a file in a repository. Requires maintainer authentication.", + "tags": ["Files"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": {"type": "string"} + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["content", "commitMessage", "authorName", "authorEmail", "userPubkey"], + "properties": { + "content": {"type": "string"}, + "commitMessage": {"type": "string"}, + "authorName": {"type": "string"}, + "authorEmail": {"type": "string"}, + "branch": {"type": "string", "default": "main"}, + "userPubkey": {"type": "string"}, + "commitSignatureEvent": {"$ref": "#/components/schemas/NostrEvent"} + } + } + } + } + }, + "responses": { + "200": {"description": "File updated"}, + "403": {"description": "Not authorized", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}} + } + }, + "delete": { + "summary": "Delete file", + "description": "Delete a file from a repository. Requires maintainer authentication.", + "tags": ["Files"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": {"type": "string"}, + "description": "File path relative to repository root" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["commitMessage", "authorName", "authorEmail", "userPubkey"], + "properties": { + "commitMessage": {"type": "string"}, + "authorName": {"type": "string"}, + "authorEmail": {"type": "string"}, + "branch": {"type": "string", "default": "main"}, + "userPubkey": {"type": "string"}, + "commitSignatureEvent": {"$ref": "#/components/schemas/NostrEvent"} } } } } + }, + "responses": { + "200": {"description": "File deleted"}, + "403": {"description": "Not authorized", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}} } } }, @@ -733,7 +860,7 @@ } } }, - "/api/repos/{npub}/{repo}/prs": { + "/api/repos/{npub}/{repo}/pull-requests": { "get": { "summary": "List pull requests", "description": "Get all pull requests for a repository (NIP-34 kind 1618)", @@ -837,66 +964,74 @@ } } }, - "/api/repos/{npub}/{repo}/issues": { + "/api/repos/{npub}/{repo}/pull-requests/{id}": { "get": { - "summary": "List issues", - "description": "Get all issues for a repository (NIP-34 kind 1621)", - "tags": ["Issues"], + "summary": "Get pull request", + "description": "Get a specific pull request by ID", + "tags": ["Pull Requests"], "parameters": [ { "name": "npub", "in": "path", "required": true, - "schema": { - "type": "string" - } + "schema": {"type": "string"} }, { "name": "repo", "in": "path", "required": true, - "schema": { - "type": "string" - } + "schema": {"type": "string"} + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "Pull request ID (event ID)" } ], "responses": { "200": { - "description": "List of issues", + "description": "Pull request", "content": { "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Issue" - } - } + "schema": {"$ref": "#/components/schemas/PullRequest"} + } + } + }, + "404": { + "description": "Pull request not found", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} } } } } }, - "post": { - "summary": "Create issue", - "description": "Create a new issue (NIP-34 kind 1621)", - "tags": ["Issues"], + "patch": { + "summary": "Update pull request", + "description": "Update pull request status or commit", + "tags": ["Pull Requests"], "security": [{"NIP98": []}], "parameters": [ { "name": "npub", "in": "path", "required": true, - "schema": { - "type": "string" - } + "schema": {"type": "string"} }, { "name": "repo", "in": "path", "required": true, - "schema": { - "type": "string" - } + "schema": {"type": "string"} + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": {"type": "string"} } ], "requestBody": { @@ -905,24 +1040,156 @@ "application/json": { "schema": { "type": "object", - "required": ["event"], "properties": { - "event": { - "$ref": "#/components/schemas/NostrEvent", - "description": "Signed Nostr event (kind 1621)" - } + "prAuthor": {"type": "string"}, + "status": {"type": "string", "enum": ["open", "closed", "merged"]}, + "mergeCommitId": {"type": "string"}, + "newCommitId": {"type": "string"}, + "mergeBase": {"type": "string"} } } } } }, "responses": { - "200": { - "description": "Issue created", - "content": { - "application/json": { - "schema": { - "type": "object", + "200": {"description": "Pull request updated"}, + "403": {"description": "Not authorized", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}} + } + } + }, + "/api/repos/{npub}/{repo}/pull-requests/{id}/merge": { + "post": { + "summary": "Merge pull request", + "description": "Merge a pull request into the target branch", + "tags": ["Pull Requests"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "targetBranch": {"type": "string", "default": "main"}, + "mergeCommitMessage": {"type": "string"}, + "mergeStrategy": {"type": "string", "enum": ["merge", "squash", "rebase"], "default": "merge"} + } + } + } + } + }, + "responses": { + "200": {"description": "Pull request merged"}, + "403": {"description": "Not authorized", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}} + } + } + }, + "/api/repos/{npub}/{repo}/issues": { + "get": { + "summary": "List issues", + "description": "Get all issues for a repository (NIP-34 kind 1621)", + "tags": ["Issues"], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of issues", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Issue" + } + } + } + } + } + } + }, + "post": { + "summary": "Create issue", + "description": "Create a new issue (NIP-34 kind 1621)", + "tags": ["Issues"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["event"], + "properties": { + "event": { + "$ref": "#/components/schemas/NostrEvent", + "description": "Signed Nostr event (kind 1621)" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Issue created", + "content": { + "application/json": { + "schema": { + "type": "object", "properties": { "success": { "type": "boolean" @@ -944,7 +1211,7 @@ "/api/search": { "get": { "summary": "Search repositories and code", - "description": "Search for repositories or code content", + "description": "Search for repositories (type=repos) or code content (type=code). For code search, optionally filter by repository using the 'repo' parameter in format 'npub/repo'.", "tags": ["Search"], "parameters": [ { @@ -961,10 +1228,18 @@ "in": "query", "schema": { "type": "string", - "enum": ["repos", "code", "all"], + "enum": ["repos", "code"], "default": "repos" }, - "description": "Search type" + "description": "Search type: 'repos' for repository search, 'code' for code search" + }, + { + "name": "repo", + "in": "query", + "schema": { + "type": "string" + }, + "description": "For code search: filter by repository in format 'npub/repo' (optional)" }, { "name": "limit", @@ -973,7 +1248,7 @@ "type": "integer", "default": 20 }, - "description": "Maximum number of results" + "description": "Maximum number of results (default: 20 for repos, 100 for code)" } ], "responses": { @@ -993,61 +1268,6 @@ } } }, - "/api/repos/{npub}/{repo}/tree": { - "get": { - "summary": "List files and directories", - "description": "Get directory listing for a repository", - "tags": ["Files"], - "security": [{"NIP98": []}], - "parameters": [ - { - "name": "npub", - "in": "path", - "required": true, - "schema": {"type": "string"} - }, - { - "name": "repo", - "in": "path", - "required": true, - "schema": {"type": "string"} - }, - { - "name": "path", - "in": "query", - "schema": {"type": "string"}, - "description": "Directory path (empty for root)" - }, - { - "name": "ref", - "in": "query", - "schema": {"type": "string", "default": "HEAD"}, - "description": "Git reference" - } - ], - "responses": { - "200": { - "description": "Directory listing", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "path": {"type": "string"}, - "type": {"type": "string", "enum": ["file", "directory"]}, - "size": {"type": "integer"} - } - } - } - } - } - } - } - } - }, "/api/repos/{npub}/{repo}/commits": { "get": { "summary": "Get commit history", @@ -1109,48 +1329,6 @@ } } }, - "/api/repos/{npub}/{repo}/raw": { - "get": { - "summary": "Get raw file content", - "description": "Get raw file content (no JSON wrapper)", - "tags": ["Files"], - "parameters": [ - { - "name": "npub", - "in": "path", - "required": true, - "schema": {"type": "string"} - }, - { - "name": "repo", - "in": "path", - "required": true, - "schema": {"type": "string"} - }, - { - "name": "path", - "in": "query", - "required": true, - "schema": {"type": "string"} - }, - { - "name": "ref", - "in": "query", - "schema": {"type": "string", "default": "HEAD"} - } - ], - "responses": { - "200": { - "description": "Raw file content", - "content": { - "text/plain": {"schema": {"type": "string"}}, - "application/javascript": {"schema": {"type": "string"}}, - "text/html": {"schema": {"type": "string"}} - } - } - } - } - }, "/api/repos/{npub}/{repo}/readme": { "get": { "summary": "Get README file", @@ -1196,9 +1374,9 @@ } } }, - "/api/repos/{npub}/{repo}/download": { + "/api/repos/{npub}/{repo}/archive": { "get": { - "summary": "Download repository as archive", + "summary": "Download repository archive", "description": "Download repository as ZIP or tar.gz archive", "tags": ["Repositories"], "security": [{"NIP98": []}], @@ -1238,7 +1416,7 @@ } } }, - "/api/repos/{npub}/{repo}/fork": { + "/api/repos/{npub}/{repo}/forks": { "get": { "summary": "Get fork information", "description": "Check if repository is a fork and get original repo info", @@ -1309,7 +1487,8 @@ "required": ["userPubkey"], "properties": { "userPubkey": {"type": "string"}, - "forkName": {"type": "string"} + "forkName": {"type": "string"}, + "localOnly": {"type": "boolean", "description": "Create a local-only fork (not published to Nostr)"} } } } @@ -1329,13 +1508,40 @@ "properties": { "npub": {"type": "string"}, "repo": {"type": "string"}, - "url": {"type": "string"} + "url": {"type": "string"}, + "localOnly": {"type": "boolean"}, + "announcementId": {"type": "string"}, + "ownershipTransferId": {"type": "string"}, + "publishedTo": { + "type": "object", + "properties": { + "announcement": {"type": "integer"}, + "ownershipTransfer": {"type": "integer"} + } + } } - } + }, + "message": {"type": "string"} } } } } + }, + "403": { + "description": "Not authorized or resource limit exceeded", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + }, + "409": { + "description": "Fork already exists", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } } } } @@ -1416,10 +1622,10 @@ } } }, - "/api/repos/{npub}/{repo}/transfer": { + "/api/repos/{npub}/{repo}": { "get": { - "summary": "Get ownership transfer history", - "description": "Get ownership transfer history for a repository", + "summary": "Get repository information", + "description": "Get repository info including settings, maintainers, access, and verification. Use query parameter 'include' to specify what to include (settings, maintainers, access, verification, or 'all').", "tags": ["Repositories"], "parameters": [ { @@ -1433,20 +1639,48 @@ "in": "path", "required": true, "schema": {"type": "string"} + }, + { + "name": "include", + "in": "query", + "schema": {"type": "string"}, + "description": "Comma-separated list of sections to include: settings, maintainers, access, verification, or 'all'" } ], "responses": { "200": { - "description": "Ownership information", + "description": "Repository information", "content": { "application/json": { "schema": { "type": "object", "properties": { - "originalOwner": {"type": "string"}, - "currentOwner": {"type": "string"}, - "transferred": {"type": "boolean"}, - "transfers": {"type": "array"} + "npub": {"type": "string"}, + "repo": {"type": "string"}, + "owner": {"type": "string"}, + "description": {"type": "string"}, + "visibility": {"type": "string", "enum": ["public", "unlisted", "restricted", "private"]}, + "projectRelays": {"type": "array", "items": {"type": "string"}}, + "private": {"type": "boolean"}, + "maintainers": {"type": "array", "items": {"type": "string"}}, + "isMaintainer": {"type": "boolean"}, + "isOwner": {"type": "boolean"}, + "access": { + "type": "object", + "properties": { + "canView": {"type": "boolean"}, + "isPrivate": {"type": "boolean"}, + "isMaintainer": {"type": "boolean"}, + "isOwner": {"type": "boolean"} + } + }, + "verification": { + "type": "object", + "properties": { + "exists": {"type": "boolean"}, + "announcementFound": {"type": "boolean"} + } + } } } } @@ -1454,20 +1688,855 @@ } } }, - "post": { - "summary": "Transfer repository ownership", - "description": "Transfer repository ownership to another user. Requires current owner authentication.", + "put": { + "summary": "Replace repository (full update)", + "description": "Replace repository settings with a full update. Requires maintainer authentication.", "tags": ["Repositories"], "security": [{"NIP98": []}], - "requestBody": { + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": {"type": "string"}, + "visibility": {"type": "string", "enum": ["public", "unlisted", "restricted", "private"]}, + "projectRelays": {"type": "array", "items": {"type": "string"}}, + "private": {"type": "boolean"}, + "branchProtection": {"type": "object"} + } + } + } + } + }, + "responses": { + "200": { + "description": "Repository updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "npub": {"type": "string"}, + "repo": {"type": "string"}, + "owner": {"type": "string"}, + "description": {"type": "string"}, + "visibility": {"type": "string"}, + "projectRelays": {"type": "array", "items": {"type": "string"}}, + "private": {"type": "boolean"} + } + } + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + } + } + }, + "patch": { + "summary": "Partial update repository", + "description": "Partially update repository settings. Requires maintainer authentication.", + "tags": ["Repositories"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": {"type": "string"}, + "visibility": {"type": "string", "enum": ["public", "unlisted", "restricted", "private"]}, + "projectRelays": {"type": "array", "items": {"type": "string"}}, + "private": {"type": "boolean"}, + "branchProtection": {"type": "object"} + } + } + } + } + }, + "responses": { + "200": { + "description": "Repository updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "npub": {"type": "string"}, + "repo": {"type": "string"}, + "owner": {"type": "string"}, + "description": {"type": "string"}, + "visibility": {"type": "string"}, + "projectRelays": {"type": "array", "items": {"type": "string"}}, + "private": {"type": "boolean"} + } + } + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + } + } + }, + "delete": { + "summary": "Delete repository", + "description": "Delete a local repository clone. Requires owner or admin authentication.", + "tags": ["Repositories"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Repository deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "message": {"type": "string"} + } + } + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + }, + "404": { + "description": "Repository not found", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + } + } + } + }, + "/api/repos/{npub}/{repo}/transfers": { + "get": { + "summary": "Get ownership transfer history", + "description": "Get ownership transfer history for a repository", + "tags": ["Repositories"], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Ownership information", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "originalOwner": {"type": "string"}, + "currentOwner": {"type": "string"}, + "transferred": {"type": "boolean"}, + "transfers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "eventId": {"type": "string"}, + "from": {"type": "string"}, + "to": {"type": "string"}, + "timestamp": {"type": "integer"}, + "createdAt": {"type": "string"} + } + } + } + } + } + } + } + } + } + }, + "post": { + "summary": "Transfer repository ownership", + "description": "Transfer repository ownership to another user. Requires current owner authentication.", + "tags": ["Repositories"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["transferEvent"], + "properties": { + "transferEvent": {"$ref": "#/components/schemas/NostrEvent"} + } + } + } + } + }, + "responses": { + "200": { + "description": "Ownership transferred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "event": {"$ref": "#/components/schemas/NostrEvent"}, + "published": {"type": "object"}, + "message": {"type": "string"}, + "transferEvent": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "from": {"type": "string"}, + "to": {"type": "string"} + } + } + } + } + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + } + } + } + }, + "/api/repos/{npub}/{repo}/verification": { + "get": { + "summary": "Verify repository ownership", + "description": "Verify repository ownership by checking announcement file", + "tags": ["Repositories"], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Verification result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "verified": {"type": "boolean"}, + "announcementId": {"type": "string"}, + "ownerPubkey": {"type": "string"}, + "verificationMethod": {"type": "string"}, + "cloneVerifications": { + "type": "array", + "items": { + "type": "object", + "properties": { + "url": {"type": "string"}, + "verified": {"type": "boolean"}, + "ownerPubkey": {"type": "string"}, + "error": {"type": "string"} + } + } + }, + "message": {"type": "string"}, + "error": {"type": "string"} + } + } + } + } + } + } + }, + "post": { + "summary": "Save announcement to repository", + "description": "Save repository announcement to the repository for verification. Requires maintainer authentication.", + "tags": ["Repositories"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Announcement saved", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "message": {"type": "string"}, + "announcementId": {"type": "string"} + } + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + }, + "404": { + "description": "Repository not found", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + } + } + } + }, + "/api/repos/{npub}/{repo}/validate": { + "get": { + "summary": "Validate repository announcement", + "description": "Validate repository announcement by checking if it exists in repo and on relays, and if signatures are valid", + "tags": ["Repositories"], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Validation result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "valid": {"type": "boolean"}, + "error": {"type": "string"}, + "inRepo": {"type": "boolean"}, + "onRelays": {"type": "boolean"}, + "announcementsMatch": {"type": "boolean"}, + "announcementId": {"type": "string"}, + "announcementPubkey": {"type": "string"}, + "announcementCreatedAt": {"type": "integer"}, + "repoAnnouncementId": {"type": "string"}, + "relayAnnouncementId": {"type": "string"} + } + } + } + } + } + } + } + }, + "/api/repos/{npub}/{repo}/clone": { + "post": { + "summary": "Clone repository to server", + "description": "Clone a repository to the server. Requires unlimited access.", + "tags": ["Repositories"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Repository cloned", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "message": {"type": "string"}, + "alreadyExists": {"type": "boolean"} + } + } + } + } + } + } + } + }, + "/api/repos/{npub}/{repo}/delete": { + "delete": { + "summary": "Delete local repository clone", + "description": "Delete a local repository clone. Requires owner or admin authentication.", + "tags": ["Repositories"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Repository deleted" + }, + "403": { + "description": "Not authorized" + } + } + } + }, + "/api/repos/{npub}/{repo}/settings": { + "get": { + "summary": "Get repository settings", + "description": "Get repository settings including description, visibility, and project relays", + "tags": ["Repositories"], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Repository settings", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "owner": {"type": "string"}, + "description": {"type": "string"}, + "visibility": { + "type": "string", + "enum": ["public", "unlisted", "restricted", "private"] + }, + "projectRelays": { + "type": "array", + "items": {"type": "string"} + }, + "private": { + "type": "boolean", + "description": "Backward compatibility field (maps to visibility)" + } + } + } + } + } + } + } + }, + "post": { + "summary": "Update repository settings", + "description": "Update repository settings. Requires maintainer access. Supports visibility levels: public, unlisted, restricted, private. Unlisted and restricted require project-relay.", + "tags": ["Repositories"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": {"type": "string"}, + "visibility": { + "type": "string", + "enum": ["public", "unlisted", "restricted", "private"] + }, + "projectRelays": { + "type": "array", + "items": {"type": "string"} + }, + "private": { + "type": "boolean", + "description": "Deprecated: Use visibility instead" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Settings updated successfully" + }, + "403": { + "description": "Not authorized (requires maintainer access)" + } + } + } + }, + "/api/repos/{npub}/{repo}/access": { + "get": { + "summary": "Check repository access", + "description": "Check if current user can view the repository", + "tags": ["Repositories"], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Access information", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "canView": {"type": "boolean"}, + "isPrivate": {"type": "boolean"}, + "isMaintainer": {"type": "boolean"}, + "isOwner": {"type": "boolean"} + } + } + } + } + } + } + } + }, + "/api/repos/{npub}/{repo}/branches/default": { + "get": { + "summary": "Get default branch", + "description": "Get the default branch name for a repository", + "tags": ["Repositories"], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Default branch", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "defaultBranch": {"type": "string"}, + "branch": {"type": "string"} + } + } + } + } + } + } + } + }, + "/api/repos/{npub}/{repo}/branch-protection": { + "get": { + "summary": "Get branch protection rules", + "description": "Get branch protection rules for a repository", + "tags": ["Repositories"], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "Branch protection rules", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rules": {"type": "array"} + } + } + } + } + } + } + }, + "post": { + "summary": "Update branch protection rules", + "description": "Update branch protection rules. Requires owner authentication.", + "tags": ["Repositories"], + "security": [{"NIP98": []}], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["rules"], + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "branch": {"type": "string"}, + "requirePullRequest": {"type": "boolean"}, + "requireReviewers": {"type": "array", "items": {"type": "string"}}, + "allowForcePush": {"type": "boolean"} + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Rules updated" + } + } + } + }, + "/api/repos/{npub}/{repo}/releases": { + "get": { + "summary": "List releases", + "description": "Get all releases for a repository (NIP-34 kind 1642)", + "tags": ["Repositories"], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "List of releases", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"$ref": "#/components/schemas/NostrEvent"} + } + } + } + } + } + }, + "post": { + "summary": "Create release", + "description": "Create a new release (NIP-34 kind 1642). Requires maintainer authentication.", + "tags": ["Repositories"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": ["transferEvent"], + "required": ["tagName", "tagHash"], "properties": { - "transferEvent": {"$ref": "#/components/schemas/NostrEvent"} + "title": {"type": "string"}, + "tagName": {"type": "string"}, + "tagHash": {"type": "string"}, + "releaseNotes": {"type": "string"}, + "downloadUrl": {"type": "string"}, + "isDraft": {"type": "boolean"}, + "isPrerelease": {"type": "boolean"} } } } @@ -1475,16 +2544,34 @@ }, "responses": { "200": { - "description": "Ownership transferred" + "description": "Release created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "event": {"$ref": "#/components/schemas/NostrEvent"} + } + } + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } } } - } - }, - "/api/repos/{npub}/{repo}/verify": { - "get": { - "summary": "Verify repository ownership", - "description": "Verify repository ownership by checking announcement file", + }, + "patch": { + "summary": "Update release", + "description": "Update a release. Requires maintainer authentication.", "tags": ["Repositories"], + "security": [{"NIP98": []}], "parameters": [ { "name": "npub", @@ -1499,29 +2586,99 @@ "schema": {"type": "string"} } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["releaseId", "tagName"], + "properties": { + "releaseId": {"type": "string"}, + "tagName": {"type": "string"}, + "releaseNotes": {"type": "string"}, + "isDraft": {"type": "boolean"}, + "isPrerelease": {"type": "boolean"} + } + } + } + } + }, "responses": { "200": { - "description": "Verification result", + "description": "Release updated", "content": { "application/json": { "schema": { "type": "object", "properties": { - "verified": {"type": "boolean"}, - "ownerPubkey": {"type": "string"}, - "cloneVerifications": {"type": "array"} + "success": {"type": "boolean"}, + "event": {"$ref": "#/components/schemas/NostrEvent"} } } } } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } } } } }, - "/api/repos/{npub}/{repo}/clone": { + "/api/repos/{npub}/{repo}/patches": { + "get": { + "summary": "List patches", + "description": "Get all patches for a repository (NIP-34 kind 1617)", + "tags": ["Repositories"], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "List of patches with status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "pubkey": {"type": "string"}, + "created_at": {"type": "integer"}, + "kind": {"type": "integer"}, + "tags": {"type": "array"}, + "content": {"type": "string"}, + "sig": {"type": "string"}, + "status": {"type": "string", "enum": ["open", "applied", "closed", "draft"]}, + "statusEvent": {"$ref": "#/components/schemas/NostrEvent"} + } + } + } + } + } + } + } + }, "post": { - "summary": "Clone repository to server", - "description": "Clone a repository to the server. Requires unlimited access.", + "summary": "Create patch", + "description": "Create a new patch (NIP-34 kind 1617)", "tags": ["Repositories"], "security": [{"NIP98": []}], "parameters": [ @@ -1538,29 +2695,106 @@ "schema": {"type": "string"} } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["event"], + "properties": { + "event": { + "$ref": "#/components/schemas/NostrEvent", + "description": "Signed Nostr event (kind 1617)" + } + } + } + } + } + }, "responses": { "200": { - "description": "Repository cloned", + "description": "Patch created", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": {"type": "boolean"}, - "message": {"type": "string"}, - "alreadyExists": {"type": "boolean"} + "event": {"$ref": "#/components/schemas/NostrEvent"}, + "published": {"type": "object"} + } + } + } + } + } + } + }, + "patch": { + "summary": "Update patch status", + "description": "Update patch status (open, applied, closed, draft). Requires maintainer or patch author authentication.", + "tags": ["Repositories"], + "security": [{"NIP98": []}], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + }, + { + "name": "repo", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["patchId", "patchAuthor", "status"], + "properties": { + "patchId": {"type": "string"}, + "patchAuthor": {"type": "string"}, + "status": {"type": "string", "enum": ["open", "applied", "closed", "draft"]} + } + } + } + } + }, + "responses": { + "200": { + "description": "Patch status updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "event": {"$ref": "#/components/schemas/NostrEvent"} } } } } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } } } } }, - "/api/repos/{npub}/{repo}/delete": { - "delete": { - "summary": "Delete local repository clone", - "description": "Delete a local repository clone. Requires owner or admin authentication.", + "/api/repos/{npub}/{repo}/patches/{id}/application": { + "post": { + "summary": "Apply patch to repository", + "description": "Apply a patch to the repository. Requires maintainer authentication.", "tags": ["Repositories"], "security": [{"NIP98": []}], "parameters": [ @@ -1575,22 +2809,68 @@ "in": "path", "required": true, "schema": {"type": "string"} + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "Patch ID (event ID)" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "branch": {"type": "string", "default": "main"}, + "commitMessage": {"type": "string"} + } + } + } + } + }, "responses": { "200": { - "description": "Repository deleted" + "description": "Patch applied successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "commitHash": {"type": "string"}, + "message": {"type": "string"} + } + } + } + } }, "403": { - "description": "Not authorized" + "description": "Not authorized", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + }, + "404": { + "description": "Patch or repository not found", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } } } } }, - "/api/repos/{npub}/{repo}/settings": { + "/api/repos/{npub}/{repo}/clone-urls": { "get": { - "summary": "Get repository settings", - "description": "Get repository settings including description, visibility, and project relays", + "summary": "List clone URLs", + "description": "Get all clone URLs for a repository, optionally with reachability status", "tags": ["Repositories"], "parameters": [ { @@ -1604,42 +2884,56 @@ "in": "path", "required": true, "schema": {"type": "string"} + }, + { + "name": "includeReachability", + "in": "query", + "schema": {"type": "boolean", "default": false}, + "description": "Include reachability status for each URL" + }, + { + "name": "forceRefresh", + "in": "query", + "schema": {"type": "boolean", "default": false}, + "description": "Force refresh reachability cache" } ], "responses": { "200": { - "description": "Repository settings", + "description": "Clone URLs", "content": { "application/json": { "schema": { "type": "object", "properties": { - "owner": {"type": "string"}, - "description": {"type": "string"}, - "visibility": { - "type": "string", - "enum": ["public", "unlisted", "restricted", "private"] - }, - "projectRelays": { + "cloneUrls": { "type": "array", "items": {"type": "string"} }, - "private": { - "type": "boolean", - "description": "Backward compatibility field (maps to visibility)" + "count": {"type": "integer"}, + "reachability": { + "type": "object", + "description": "Reachability results (only if includeReachability=true)" } } } } } + }, + "404": { + "description": "Repository announcement not found", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } } } }, "post": { - "summary": "Update repository settings", - "description": "Update repository settings. Requires maintainer access. Supports visibility levels: public, unlisted, restricted, private. Unlisted and restricted require project-relay.", + "summary": "Check clone URL reachability", + "description": "Check reachability of specific clone URLs", "tags": ["Repositories"], - "security": [{"NIP98": []}], "parameters": [ { "name": "npub", @@ -1660,39 +2954,51 @@ "application/json": { "schema": { "type": "object", + "required": ["urls"], "properties": { - "description": {"type": "string"}, - "visibility": { - "type": "string", - "enum": ["public", "unlisted", "restricted", "private"] - }, - "projectRelays": { + "urls": { "type": "array", - "items": {"type": "string"} + "items": {"type": "string"}, + "description": "Array of clone URLs to check" }, - "private": { - "type": "boolean", - "description": "Deprecated: Use visibility instead" + "forceRefresh": {"type": "boolean", "default": false} + } + } + } + } + }, + "responses": { + "200": { + "description": "Reachability results", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "object", + "description": "Map of URL to reachability result" + } } } } } - } - }, - "responses": { - "200": { - "description": "Settings updated successfully" }, - "403": { - "description": "Not authorized (requires maintainer access)" + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } } } } }, - "/api/repos/{npub}/{repo}/access": { + "/api/repos/{npub}/{repo}/clone-urls/reachability": { "get": { - "summary": "Check repository access", - "description": "Check if current user can view the repository", + "summary": "Get clone URL reachability status", + "description": "Get cached reachability status for all clone URLs of a repository", "tags": ["Repositories"], "parameters": [ { @@ -1706,32 +3012,44 @@ "in": "path", "required": true, "schema": {"type": "string"} + }, + { + "name": "forceRefresh", + "in": "query", + "schema": {"type": "boolean", "default": false}, + "description": "Force refresh reachability cache" } ], "responses": { "200": { - "description": "Access information", + "description": "Reachability results", "content": { "application/json": { "schema": { "type": "object", "properties": { - "canView": {"type": "boolean"}, - "isPrivate": {"type": "boolean"}, - "isMaintainer": {"type": "boolean"}, - "isOwner": {"type": "boolean"} + "results": { + "type": "object", + "description": "Map of URL to reachability result" + } } } } } + }, + "404": { + "description": "Repository announcement not found", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } } } - } - }, - "/api/repos/{npub}/{repo}/default-branch": { - "get": { - "summary": "Get default branch", - "description": "Get the default branch name for a repository", + }, + "post": { + "summary": "Test clone URL reachability", + "description": "Test reachability of specific clone URLs", "tags": ["Repositories"], "parameters": [ { @@ -1747,28 +3065,57 @@ "schema": {"type": "string"} } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["urls"], + "properties": { + "urls": { + "type": "array", + "items": {"type": "string"}, + "description": "Array of clone URLs to test" + }, + "forceRefresh": {"type": "boolean", "default": false} + } + } + } + } + }, "responses": { "200": { - "description": "Default branch", + "description": "Reachability results", "content": { "application/json": { "schema": { "type": "object", "properties": { - "defaultBranch": {"type": "string"}, - "branch": {"type": "string"} + "results": { + "type": "object", + "description": "Map of URL to reachability result" + } } } } } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } } } } }, - "/api/repos/{npub}/{repo}/branch-protection": { + "/api/repos/{npub}/{repo}/commits/{hash}/verification": { "get": { - "summary": "Get branch protection rules", - "description": "Get branch protection rules for a repository", + "summary": "Verify commit signature", + "description": "Verify the signature of a commit using Nostr commit signature events (kind 1640)", "tags": ["Repositories"], "parameters": [ { @@ -1782,62 +3129,44 @@ "in": "path", "required": true, "schema": {"type": "string"} + }, + { + "name": "hash", + "in": "path", + "required": true, + "schema": {"type": "string"}, + "description": "Commit hash" } ], "responses": { "200": { - "description": "Branch protection rules", + "description": "Verification result", "content": { "application/json": { "schema": { "type": "object", "properties": { - "rules": {"type": "array"} + "verified": {"type": "boolean"}, + "signer": {"type": "string"}, + "event": {"$ref": "#/components/schemas/NostrEvent"}, + "error": {"type": "string"} } } } } - } - } - }, - "post": { - "summary": "Update branch protection rules", - "description": "Update branch protection rules. Requires owner authentication.", - "tags": ["Repositories"], - "security": [{"NIP98": []}], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["rules"], - "properties": { - "rules": { - "type": "array", - "items": { - "type": "object", - "properties": { - "branch": {"type": "string"}, - "requirePullRequest": {"type": "boolean"}, - "requireReviewers": {"type": "array", "items": {"type": "string"}}, - "allowForcePush": {"type": "boolean"} - } - } - } - } + }, + "404": { + "description": "Commit not found", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} } } } - }, - "responses": { - "200": { - "description": "Rules updated" - } } } }, - "/api/repos/{npub}/{repo}/diff": { + "/api/repos/{npub}/{repo}/diffs": { "get": { "summary": "Get diff between commits", "description": "Get diff between two git references", @@ -2092,6 +3421,243 @@ } } } + }, + "/api/user/messaging-preferences/summary": { + "get": { + "summary": "Get messaging preferences summary", + "description": "Get safe summary of messaging preferences (no sensitive tokens). Requires unlimited access. Preferences are stored client-side in IndexedDB.", + "tags": ["User"], + "security": [{"NIP98": []}], + "responses": { + "200": { + "description": "Preferences summary", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "configured": {"type": "boolean"}, + "enabled": {"type": "boolean"}, + "platforms": {"type": "object"}, + "message": {"type": "string"}, + "error": {"type": "string"} + } + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + }, + "403": { + "description": "Unlimited access required", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + } + } + } + }, + "/api/users/{npub}/profile": { + "get": { + "summary": "Get user profile", + "description": "Get user profile (kind 0) and payment targets (kind 10133)", + "tags": ["User"], + "parameters": [ + { + "name": "npub", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "User profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "npub": {"type": "string"}, + "pubkey": {"type": "string"}, + "profile": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "about": {"type": "string"}, + "picture": {"type": "string"}, + "websites": {"type": "array", "items": {"type": "string"}}, + "nip05": {"type": "array", "items": {"type": "string"}} + } + }, + "profileEvent": {"$ref": "#/components/schemas/NostrEvent"}, + "paymentTargets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "authority": {"type": "string"}, + "payto": {"type": "string"} + } + } + }, + "paymentEvent": {"$ref": "#/components/schemas/NostrEvent"} + } + } + } + } + }, + "400": { + "description": "Invalid npub format", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + } + } + } + }, + "/api/config": { + "get": { + "summary": "Get server configuration status", + "description": "Get server configuration status without exposing sensitive values", + "tags": ["Infrastructure"], + "responses": { + "200": { + "description": "Configuration status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "github": { + "type": "object", + "properties": { + "tokenConfigured": {"type": "boolean"} + } + }, + "git": { + "type": "object", + "properties": { + "repoRoot": {"type": "string"}, + "domain": {"type": "string"}, + "defaultBranch": {"type": "string"}, + "operationTimeoutMs": {"type": "integer"}, + "cloneTimeoutMs": {"type": "integer"}, + "allowForcePush": {"type": "boolean"} + } + }, + "nostr": { + "type": "object", + "properties": { + "relays": {"type": "array", "items": {"type": "string"}}, + "searchRelays": {"type": "array", "items": {"type": "string"}}, + "nip98AuthWindowSeconds": {"type": "integer"} + } + }, + "tor": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "socksProxy": {"type": "string"}, + "hostnameFile": {"type": "string"}, + "onionAddress": {"type": "string"} + } + }, + "security": { + "type": "object", + "properties": { + "adminPubkeysConfigured": {"type": "boolean"}, + "auditLoggingEnabled": {"type": "boolean"}, + "auditLogFile": {"type": "string"}, + "auditLogRetentionDays": {"type": "integer"}, + "rateLimitEnabled": {"type": "boolean"}, + "rateLimitWindowMs": {"type": "integer"} + } + }, + "resources": { + "type": "object", + "properties": { + "maxReposPerUser": {"type": "integer"}, + "maxDiskQuotaPerUser": {"type": "integer"} + } + }, + "messaging": { + "type": "object", + "properties": { + "encryptionKeyConfigured": {"type": "boolean"}, + "saltEncryptionKeyConfigured": {"type": "boolean"}, + "lookupSecretConfigured": {"type": "boolean"} + } + }, + "enterprise": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"} + } + }, + "docker": { + "type": "object", + "properties": { + "container": {"type": "boolean"} + } + } + } + } + } + } + } + } + } + }, + "/api/transfers/pending": { + "get": { + "summary": "Get pending ownership transfers", + "description": "Get pending ownership transfers for the authenticated user. Returns transfers where the user is the new owner.", + "tags": ["Repositories"], + "responses": { + "200": { + "description": "Pending transfers", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pendingTransfers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "eventId": {"type": "string"}, + "fromPubkey": {"type": "string"}, + "toPubkey": {"type": "string"}, + "repoTag": {"type": "string"}, + "repoName": {"type": "string"}, + "originalOwner": {"type": "string"}, + "timestamp": {"type": "integer"}, + "createdAt": {"type": "string"}, + "event": {"$ref": "#/components/schemas/NostrEvent"} + } + } + }, + "error": {"type": "string"} + } + } + } + } + } + } + } } } } diff --git a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts deleted file mode 100644 index 9602c0d..0000000 --- a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts +++ /dev/null @@ -1,543 +0,0 @@ -/** - * API endpoint for forking repositories - */ - -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { DEFAULT_NOSTR_RELAYS, combineRelays, getGitUrl } from '$lib/config.js'; -import { getUserRelays } from '$lib/services/nostr/user-relays.js'; -import { NostrClient } from '$lib/services/nostr/nostr-client.js'; -import { KIND, type NostrEvent } from '$lib/types/nostr.js'; -import { getVisibility, getProjectRelays } from '$lib/utils/repo-visibility.js'; -import { nip19 } from 'nostr-tools'; -import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; -import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; -import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; -import { existsSync } from 'fs'; -import { rm } from 'fs/promises'; -import { join, resolve } from 'path'; -import simpleGit from 'simple-git'; -import { isValidBranchName, validateRepoPath } from '$lib/utils/security.js'; -import { ResourceLimits } from '$lib/services/security/resource-limits.js'; -import { auditLogger } from '$lib/services/security/audit-logger.js'; -import { ForkCountService } from '$lib/services/nostr/fork-count-service.js'; -import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; -import { hasUnlimitedAccess } from '$lib/utils/user-access.js'; -import logger from '$lib/services/logger.js'; -import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; -import { eventCache } from '$lib/services/nostr/event-cache.js'; -import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; - -import { repoManager, nostrClient, forkCountService } from '$lib/services/service-registry.js'; - -// Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths) -const repoRootEnv = process.env.GIT_REPO_ROOT || '/repos'; -const repoRoot = resolve(repoRootEnv); -const resourceLimits = new ResourceLimits(repoRoot); - -/** - * Retry publishing an event with exponential backoff - * Attempts up to 3 times with delays: 1s, 2s, 4s - */ -async function publishEventWithRetry( - event: NostrEvent, - relays: string[], - eventName: string, - maxAttempts: number = 3, - context?: string -): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> { - let lastResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null; - - // Extract context from event if available (for better logging) - const eventId = event.id.slice(0, 8); - const logContext = context || `[event:${eventId}]`; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - logger.info({ logContext, eventName, attempt, maxAttempts }, `[Fork] Publishing ${eventName} - Attempt ${attempt}/${maxAttempts}...`); - - lastResult = await nostrClient.publishEvent(event, relays); - - if (lastResult.success.length > 0) { - logger.info({ logContext, eventName, successCount: lastResult.success.length, relays: lastResult.success }, `[Fork] ${eventName} published successfully`); - if (lastResult.failed.length > 0) { - logger.warn({ logContext, eventName, failed: lastResult.failed }, `[Fork] Some relays failed`); - } - return lastResult; - } - - if (attempt < maxAttempts) { - const delayMs = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s - logger.warn({ logContext, eventName, attempt, delayMs, failed: lastResult.failed }, `[Fork] ${eventName} failed on attempt ${attempt}. Retrying...`); - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - } - - // All attempts failed - logger.error({ logContext, eventName, maxAttempts, failed: lastResult?.failed }, `[Fork] ${eventName} failed after ${maxAttempts} attempts`); - return lastResult!; -} - -/** - * POST - Fork a repository - * Body: { userPubkey, forkName? } - */ -export const POST: RequestHandler = async ({ params, request }) => { - const { npub, repo } = params; - - if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); - } - - try { - const body = await request.json(); - const { userPubkey, forkName, localOnly } = body; - - if (!userPubkey) { - return error(401, 'Authentication required. Please provide userPubkey.'); - } - - // Validate localOnly parameter - const isLocalOnly = localOnly === true; - - // Decode original repo owner npub - let originalOwnerPubkey: string; - try { - originalOwnerPubkey = requireNpubHex(npub); - } catch { - return error(400, 'Invalid npub format'); - } - - // Decode user pubkey if needed (must be done before using it) - const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey; - - // Convert to npub for resource check and path construction - const userNpub = nip19.npubEncode(userPubkeyHex); - - // Determine fork name (use original name if not specified) - const forkRepoName = forkName || repo; - - // Check if user has unlimited access (required for storing repos locally) - const userLevel = getCachedUserLevel(userPubkeyHex); - if (!hasUnlimitedAccess(userLevel?.level)) { - const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; - auditLogger.logRepoFork( - userPubkeyHex, - `${npub}/${repo}`, - `${userNpub}/${forkRepoName}`, - 'failure', - 'User does not have unlimited access' - ); - return error(403, 'Repository creation requires unlimited access. Please verify you can write to at least one default Nostr relay.'); - } - - // Check resource limits before forking - const resourceCheck = await resourceLimits.canCreateRepo(userNpub); - if (!resourceCheck.allowed) { - const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; - auditLogger.logRepoFork( - userPubkeyHex, - `${npub}/${repo}`, - `${userNpub}/${forkRepoName}`, - 'failure', - resourceCheck.reason - ); - return error(403, resourceCheck.reason || 'Resource limit exceeded'); - } - - // Check if original repo exists - const originalRepoPath = join(repoRoot, npub, `${repo}.git`); - // Security: Ensure resolved path is within repoRoot - const originalPathValidation = validateRepoPath(originalRepoPath, repoRoot); - if (!originalPathValidation.valid) { - return error(403, originalPathValidation.error || 'Invalid repository path'); - } - if (!existsSync(originalRepoPath)) { - return error(404, 'Original repository not found'); - } - - // Get original repo announcement (case-insensitive) with caching - const allAnnouncements = await fetchRepoAnnouncementsWithCache(nostrClient, originalOwnerPubkey, eventCache); - const originalAnnouncement = findRepoAnnouncement(allAnnouncements, repo); - - if (!originalAnnouncement) { - return error(404, 'Original repository announcement not found'); - } - - // Check if fork already exists - const forkRepoPath = join(repoRoot, userNpub, `${forkRepoName}.git`); - // Security: Ensure resolved path is within repoRoot - const forkPathValidation = validateRepoPath(forkRepoPath, repoRoot); - if (!forkPathValidation.valid) { - return error(403, forkPathValidation.error || 'Invalid fork repository path'); - } - if (existsSync(forkRepoPath)) { - return error(409, 'Fork already exists'); - } - - // Clone the repository using simple-git (safer than shell commands) - const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; - auditLogger.logRepoFork( - userPubkeyHex, - `${npub}/${repo}`, - `${userNpub}/${forkRepoName}`, - 'success' - ); - - const git = simpleGit(); - await git.clone(originalRepoPath, forkRepoPath, ['--bare']); - - // Invalidate resource limit cache after creating repo - resourceLimits.invalidateCache(userNpub); - - // Create fork announcement - const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543'; - const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); - const protocol = isLocalhost ? 'http' : 'https'; - const forkGitUrl = `${protocol}://${gitDomain}/${userNpub}/${forkRepoName}.git`; - - // Get Tor .onion URL if available - const { getTorGitUrl } = await import('$lib/services/tor/hidden-service.js'); - const torOnionUrl = await getTorGitUrl(userNpub, forkRepoName); - - // Extract original clone URLs and earliest unique commit - const originalCloneUrls = originalAnnouncement.tags - .filter(t => t[0] === 'clone') - .flatMap(t => t.slice(1)) - .filter(url => url && typeof url === 'string') - .filter(url => { - // Exclude our domain and .onion URLs (we'll add our own if available) - if (url.includes(gitDomain)) return false; - if (url.includes('.onion')) return false; - return true; - }) as string[]; - - const earliestCommitTag = originalAnnouncement.tags.find(t => t[0] === 'r' && t[2] === 'euc'); - const earliestCommit = earliestCommitTag?.[1]; - - // Get original repo name and description - const originalName = originalAnnouncement.tags.find(t => t[0] === 'name')?.[1] || repo; - const originalDescription = originalAnnouncement.tags.find(t => t[0] === 'description')?.[1] || ''; - - // Build clone URLs for fork - NEVER include localhost, only include public domain or Tor .onion - const forkCloneUrls: string[] = []; - - // Add our domain URL only if it's NOT localhost (explicitly check the URL) - if (!isLocalhost && !forkGitUrl.includes('localhost') && !forkGitUrl.includes('127.0.0.1')) { - forkCloneUrls.push(forkGitUrl); - } - - // Add Tor .onion URL if available - if (torOnionUrl) { - forkCloneUrls.push(torOnionUrl); - } - - // Add original clone URLs - forkCloneUrls.push(...originalCloneUrls); - - // Validate: If using localhost, require either Tor .onion URL or at least one other clone URL - if (isLocalhost && !torOnionUrl && originalCloneUrls.length === 0) { - return error(400, 'Cannot create fork with only localhost. The original repository must have at least one public clone URL, or you need to configure a Tor .onion address.'); - } - - // Preserve visibility and project-relay from original repo - const originalVisibility = getVisibility(originalAnnouncement); - const originalProjectRelays = getProjectRelays(originalAnnouncement); - - // 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], - ['fork', originalRepoTag], // Standardized fork tag format - ['p', originalOwnerPubkey], // Original owner - ]; - - // Local-only forks are always private and marked as synthetic - if (isLocalOnly) { - tags.push(['visibility', 'private']); - tags.push(['local-only', 'true']); // Mark as synthetic/local-only - } else { - // Preserve visibility from original repo (defaults to public if not set) - if (originalVisibility !== 'public') { - tags.push(['visibility', originalVisibility]); - } - } - - // Preserve project-relay tags from original repo - for (const relay of originalProjectRelays) { - tags.push(['project-relay', relay]); - } - - // Add earliest unique commit if available - if (earliestCommit) { - tags.push(['r', earliestCommit, 'euc']); - } - - // Create fork announcement event - const forkAnnouncementTemplate = { - kind: KIND.REPO_ANNOUNCEMENT, - pubkey: userPubkeyHex, - created_at: Math.floor(Date.now() / 1000), - content: '', - tags - }; - - // Sign fork announcement - const signedForkAnnouncement = await signEventWithNIP07(forkAnnouncementTemplate); - - // Security: Truncate npub in logs and create context (must be before use) - const truncatedNpub = userNpub.length > 16 ? `${userNpub.slice(0, 12)}...` : userNpub; - const truncatedOriginalNpub = npub.length > 16 ? `${npub.slice(0, 12)}...` : npub; - const context = `[${truncatedOriginalNpub}/${repo} → ${truncatedNpub}/${forkRepoName}]`; - - let publishResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null; - let ownershipPublishResult: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = null; - let signedOwnershipEvent: NostrEvent | null = null; - - if (isLocalOnly) { - // Local-only fork: Skip publishing to Nostr relays - logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, localOnly: true }, 'Creating local-only fork (not publishing to Nostr)'); - publishResult = { success: [], failed: [] }; - ownershipPublishResult = { success: [], failed: [] }; - - // For local-only forks, create a synthetic ownership event (not published) - const ownershipService = new OwnershipTransferService([]); - const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(userPubkeyHex, forkRepoName); - signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent); - logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Created synthetic ownership event for local-only fork'); - } else { - // Public fork: Publish to Nostr relays - const { outbox } = await getUserRelays(userPubkeyHex, nostrClient); - const combinedRelays = combineRelays(outbox); - - logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, relayCount: combinedRelays.length, relays: combinedRelays }, 'Starting fork process'); - - publishResult = await publishEventWithRetry( - signedForkAnnouncement, - combinedRelays, - 'fork announcement', - 3, - context - ); - - if (publishResult.success.length === 0) { - // Clean up repo if announcement failed - logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: publishResult.failed }, 'Fork announcement failed after all retries. Cleaning up repository.'); - await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {}); - const errorDetails = `All relays failed: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`; - return json({ - success: false, - error: 'Failed to publish fork announcement to relays after 3 attempts', - details: errorDetails, - eventName: 'fork announcement' - }, { status: 500 }); - } - - // Create and publish initial ownership proof (self-transfer event) - // This MUST succeed for the fork to be valid - without it, there's no proof of ownership on Nostr - const ownershipService = new OwnershipTransferService(combinedRelays); - const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(userPubkeyHex, forkRepoName); - signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent); - - ownershipPublishResult = await publishEventWithRetry( - signedOwnershipEvent, - combinedRelays, - 'ownership transfer event', - 3, - context - ); - - if (ownershipPublishResult.success.length === 0) { - // Clean up repo if ownership proof failed - logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: ownershipPublishResult.failed }, 'Ownership transfer event failed after all retries. Cleaning up repository and publishing deletion request.'); - await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {}); - - // Publish deletion request (NIP-09) for the announcement since it's invalid without ownership proof - logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Publishing deletion request for invalid fork announcement...'); - const deletionRequest = { - kind: KIND.DELETION_REQUEST, // NIP-09: Event Deletion Request - pubkey: userPubkeyHex, - created_at: Math.floor(Date.now() / 1000), - content: 'Fork failed: ownership transfer event could not be published after 3 attempts. This announcement is invalid.', - tags: [ - ['a', `${KIND.REPO_ANNOUNCEMENT}:${userPubkeyHex}:${forkRepoName}`], // Reference to the repo announcement - ['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted - ] - }; - - const signedDeletionRequest = await signEventWithNIP07(deletionRequest); - const deletionResult = await publishEventWithRetry( - signedDeletionRequest, - combinedRelays, - 'deletion request', - 3, - context - ); - - if (deletionResult.success.length > 0) { - logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Deletion request published successfully'); - } else { - logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: deletionResult.failed }, 'Failed to publish deletion request'); - } - - const errorDetails = `Fork is invalid without ownership proof. All relays failed: ${ownershipPublishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}. Deletion request ${deletionResult.success.length > 0 ? 'published' : 'failed to publish'}.`; - return json({ - success: false, - error: 'Failed to publish ownership transfer event to relays after 3 attempts', - details: errorDetails, - eventName: 'ownership transfer event' - }, { status: 500 }); - } - } - - // Provision the fork repo (this will create verification file and include self-transfer) - logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, localOnly: isLocalOnly }, 'Provisioning fork repository...'); - await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent || undefined, false); - - // Save fork announcement to repo (offline papertrail) in nostr/repo-events.jsonl - try { - const { fileManager } = await import('$lib/services/service-registry.js'); - - // Save to repo if it exists locally (should exist after provisioning) - if (fileManager.repoExists(userNpub, forkRepoName)) { - // 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/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) { - // Log but don't fail - publishing to relays is more important - logger.warn({ error: err, npub: userNpub, repo: forkRepoName }, 'Failed to save fork announcement to repo'); - } - - logger.info({ - operation: 'fork', - originalRepo: `${npub}/${repo}`, - forkRepo: `${userNpub}/${forkRepoName}`, - localOnly: isLocalOnly, - announcementId: signedForkAnnouncement.id, - ownershipTransferId: signedOwnershipEvent?.id, - announcementRelays: publishResult?.success.length || 0, - ownershipRelays: ownershipPublishResult?.success.length || 0 - }, 'Fork completed successfully'); - - const message = isLocalOnly - ? 'Local-only fork created successfully! This fork is private and only exists on this server.' - : `Repository forked successfully! Published to ${publishResult?.success.length || 0} relay(s) for announcement and ${ownershipPublishResult?.success.length || 0} relay(s) for ownership proof.`; - - return json({ - success: true, - fork: { - npub: userNpub, - repo: forkRepoName, - url: forkGitUrl, - localOnly: isLocalOnly, - announcementId: signedForkAnnouncement.id, - ownershipTransferId: signedOwnershipEvent?.id, - publishedTo: isLocalOnly ? null : { - announcement: publishResult?.success.length || 0, - ownershipTransfer: ownershipPublishResult?.success.length || 0 - } - }, - message - }); - } catch (err) { - return handleApiError(err, { operation: 'fork', npub, repo }, 'Failed to fork repository'); - } -}; - -/** - * GET - Get fork information - * Returns whether this is a fork and what it's forked from - */ -export const GET: RequestHandler = async ({ params }) => { - const { npub, repo } = params; - - if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); - } - - try { - // Decode repo owner npub - let ownerPubkey: string; - try { - ownerPubkey = requireNpubHex(npub); - } catch { - return error(400, 'Invalid npub format'); - } - - // Get repo announcement (case-insensitive) with caching - const allAnnouncements = await fetchRepoAnnouncementsWithCache(nostrClient, ownerPubkey, eventCache); - const announcement = findRepoAnnouncement(allAnnouncements, repo); - - if (!announcement) { - return error(404, 'Repository announcement not found'); - } - - // announcement is already set above - const isFork = announcement.tags.some(t => t[0] === 't' && t[1] === 'fork'); - - // Get original repo reference - const originalRepoTag = announcement.tags.find(t => t[0] === 'a' && t[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)); - const originalOwnerTag = announcement.tags.find(t => t[0] === 'p' && t[1] !== ownerPubkey); - - let originalRepo: { npub: string; repo: string } | null = null; - if (originalRepoTag && originalRepoTag[1]) { - const match = originalRepoTag[1].match(new RegExp(`^${KIND.REPO_ANNOUNCEMENT}:([a-f0-9]{64}):(.+)$`)); - if (match) { - const [, originalOwnerPubkey, originalRepoName] = match; - try { - const originalNpub = nip19.npubEncode(originalOwnerPubkey); - originalRepo = { npub: originalNpub, repo: originalRepoName }; - } catch { - // Invalid pubkey - } - } - } - - // Get fork count for this repo - let forkCount = 0; - if (!isFork && ownerPubkey && repo) { - try { - forkCount = await forkCountService.getForkCount(ownerPubkey, repo); - } catch (err) { - // Log but don't fail the request - const context = npub && repo ? `[${npub}/${repo}]` : '[unknown]'; - logger.warn({ error: err, npub, repo }, `[Fork] ${context} Failed to get fork count`); - } - } - - return json({ - isFork, - originalRepo, - forkCount - }); - } catch (err) { - return handleApiError(err, { operation: 'getForkInfo', npub, repo }, 'Failed to get fork information'); - } -}; diff --git a/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts b/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts deleted file mode 100644 index 1f338a3..0000000 --- a/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * API endpoint for transferring repository ownership - */ - -import { json, error } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { ownershipTransferService, nostrClient, fileManager } from '$lib/services/service-registry.js'; -import { combineRelays } from '$lib/config.js'; -import { KIND } from '$lib/types/nostr.js'; -import { verifyEvent, nip19 } from 'nostr-tools'; -import type { NostrEvent } from '$lib/types/nostr.js'; -import { getUserRelays } from '$lib/services/nostr/user-relays.js'; -import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js'; -import type { RepoRequestContext } from '$lib/utils/api-context.js'; -import type { RequestEvent } from '@sveltejs/kit'; -import { handleApiError, handleValidationError, handleAuthorizationError } from '$lib/utils/error-handler.js'; -import logger from '$lib/services/logger.js'; - -/** - * GET - Get current owner and transfer history - */ -export const GET: RequestHandler = createRepoGetHandler( - async (context: RepoRequestContext) => { - // Get current owner (may be different if transferred) - const currentOwner = await ownershipTransferService.getCurrentOwner(context.repoOwnerPubkey, context.repo); - - // Fetch transfer events for history - const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${context.repoOwnerPubkey}:${context.repo}`; - const transferEvents = await nostrClient.fetchEvents([ - { - kinds: [KIND.OWNERSHIP_TRANSFER], - '#a': [repoTag], - limit: 100 - } - ]); - - // Sort by created_at descending - transferEvents.sort((a, b) => b.created_at - a.created_at); - - return json({ - originalOwner: context.repoOwnerPubkey, - currentOwner, - transferred: currentOwner !== context.repoOwnerPubkey, - transfers: transferEvents.map(event => { - const pTag = event.tags.find(t => t[0] === 'p'); - return { - eventId: event.id, - from: event.pubkey, - to: pTag?.[1] || 'unknown', - timestamp: event.created_at, - createdAt: new Date(event.created_at * 1000).toISOString() - }; - }) - }); - }, - { operation: 'getOwnership', requireRepoAccess: false } // Ownership info is public -); - -/** - * POST - Initiate ownership transfer - * Requires a pre-signed NIP-98 authenticated event from the current owner - */ -export const POST: RequestHandler = withRepoValidation( - async ({ repoContext, requestContext, event }) => { - if (!requestContext.userPubkeyHex) { - throw handleApiError(new Error('Authentication required'), { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }, 'Authentication required'); - } - - const body = await event.request.json(); - const { transferEvent } = body; - - if (!transferEvent) { - return handleValidationError('Missing transferEvent in request body', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); - } - - // Verify the event is properly signed - if (!transferEvent.sig || !transferEvent.id) { - throw handleValidationError('Invalid event: missing signature or ID', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); - } - - if (!verifyEvent(transferEvent)) { - throw handleValidationError('Invalid event signature', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); - } - - // Verify user is the current owner - const canTransfer = await ownershipTransferService.canTransfer( - requestContext.userPubkeyHex, - repoContext.repoOwnerPubkey, - repoContext.repo - ); - - if (!canTransfer) { - throw handleAuthorizationError('Only the current repository owner can transfer ownership', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); - } - - // Verify the transfer event is from the current owner - if (transferEvent.pubkey !== requestContext.userPubkeyHex) { - throw handleAuthorizationError('Transfer event must be signed by the current owner', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); - } - - // Verify it's an ownership transfer event - if (transferEvent.kind !== KIND.OWNERSHIP_TRANSFER) { - throw handleValidationError(`Event must be kind ${KIND.OWNERSHIP_TRANSFER} (ownership transfer)`, { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); - } - - // Verify the 'a' tag references this repo - const aTag = transferEvent.tags.find(t => t[0] === 'a'); - const expectedRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${repoContext.repoOwnerPubkey}:${repoContext.repo}`; - if (!aTag || aTag[1] !== expectedRepoTag) { - throw handleValidationError("Transfer event 'a' tag does not match this repository", { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }); - } - - // Get user's relays and publish - const { outbox } = await getUserRelays(requestContext.userPubkeyHex, nostrClient); - const combinedRelays = combineRelays(outbox); - - const result = await nostrClient.publishEvent(transferEvent as NostrEvent, combinedRelays); - - if (result.success.length === 0) { - throw handleApiError(new Error('Failed to publish transfer event to any relays'), { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish transfer event to any relays'); - } - - // Save transfer event to repo (offline papertrail - step 1 requirement) - try { - const transferEventContent = JSON.stringify(transferEvent, null, 2) + '\n'; - // Use consistent filename pattern: .nostr-ownership-transfer-{eventId}.json - const transferFileName = `.nostr-ownership-transfer-${transferEvent.id}.json`; - - // Save to repo if it exists locally - if (fileManager.repoExists(repoContext.npub, repoContext.repo)) { - // Get worktree to save to repo-events.jsonl - const defaultBranch = await fileManager.getDefaultBranch(repoContext.npub, repoContext.repo).catch(() => 'main'); - const repoPath = fileManager.getRepoPath(repoContext.npub, repoContext.repo); - const workDir = await fileManager.getWorktree(repoPath, defaultBranch, repoContext.npub, repoContext.repo); - - // Save to repo-events.jsonl (standard file for easy analysis) - await fileManager.saveRepoEventToWorktree(workDir, transferEvent as NostrEvent, 'transfer').catch(err => { - logger.debug({ error: err }, 'Failed to save transfer event to repo-events.jsonl'); - }); - - // Also save individual transfer file - await fileManager.writeFile( - repoContext.npub, - repoContext.repo, - transferFileName, - transferEventContent, - `Add ownership transfer event: ${transferEvent.id.slice(0, 16)}...`, - 'Nostr', - `${requestContext.userPubkeyHex}@nostr`, - defaultBranch - ).catch(err => { - // Log but don't fail - publishing to relays is more important - logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save transfer event to repo'); - }); - - // Clean up worktree - await fileManager.removeWorktree(repoPath, workDir).catch(err => { - logger.debug({ error: err }, 'Failed to remove worktree after saving transfer event'); - }); - } else { - logger.debug({ npub: repoContext.npub, repo: repoContext.repo }, 'Repo does not exist locally, skipping transfer event save to repo'); - } - } catch (err) { - // Log but don't fail - publishing to relays is more important - logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo }, 'Failed to save transfer event to repo'); - } - - // Clear cache so new owner is recognized immediately - ownershipTransferService.clearCache(repoContext.repoOwnerPubkey, repoContext.repo); - - return json({ - success: true, - event: transferEvent, - published: result, - message: 'Ownership transfer initiated successfully', - // Signal to client that page should refresh - refresh: true - }); - }, - { operation: 'transferOwnership', requireRepoAccess: false } // Override to check owner instead -); diff --git a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts deleted file mode 100644 index 868c298..0000000 --- a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * API endpoint for verifying repository ownership - */ - -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 } 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'; -import { join } from 'path'; -import { decodeNpubToHex } from '$lib/utils/npub-utils.js'; -import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js'; -import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; -import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; -import { eventCache } from '$lib/services/nostr/event-cache.js'; -import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; -import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; -import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; -import { AnnouncementManager } from '$lib/services/git/announcement-manager.js'; -import { extractRequestContext } from '$lib/utils/api-context.js'; -import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js'; -import simpleGit from 'simple-git'; -import logger from '$lib/services/logger.js'; - -const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT - ? process.env.GIT_REPO_ROOT - : '/repos'; - -export const GET: RequestHandler = createRepoGetHandler( - async (context: RepoRequestContext) => { - // Check if repository exists - verification doesn't require the repo to be cloned locally - // We can verify ownership from Nostr events alone - - // Fetch the repository announcement (case-insensitive) with caching - const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); - const announcement = findRepoAnnouncement(allEvents, context.repo); - - if (!announcement) { - return json({ - verified: false, - error: 'Repository announcement not found', - message: 'Could not find a NIP-34 repository announcement for this repository.' - }); - } - - // Extract clone URLs from announcement - const cloneUrls: string[] = []; - for (const tag of announcement.tags) { - if (tag[0] === 'clone') { - for (let i = 1; i < tag.length; i++) { - const url = tag[i]; - if (url && typeof url === 'string') { - cloneUrls.push(url); - } - } - } - } - - // Verify ownership for each clone separately - // Ownership is determined by the most recent announcement file checked into each clone - const cloneVerifications: Array<{ url: string; verified: boolean; ownerPubkey: string | null; error?: string }> = []; - - // First, verify the local GitRepublic clone (if it exists) - let localVerified = false; - let localOwner: string | null = null; - let localError: string | undefined; - const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); - const repoExists = existsSync(repoPath); - - if (repoExists) { - // Repo is cloned - verify the announcement file matches - try { - // Get current owner from the most recent announcement file in the repo - localOwner = await fileManager.getCurrentOwnerFromRepo(context.npub, context.repo); - - if (localOwner) { - // Verify the announcement in nostr/repo-events.jsonl matches the announcement event - try { - 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; - localError = 'Announcement file not found in repository'; - } - } else { - localVerified = false; - localError = 'No announcement found in repository'; - } - } catch (err) { - localVerified = false; - localError = err instanceof Error ? err.message : 'Failed to verify local clone'; - } - } else { - // Repo is not cloned yet - verify from Nostr announcement alone - // The announcement pubkey must match the repo owner - if (announcement.pubkey === context.repoOwnerPubkey) { - localVerified = true; - localOwner = context.repoOwnerPubkey; - localError = undefined; - } else { - localVerified = false; - localOwner = announcement.pubkey; - localError = 'Announcement pubkey does not match repository owner'; - } - } - - // Add local clone verification - const localUrl = cloneUrls.find(url => url.includes(context.npub) || url.includes(context.repoOwnerPubkey)); - if (localUrl) { - cloneVerifications.push({ - url: localUrl, - verified: localVerified, - ownerPubkey: localOwner, - error: localError - }); - } - - // For other clones (GitHub, GitLab, etc.), we'd need to fetch them first to check their announcement files - // This is a future enhancement - for now we only verify the local GitRepublic clone - - // Overall verification: at least one clone must be verified - const overallVerified = cloneVerifications.some(cv => cv.verified); - const verifiedClones = cloneVerifications.filter(cv => cv.verified); - const currentOwner = localOwner || context.repoOwnerPubkey; - - if (overallVerified) { - return json({ - verified: true, - announcementId: announcement.id, - ownerPubkey: currentOwner, - verificationMethod: 'announcement-file', - cloneVerifications: cloneVerifications.map(cv => ({ - url: cv.url, - verified: cv.verified, - ownerPubkey: cv.ownerPubkey, - error: cv.error - })), - message: `Repository ownership verified successfully for ${verifiedClones.length} clone(s)` - }); - } else { - return json({ - verified: false, - error: localError || 'Repository ownership verification failed', - announcementId: announcement.id, - verificationMethod: 'announcement-file', - cloneVerifications: cloneVerifications.map(cv => ({ - url: cv.url, - verified: cv.verified, - ownerPubkey: cv.ownerPubkey, - error: cv.error - })), - message: 'Repository ownership verification failed for all clones' - }); - } - }, - { operation: 'verifyRepo', requireRepoExists: false, requireRepoAccess: false } // Verification is public, doesn't need repo to exist -); - -const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); -const announcementManager = new AnnouncementManager(repoRoot); - -export const POST: RequestHandler = createRepoPostHandler( - async (context: RepoRequestContext, event: RequestEvent) => { - const requestContext = extractRequestContext(event); - const userPubkeyHex = requestContext.userPubkeyHex; - - if (!userPubkeyHex) { - return error(401, 'Authentication required. Please provide userPubkey.'); - } - - // Check if user is a maintainer or the repository owner - const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, context.repoOwnerPubkey, context.repo); - const isOwner = userPubkeyHex === context.repoOwnerPubkey; - if (!isMaintainer && !isOwner) { - return error(403, 'Only repository owners and maintainers can save announcements.'); - } - - // Check if repository is cloned - const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); - if (!existsSync(repoPath)) { - return error(404, 'Repository is not cloned locally. Please clone the repository first.'); - } - - // Fetch the repository announcement - const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); - const announcement = findRepoAnnouncement(allEvents, context.repo); - - if (!announcement) { - return error(404, 'Repository announcement not found'); - } - - try { - // Check if repository has any commits - const git = simpleGit(repoPath); - let hasCommits = false; - // Use same default branch logic as repo-manager (master, or from env) - let defaultBranch = process.env.DEFAULT_BRANCH || 'master'; - - try { - const commitCount = await git.raw(['rev-list', '--count', '--all']); - hasCommits = parseInt(commitCount.trim(), 10) > 0; - } catch { - // If we can't check, assume no commits - hasCommits = false; - } - - // If repository has commits, get the default branch - if (hasCommits) { - try { - defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo); - } catch { - // Fallback to default if getDefaultBranch fails - defaultBranch = process.env.DEFAULT_BRANCH || 'master'; - } - } - - // Get worktree for the default branch (worktree manager will create branch if needed) - logger.info({ npub: context.npub, repo: context.repo, branch: defaultBranch, hasCommits }, 'Getting worktree for announcement commit'); - const worktreePath = await fileManager.getWorktree(repoPath, defaultBranch, context.npub, context.repo); - - // Check if announcement already exists - const hasAnnouncement = await announcementManager.hasAnnouncementInRepo(worktreePath, announcement.id); - - if (hasAnnouncement) { - // Announcement already exists, but we'll update it anyway to ensure it's the latest - logger.debug({ npub: context.npub, repo: context.repo, eventId: announcement.id }, 'Announcement already exists, updating anyway'); - } - - // Save announcement to worktree - const saved = await announcementManager.saveRepoEventToWorktree(worktreePath, announcement, 'announcement', false); - - if (!saved) { - return error(500, 'Failed to save announcement to repository'); - } - - // Stage the file - const workGit = simpleGit(worktreePath); - await workGit.add('nostr/repo-events.jsonl'); - - // Get author info - let authorName = await fetchUserName(userPubkeyHex, requestContext.userPubkey || '', DEFAULT_NOSTR_RELAYS); - let authorEmail = await fetchUserEmail(userPubkeyHex, requestContext.userPubkey || '', DEFAULT_NOSTR_RELAYS); - - if (!authorName) { - const { nip19 } = await import('nostr-tools'); - const npub = requestContext.userPubkey || nip19.npubEncode(userPubkeyHex); - authorName = npub.substring(0, 20); - } - if (!authorEmail) { - const { nip19 } = await import('nostr-tools'); - const npub = requestContext.userPubkey || nip19.npubEncode(userPubkeyHex); - authorEmail = `${npub.substring(0, 20)}@gitrepublic.web`; - } - - // Commit the announcement - const commitMessage = `Verify repository ownership by committing repo announcement event\n\nEvent ID: ${announcement.id}`; - - // For empty repositories, ensure the branch is set up in the worktree - if (!hasCommits) { - try { - // Check if branch exists in worktree - const currentBranch = await workGit.revparse(['--abbrev-ref', 'HEAD']).catch(() => null); - if (!currentBranch || currentBranch === 'HEAD') { - // Branch doesn't exist, create orphan branch in worktree - logger.debug({ npub: context.npub, repo: context.repo, branch: defaultBranch }, 'Creating orphan branch in worktree'); - await workGit.raw(['checkout', '--orphan', defaultBranch]); - } else if (currentBranch !== defaultBranch) { - // Switch to the correct branch - logger.debug({ npub: context.npub, repo: context.repo, currentBranch, targetBranch: defaultBranch }, 'Switching to target branch in worktree'); - await workGit.checkout(defaultBranch); - } - } catch (branchErr) { - logger.warn({ error: branchErr, npub: context.npub, repo: context.repo, branch: defaultBranch }, 'Branch setup in worktree failed, attempting commit anyway'); - } - } - - logger.info({ npub: context.npub, repo: context.repo, branch: defaultBranch, hasCommits }, 'Committing announcement file'); - await workGit.commit(commitMessage, ['nostr/repo-events.jsonl'], { - '--author': `${authorName} <${authorEmail}>` - }); - - // Verify commit was created - const commitHash = await workGit.revparse(['HEAD']).catch(() => null); - if (!commitHash) { - throw new Error('Commit was created but HEAD is not pointing to a valid commit'); - } - logger.info({ npub: context.npub, repo: context.repo, commitHash, branch: defaultBranch }, 'Announcement committed successfully'); - - // Push to default branch (if there's a remote) - try { - await workGit.push('origin', defaultBranch); - } catch (pushErr) { - // Push might fail if there's no remote, that's okay - logger.debug({ error: pushErr, npub: context.npub, repo: context.repo }, 'Push failed (may not have remote)'); - } - - // Clean up worktree - await fileManager.removeWorktree(repoPath, worktreePath); - - return json({ - success: true, - message: 'Repository announcement committed successfully. Verification should update shortly.', - announcementId: announcement.id - }); - } catch (err) { - logger.error({ error: err, npub: context.npub, repo: context.repo }, 'Failed to commit announcement for verification'); - return handleApiError(err, { operation: 'verifyRepoCommit', npub: context.npub, repo: context.repo }, 'Failed to commit announcement'); - } - }, - { operation: 'verifyRepoCommit', requireRepoExists: true, requireRepoAccess: true } -); diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 4321c29..c20699f 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -407,7 +407,7 @@ const loadCloneUrlReachability = (forceRefresh = false) => loadCloneUrlReachabilityService(forceRefresh, state, repoCloneUrls); const loadForkInfo = async () => { try { - const response = await fetch(`/api/repos/${state.npub}/${state.repo}/fork`, { headers: buildApiHeaders() }); + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/forks`, { headers: buildApiHeaders() }); if (response.ok) state.fork.info = await response.json(); } catch (err) { console.error('Error loading fork info:', err); @@ -1603,9 +1603,9 @@ // Pre-fill download URL with full URL if (typeof window !== 'undefined') { const origin = window.location.origin; - state.forms.release.downloadUrl = `${origin}/api/repos/${state.npub}/${state.repo}/download?ref=${encodeURIComponent(tagName)}&format=zip`; + state.forms.release.downloadUrl = `${origin}/api/repos/${state.npub}/${state.repo}/archive?ref=${encodeURIComponent(tagName)}&format=zip`; } else { - state.forms.release.downloadUrl = `/api/repos/${state.npub}/${state.repo}/download?ref=${encodeURIComponent(tagName)}&format=zip`; + state.forms.release.downloadUrl = `/api/repos/${state.npub}/${state.repo}/archive?ref=${encodeURIComponent(tagName)}&format=zip`; } state.openDialog = 'createRelease'; }} @@ -1704,11 +1704,10 @@ } try { - const response = await fetch(`/api/repos/${state.npub}/${state.repo}/prs`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/pull-requests/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - prId: id, prAuthor: pr.author, status }) diff --git a/src/routes/repos/[npub]/[repo]/components/DocsTab.svelte b/src/routes/repos/[npub]/[repo]/components/DocsTab.svelte index c083915..1f9e7fd 100644 --- a/src/routes/repos/[npub]/[repo]/components/DocsTab.svelte +++ b/src/routes/repos/[npub]/[repo]/components/DocsTab.svelte @@ -98,7 +98,7 @@ // Now check for docs folder in the background try { - const response = await fetch(`/api/repos/${npub}/${repo}/tree?ref=${currentBranch || 'HEAD'}&path=docs`); + const response = await fetch(`/api/repos/${npub}/${repo}/files?action=tree&ref=${currentBranch || 'HEAD'}&path=docs`); if (response.ok) { const data = await response.json(); const docsFiles = Array.isArray(data) ? data : (data.files || []); @@ -141,7 +141,7 @@ documentationTitle = null; indexEvent = null; - const response = await fetch(`/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(path)}&ref=${currentBranch || 'HEAD'}`); + const response = await fetch(`/api/repos/${npub}/${repo}/files?path=${encodeURIComponent(path)}&format=raw&ref=${currentBranch || 'HEAD'}`); if (response.ok) { const content = await response.text(); documentationContent = content; diff --git a/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte b/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte index 0139b17..05bf187 100644 --- a/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte +++ b/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte @@ -54,7 +54,7 @@ imagePath = normalizedPath.join('/'); // Build API URL - const apiUrl = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(branch)}`; + const apiUrl = `/api/repos/${npub}/${repo}/files?path=${encodeURIComponent(imagePath)}&format=raw&ref=${encodeURIComponent(branch)}`; return ``; }); diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte index 34c32f7..6266e25 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte @@ -27,7 +27,7 @@