20 changed files with 1125 additions and 872 deletions
@ -0,0 +1,158 @@ |
|||||||
|
/** |
||||||
|
* Service Registry |
||||||
|
* Provides singleton instances of commonly used services |
||||||
|
* Reduces memory usage and ensures consistent service configuration across API routes |
||||||
|
*/ |
||||||
|
|
||||||
|
import { FileManager } from './git/file-manager.js'; |
||||||
|
import { RepoManager } from './git/repo-manager.js'; |
||||||
|
import { MaintainerService } from './nostr/maintainer-service.js'; |
||||||
|
import { NostrClient } from './nostr/nostr-client.js'; |
||||||
|
import { OwnershipTransferService } from './nostr/ownership-transfer-service.js'; |
||||||
|
import { BranchProtectionService } from './nostr/branch-protection-service.js'; |
||||||
|
import { IssuesService } from './nostr/issues-service.js'; |
||||||
|
import { ForkCountService } from './nostr/fork-count-service.js'; |
||||||
|
import { PRsService } from './nostr/prs-service.js'; |
||||||
|
import { HighlightsService } from './nostr/highlights-service.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '../config.js'; |
||||||
|
|
||||||
|
// Get repo root from environment or use default
|
||||||
|
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT |
||||||
|
? process.env.GIT_REPO_ROOT |
||||||
|
: '/repos'; |
||||||
|
|
||||||
|
// Lazy initialization - services are created on first access
|
||||||
|
let _fileManager: FileManager | null = null; |
||||||
|
let _repoManager: RepoManager | null = null; |
||||||
|
let _maintainerService: MaintainerService | null = null; |
||||||
|
let _nostrClient: NostrClient | null = null; |
||||||
|
let _nostrSearchClient: NostrClient | null = null; |
||||||
|
let _ownershipTransferService: OwnershipTransferService | null = null; |
||||||
|
let _branchProtectionService: BranchProtectionService | null = null; |
||||||
|
let _issuesService: IssuesService | null = null; |
||||||
|
let _forkCountService: ForkCountService | null = null; |
||||||
|
let _prsService: PRsService | null = null; |
||||||
|
let _highlightsService: HighlightsService | null = null; |
||||||
|
|
||||||
|
/** |
||||||
|
* Get singleton FileManager instance |
||||||
|
*/ |
||||||
|
export function getFileManager(): FileManager { |
||||||
|
if (!_fileManager) { |
||||||
|
_fileManager = new FileManager(repoRoot); |
||||||
|
} |
||||||
|
return _fileManager; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get singleton RepoManager instance |
||||||
|
*/ |
||||||
|
export function getRepoManager(): RepoManager { |
||||||
|
if (!_repoManager) { |
||||||
|
_repoManager = new RepoManager(repoRoot); |
||||||
|
} |
||||||
|
return _repoManager; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get singleton MaintainerService instance |
||||||
|
*/ |
||||||
|
export function getMaintainerService(): MaintainerService { |
||||||
|
if (!_maintainerService) { |
||||||
|
_maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); |
||||||
|
} |
||||||
|
return _maintainerService; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get singleton NostrClient instance (default relays) |
||||||
|
*/ |
||||||
|
export function getNostrClient(): NostrClient { |
||||||
|
if (!_nostrClient) { |
||||||
|
_nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
} |
||||||
|
return _nostrClient; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get singleton NostrClient instance (search relays) |
||||||
|
*/ |
||||||
|
export function getNostrSearchClient(): NostrClient { |
||||||
|
if (!_nostrSearchClient) { |
||||||
|
_nostrSearchClient = new NostrClient(DEFAULT_NOSTR_SEARCH_RELAYS); |
||||||
|
} |
||||||
|
return _nostrSearchClient; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get singleton OwnershipTransferService instance |
||||||
|
*/ |
||||||
|
export function getOwnershipTransferService(): OwnershipTransferService { |
||||||
|
if (!_ownershipTransferService) { |
||||||
|
_ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); |
||||||
|
} |
||||||
|
return _ownershipTransferService; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get singleton BranchProtectionService instance |
||||||
|
*/ |
||||||
|
export function getBranchProtectionService(): BranchProtectionService { |
||||||
|
if (!_branchProtectionService) { |
||||||
|
_branchProtectionService = new BranchProtectionService(DEFAULT_NOSTR_RELAYS); |
||||||
|
} |
||||||
|
return _branchProtectionService; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get singleton IssuesService instance |
||||||
|
*/ |
||||||
|
export function getIssuesService(): IssuesService { |
||||||
|
if (!_issuesService) { |
||||||
|
_issuesService = new IssuesService(DEFAULT_NOSTR_RELAYS); |
||||||
|
} |
||||||
|
return _issuesService; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get singleton ForkCountService instance |
||||||
|
*/ |
||||||
|
export function getForkCountService(): ForkCountService { |
||||||
|
if (!_forkCountService) { |
||||||
|
_forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS); |
||||||
|
} |
||||||
|
return _forkCountService; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get singleton PRsService instance |
||||||
|
*/ |
||||||
|
export function getPRsService(): PRsService { |
||||||
|
if (!_prsService) { |
||||||
|
_prsService = new PRsService(DEFAULT_NOSTR_RELAYS); |
||||||
|
} |
||||||
|
return _prsService; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get singleton HighlightsService instance |
||||||
|
*/ |
||||||
|
export function getHighlightsService(): HighlightsService { |
||||||
|
if (!_highlightsService) { |
||||||
|
_highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); |
||||||
|
} |
||||||
|
return _highlightsService; |
||||||
|
} |
||||||
|
|
||||||
|
// Convenience exports for direct access (common pattern)
|
||||||
|
export const fileManager = getFileManager(); |
||||||
|
export const repoManager = getRepoManager(); |
||||||
|
export const maintainerService = getMaintainerService(); |
||||||
|
export const nostrClient = getNostrClient(); |
||||||
|
export const nostrSearchClient = getNostrSearchClient(); |
||||||
|
export const ownershipTransferService = getOwnershipTransferService(); |
||||||
|
export const branchProtectionService = getBranchProtectionService(); |
||||||
|
export const issuesService = getIssuesService(); |
||||||
|
export const forkCountService = getForkCountService(); |
||||||
|
export const prsService = getPRsService(); |
||||||
|
export const highlightsService = getHighlightsService(); |
||||||
@ -0,0 +1,151 @@ |
|||||||
|
/** |
||||||
|
* API Authorization Helpers |
||||||
|
* Reusable authorization functions for API routes |
||||||
|
*/ |
||||||
|
|
||||||
|
import { error } from '@sveltejs/kit'; |
||||||
|
import type { NostrEvent } from '../types/nostr.js'; |
||||||
|
import { verifyNIP98Auth } from '../services/nostr/nip98-auth.js'; |
||||||
|
import { maintainerService } from '../services/service-registry.js'; |
||||||
|
import { fileManager } from '../services/service-registry.js'; |
||||||
|
import type { RepoContext, RequestContext, RepoRequestContext } from './api-context.js'; |
||||||
|
import { handleValidationError, handleAuthError, handleAuthorizationError, handleNotFoundError } from './error-handler.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if user has access to a repository (privacy check) |
||||||
|
*
|
||||||
|
* @param repoContext - Repository context |
||||||
|
* @param requestContext - Request context with user pubkey |
||||||
|
* @param operation - Operation name for error context |
||||||
|
* @returns void if access allowed, throws error if denied |
||||||
|
*/ |
||||||
|
export async function requireRepoAccess( |
||||||
|
repoContext: RepoContext, |
||||||
|
requestContext: RequestContext, |
||||||
|
operation?: string |
||||||
|
): Promise<void> { |
||||||
|
const canView = await maintainerService.canView( |
||||||
|
requestContext.userPubkeyHex || null, |
||||||
|
repoContext.repoOwnerPubkey, |
||||||
|
repoContext.repo |
||||||
|
); |
||||||
|
|
||||||
|
if (!canView) { |
||||||
|
throw handleAuthorizationError( |
||||||
|
'This repository is private. Only owners and maintainers can view it.', |
||||||
|
{ operation, npub: repoContext.npub, repo: repoContext.repo } |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if repository exists |
||||||
|
*
|
||||||
|
* @param repoContext - Repository context |
||||||
|
* @param operation - Operation name for error context |
||||||
|
* @returns void if exists, throws error if not found |
||||||
|
*/ |
||||||
|
export function requireRepoExists( |
||||||
|
repoContext: RepoContext, |
||||||
|
operation?: string |
||||||
|
): void { |
||||||
|
if (!fileManager.repoExists(repoContext.npub, repoContext.repo)) { |
||||||
|
throw handleNotFoundError( |
||||||
|
'Repository not found', |
||||||
|
{ operation, npub: repoContext.npub, repo: repoContext.repo } |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if user is a maintainer of the repository |
||||||
|
*
|
||||||
|
* @param repoContext - Repository context |
||||||
|
* @param requestContext - Request context with user pubkey |
||||||
|
* @param operation - Operation name for error context |
||||||
|
* @returns void if maintainer, throws error if not |
||||||
|
*/ |
||||||
|
export async function requireMaintainer( |
||||||
|
repoContext: RepoContext, |
||||||
|
requestContext: RequestContext, |
||||||
|
operation?: string |
||||||
|
): Promise<void> { |
||||||
|
if (!requestContext.userPubkeyHex) { |
||||||
|
throw handleAuthError( |
||||||
|
'Authentication required. Please provide userPubkey.', |
||||||
|
{ operation, npub: repoContext.npub, repo: repoContext.repo } |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const isMaintainer = await maintainerService.isMaintainer( |
||||||
|
requestContext.userPubkeyHex, |
||||||
|
repoContext.repoOwnerPubkey, |
||||||
|
repoContext.repo |
||||||
|
); |
||||||
|
|
||||||
|
if (!isMaintainer) { |
||||||
|
throw handleAuthorizationError( |
||||||
|
'Only repository maintainers can perform this action. Please submit a pull request instead.', |
||||||
|
{ operation, npub: repoContext.npub, repo: repoContext.repo } |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Verify NIP-98 authentication from request headers |
||||||
|
*
|
||||||
|
* @param request - Request object |
||||||
|
* @param url - URL object |
||||||
|
* @param method - HTTP method |
||||||
|
* @param operation - Operation name for error context |
||||||
|
* @returns NIP-98 event if valid, throws error if invalid |
||||||
|
*/ |
||||||
|
export function requireNIP98Auth( |
||||||
|
request: Request, |
||||||
|
url: URL, |
||||||
|
method: string, |
||||||
|
operation?: string |
||||||
|
): NostrEvent { |
||||||
|
const authHeader = request.headers.get('Authorization'); |
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Nostr ')) { |
||||||
|
throw handleAuthError( |
||||||
|
'NIP-98 authentication required', |
||||||
|
{ operation } |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// Build absolute request URL for NIP-98 validation
|
||||||
|
const protocol = request.headers.get('x-forwarded-proto') ||
|
||||||
|
(url.protocol === 'https:' ? 'https' : 'http'); |
||||||
|
const host = request.headers.get('host') || url.host; |
||||||
|
const requestUrl = `${protocol}://${host}${url.pathname}${url.search}`; |
||||||
|
|
||||||
|
const authResult = verifyNIP98Auth(authHeader, requestUrl, method); |
||||||
|
|
||||||
|
if (!authResult.valid || !authResult.event) { |
||||||
|
throw handleAuthError( |
||||||
|
authResult.error || 'Invalid NIP-98 authentication', |
||||||
|
{ operation } |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return authResult.event; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Combined check: repository exists and user has access |
||||||
|
*
|
||||||
|
* @param repoContext - Repository context |
||||||
|
* @param requestContext - Request context with user pubkey |
||||||
|
* @param operation - Operation name for error context |
||||||
|
* @returns void if all checks pass, throws error if any fail |
||||||
|
*/ |
||||||
|
export async function requireRepoAccessWithExists( |
||||||
|
repoContext: RepoContext, |
||||||
|
requestContext: RequestContext, |
||||||
|
operation?: string |
||||||
|
): Promise<void> { |
||||||
|
requireRepoExists(repoContext, operation); |
||||||
|
await requireRepoAccess(repoContext, requestContext, operation); |
||||||
|
} |
||||||
@ -0,0 +1,162 @@ |
|||||||
|
/** |
||||||
|
* API Context Utilities |
||||||
|
* Extract and validate common request context from SvelteKit requests |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit'; |
||||||
|
import { requireNpubHex, decodeNpubToHex } from './npub-utils.js'; |
||||||
|
|
||||||
|
// Re-export RequestEvent for convenience
|
||||||
|
export type { RequestEvent }; |
||||||
|
|
||||||
|
/** |
||||||
|
* Extracted request context |
||||||
|
*/ |
||||||
|
export interface RequestContext { |
||||||
|
userPubkey: string | null; |
||||||
|
userPubkeyHex: string | null; |
||||||
|
clientIp: string; |
||||||
|
ref?: string; |
||||||
|
path?: string; |
||||||
|
branch?: string; |
||||||
|
limit?: number; |
||||||
|
[key: string]: unknown; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Repository context with validated parameters |
||||||
|
*/ |
||||||
|
export interface RepoContext { |
||||||
|
npub: string; |
||||||
|
repo: string; |
||||||
|
repoOwnerPubkey: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Combined context for repository operations |
||||||
|
*/ |
||||||
|
export interface RepoRequestContext extends RequestContext, RepoContext {} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract common request context from a SvelteKit request |
||||||
|
*
|
||||||
|
* @param event - SvelteKit request event |
||||||
|
* @param url - URL object (can be extracted from event.url) |
||||||
|
* @returns Extracted request context |
||||||
|
*/ |
||||||
|
export function extractRequestContext( |
||||||
|
event: RequestEvent, |
||||||
|
url?: URL |
||||||
|
): RequestContext { |
||||||
|
const requestUrl = url || event.url; |
||||||
|
|
||||||
|
// Extract user pubkey from query params or headers
|
||||||
|
const userPubkey = requestUrl.searchParams.get('userPubkey') ||
|
||||||
|
event.request.headers.get('x-user-pubkey') ||
|
||||||
|
null; |
||||||
|
|
||||||
|
// Convert to hex if needed
|
||||||
|
const userPubkeyHex = userPubkey ? (decodeNpubToHex(userPubkey) || userPubkey) : null; |
||||||
|
|
||||||
|
// Extract client IP
|
||||||
|
let clientIp: string; |
||||||
|
try { |
||||||
|
clientIp = event.getClientAddress(); |
||||||
|
} catch { |
||||||
|
// Fallback for internal Vite dev server requests or when client address can't be determined
|
||||||
|
clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||||
|
event.request.headers.get('x-real-ip') ||
|
||||||
|
'127.0.0.1'; |
||||||
|
} |
||||||
|
|
||||||
|
// Extract common query parameters
|
||||||
|
const ref = requestUrl.searchParams.get('ref') || undefined; |
||||||
|
const path = requestUrl.searchParams.get('path') || undefined; |
||||||
|
const branch = requestUrl.searchParams.get('branch') || undefined; |
||||||
|
const limit = requestUrl.searchParams.get('limit')
|
||||||
|
? parseInt(requestUrl.searchParams.get('limit')!, 10)
|
||||||
|
: undefined; |
||||||
|
|
||||||
|
return { |
||||||
|
userPubkey, |
||||||
|
userPubkeyHex, |
||||||
|
clientIp, |
||||||
|
ref, |
||||||
|
path, |
||||||
|
branch, |
||||||
|
limit |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate and extract repository context from route parameters |
||||||
|
*
|
||||||
|
* @param params - Route parameters (from SvelteKit) |
||||||
|
* @returns Validated repository context |
||||||
|
* @throws Error if validation fails |
||||||
|
*/ |
||||||
|
export function validateRepoParams(params: { npub?: string; repo?: string }): RepoContext { |
||||||
|
const { npub, repo } = params; |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
throw new Error('Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
// Validate and convert npub to pubkey
|
||||||
|
let repoOwnerPubkey: string; |
||||||
|
try { |
||||||
|
repoOwnerPubkey = requireNpubHex(npub); |
||||||
|
} catch { |
||||||
|
throw new Error(`Invalid npub format: ${npub}`); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
npub, |
||||||
|
repo, |
||||||
|
repoOwnerPubkey |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get combined repository and request context |
||||||
|
* Combines parameter validation with request context extraction |
||||||
|
*
|
||||||
|
* @param event - SvelteKit request event |
||||||
|
* @param params - Route parameters |
||||||
|
* @returns Combined repository and request context |
||||||
|
*/ |
||||||
|
export function getRepoContext( |
||||||
|
event: RequestEvent, |
||||||
|
params: { npub?: string; repo?: string } |
||||||
|
): RepoRequestContext { |
||||||
|
const requestContext = extractRequestContext(event); |
||||||
|
const repoContext = validateRepoParams(params); |
||||||
|
|
||||||
|
return { |
||||||
|
...requestContext, |
||||||
|
...repoContext |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract user pubkey from request (convenience function) |
||||||
|
*/ |
||||||
|
export function getUserPubkey(event: RequestEvent): string | null { |
||||||
|
const url = event.url; |
||||||
|
return url.searchParams.get('userPubkey') ||
|
||||||
|
event.request.headers.get('x-user-pubkey') ||
|
||||||
|
null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract client IP from request (convenience function) |
||||||
|
*/ |
||||||
|
export function getClientIp(event: RequestEvent): string { |
||||||
|
try { |
||||||
|
return event.getClientAddress(); |
||||||
|
} catch { |
||||||
|
return event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||||
|
event.request.headers.get('x-real-ip') ||
|
||||||
|
'127.0.0.1'; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,289 @@ |
|||||||
|
/** |
||||||
|
* API Handler Wrappers |
||||||
|
* Higher-order functions to wrap SvelteKit request handlers with common logic |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { RequestHandler } from '@sveltejs/kit'; |
||||||
|
import type { RequestEvent } from '@sveltejs/kit'; |
||||||
|
import { handleApiError, handleValidationError, type ErrorContext } from './error-handler.js'; |
||||||
|
import { getRepoContext, extractRequestContext, validateRepoParams, type RepoContext, type RequestContext, type RepoRequestContext } from './api-context.js'; |
||||||
|
import { requireRepoAccess, requireRepoExists, requireMaintainer, requireNIP98Auth, requireRepoAccessWithExists } from './api-auth.js'; |
||||||
|
import { auditLogger } from '../services/security/audit-logger.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Handler function that receives validated context |
||||||
|
*/ |
||||||
|
export type RepoHandler = (context: { |
||||||
|
repoContext: RepoContext; |
||||||
|
requestContext: RequestContext; |
||||||
|
event: RequestEvent; |
||||||
|
}) => Promise<Response>; |
||||||
|
|
||||||
|
/** |
||||||
|
* Handler function with full repo request context |
||||||
|
*/ |
||||||
|
export type RepoRequestHandler = (context: { |
||||||
|
repoRequestContext: RepoRequestContext; |
||||||
|
event: RequestEvent; |
||||||
|
}) => Promise<Response>; |
||||||
|
|
||||||
|
/** |
||||||
|
* Options for handler wrappers |
||||||
|
*/ |
||||||
|
export interface HandlerOptions { |
||||||
|
operation?: string; |
||||||
|
requireAuth?: boolean; |
||||||
|
requireMaintainer?: boolean; |
||||||
|
requireNIP98?: boolean; |
||||||
|
requireRepoExists?: boolean; |
||||||
|
requireRepoAccess?: boolean; |
||||||
|
auditLog?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Wrap a handler with repository parameter validation |
||||||
|
* Validates npub/repo params and converts npub to pubkey |
||||||
|
*
|
||||||
|
* @param handler - Handler function that receives validated context |
||||||
|
* @param options - Handler options |
||||||
|
* @returns Wrapped SvelteKit RequestHandler |
||||||
|
*/ |
||||||
|
export function withRepoValidation( |
||||||
|
handler: RepoHandler, |
||||||
|
options: HandlerOptions = {} |
||||||
|
): RequestHandler { |
||||||
|
return async (event) => { |
||||||
|
const { params } = event; |
||||||
|
const operation = options.operation || 'unknown'; |
||||||
|
|
||||||
|
try { |
||||||
|
// Validate repo parameters
|
||||||
|
const repoContext = validateRepoParams(params); |
||||||
|
|
||||||
|
// Extract request context
|
||||||
|
const requestContext = extractRequestContext(event); |
||||||
|
|
||||||
|
// Check if repo exists (if required)
|
||||||
|
if (options.requireRepoExists !== false) { |
||||||
|
requireRepoExists(repoContext, operation); |
||||||
|
} |
||||||
|
|
||||||
|
// Check repository access (if required)
|
||||||
|
if (options.requireRepoAccess !== false) { |
||||||
|
await requireRepoAccess(repoContext, requestContext, operation); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if user is maintainer (if required)
|
||||||
|
if (options.requireMaintainer) { |
||||||
|
await requireMaintainer(repoContext, requestContext, operation); |
||||||
|
} |
||||||
|
|
||||||
|
// Check NIP-98 auth (if required)
|
||||||
|
if (options.requireNIP98) { |
||||||
|
requireNIP98Auth(event.request, event.url, event.request.method, operation); |
||||||
|
} |
||||||
|
|
||||||
|
// Audit logging (if enabled)
|
||||||
|
if (options.auditLog) { |
||||||
|
auditLogger.log({ |
||||||
|
user: requestContext.userPubkeyHex || undefined, |
||||||
|
ip: requestContext.clientIp, |
||||||
|
action: `api.${operation}`, |
||||||
|
resource: `${repoContext.npub}/${repoContext.repo}`, |
||||||
|
result: 'success' |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Call the handler with validated context
|
||||||
|
return await handler({ |
||||||
|
repoContext, |
||||||
|
requestContext, |
||||||
|
event |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
// If it's already a Response (from error handlers), return it
|
||||||
|
if (err instanceof Response) { |
||||||
|
return err; |
||||||
|
} |
||||||
|
|
||||||
|
// If it's a SvelteKit HttpError (from error() function), re-throw it
|
||||||
|
// SvelteKit errors have a status property and body property
|
||||||
|
if (err && typeof err === 'object' && 'status' in err && 'body' in err) { |
||||||
|
throw err; |
||||||
|
} |
||||||
|
|
||||||
|
// Otherwise, wrap in standard error handler
|
||||||
|
return handleApiError( |
||||||
|
err, |
||||||
|
{ operation, npub: params.npub, repo: params.repo }, |
||||||
|
`Failed to process ${operation}` |
||||||
|
); |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a simple GET handler for repository operations |
||||||
|
* Automatically handles validation, access checks, and error handling |
||||||
|
*
|
||||||
|
* @param handler - Handler function that receives repo request context |
||||||
|
* @param options - Handler options |
||||||
|
* @returns GET RequestHandler |
||||||
|
*/ |
||||||
|
export function createRepoGetHandler( |
||||||
|
handler: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>, |
||||||
|
options: HandlerOptions = {} |
||||||
|
): RequestHandler { |
||||||
|
return withRepoValidation(async ({ repoContext, requestContext, event }) => { |
||||||
|
const repoRequestContext: RepoRequestContext = { |
||||||
|
...repoContext, |
||||||
|
...requestContext |
||||||
|
}; |
||||||
|
|
||||||
|
return await handler(repoRequestContext, event); |
||||||
|
}, { |
||||||
|
requireRepoExists: true, |
||||||
|
requireRepoAccess: true, |
||||||
|
...options |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a POST handler with maintainer requirement |
||||||
|
*
|
||||||
|
* @param handler - Handler function that receives repo request context |
||||||
|
* @param options - Handler options |
||||||
|
* @returns POST RequestHandler |
||||||
|
*/ |
||||||
|
export function createRepoPostHandler( |
||||||
|
handler: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>, |
||||||
|
options: HandlerOptions = {} |
||||||
|
): RequestHandler { |
||||||
|
return withRepoValidation(async ({ repoContext, requestContext, event }) => { |
||||||
|
const repoRequestContext: RepoRequestContext = { |
||||||
|
...repoContext, |
||||||
|
...requestContext |
||||||
|
}; |
||||||
|
|
||||||
|
return await handler(repoRequestContext, event); |
||||||
|
}, { |
||||||
|
requireRepoExists: true, |
||||||
|
requireMaintainer: true, |
||||||
|
...options |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Wrap handler with audit logging |
||||||
|
*
|
||||||
|
* @param handler - Handler function |
||||||
|
* @param operation - Operation name for audit log |
||||||
|
* @returns Wrapped handler with audit logging |
||||||
|
*/ |
||||||
|
export function withAuditLogging( |
||||||
|
handler: RequestHandler, |
||||||
|
operation: string |
||||||
|
): RequestHandler { |
||||||
|
return async (event) => { |
||||||
|
const { params } = event; |
||||||
|
const requestContext = extractRequestContext(event); |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await handler(event); |
||||||
|
|
||||||
|
// Log successful operation
|
||||||
|
if (params.npub && params.repo) { |
||||||
|
auditLogger.log({ |
||||||
|
user: requestContext.userPubkeyHex || undefined, |
||||||
|
ip: requestContext.clientIp, |
||||||
|
action: `api.${operation}`, |
||||||
|
resource: `${params.npub}/${params.repo}`, |
||||||
|
result: 'success', |
||||||
|
metadata: { status: response.status } |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return response; |
||||||
|
} catch (err) { |
||||||
|
// Log failed operation
|
||||||
|
if (params.npub && params.repo) { |
||||||
|
auditLogger.log({ |
||||||
|
user: requestContext.userPubkeyHex || undefined, |
||||||
|
ip: requestContext.clientIp, |
||||||
|
action: `api.${operation}`, |
||||||
|
resource: `${params.npub}/${params.repo}`, |
||||||
|
result: 'failure', |
||||||
|
error: err instanceof Error ? err.message : String(err) |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
throw err; |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a handler factory for common repository API patterns |
||||||
|
*
|
||||||
|
* @param config - Handler configuration |
||||||
|
* @returns RequestHandler factory function |
||||||
|
*/ |
||||||
|
export function createRepoHandler(config: { |
||||||
|
get?: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>; |
||||||
|
post?: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>; |
||||||
|
put?: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>; |
||||||
|
delete?: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>; |
||||||
|
options?: HandlerOptions; |
||||||
|
}): { |
||||||
|
GET?: RequestHandler; |
||||||
|
POST?: RequestHandler; |
||||||
|
PUT?: RequestHandler; |
||||||
|
DELETE?: RequestHandler; |
||||||
|
} { |
||||||
|
const handlerOptions = config.options || {}; |
||||||
|
|
||||||
|
const result: { |
||||||
|
GET?: RequestHandler; |
||||||
|
POST?: RequestHandler; |
||||||
|
PUT?: RequestHandler; |
||||||
|
DELETE?: RequestHandler; |
||||||
|
} = {}; |
||||||
|
|
||||||
|
if (config.get) { |
||||||
|
result.GET = createRepoGetHandler(config.get, handlerOptions); |
||||||
|
} |
||||||
|
|
||||||
|
if (config.post) { |
||||||
|
result.POST = createRepoPostHandler(config.post, handlerOptions); |
||||||
|
} |
||||||
|
|
||||||
|
if (config.put) { |
||||||
|
result.PUT = withRepoValidation(async ({ repoContext, requestContext, event }) => { |
||||||
|
const repoRequestContext: RepoRequestContext = { |
||||||
|
...repoContext, |
||||||
|
...requestContext |
||||||
|
}; |
||||||
|
return await config.put!(repoRequestContext, event); |
||||||
|
}, { |
||||||
|
requireRepoExists: true, |
||||||
|
requireMaintainer: true, |
||||||
|
...handlerOptions |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
if (config.delete) { |
||||||
|
result.DELETE = withRepoValidation(async ({ repoContext, requestContext, event }) => { |
||||||
|
const repoRequestContext: RepoRequestContext = { |
||||||
|
...repoContext, |
||||||
|
...requestContext |
||||||
|
}; |
||||||
|
return await config.delete!(repoRequestContext, event); |
||||||
|
}, { |
||||||
|
requireRepoExists: true, |
||||||
|
requireMaintainer: true, |
||||||
|
...handlerOptions |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
Loading…
Reference in new issue