diff --git a/app/api/attestations/[attestationId]/route.ts b/app/api/attestations/[attestationId]/route.ts new file mode 100644 index 0000000..17f4767 --- /dev/null +++ b/app/api/attestations/[attestationId]/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + snippetAttestationService, + SnippetAttestationValidationError, +} from "@/lib/snippet-attestation-service"; + +/** + * GET /api/attestations/[attestationId] + * Retrieve a specific snippet attestation record by ID. + */ +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ attestationId: string }> }, +) { + try { + const { attestationId } = await params; + const attestation = + await snippetAttestationService.getAttestationById(attestationId); + + return NextResponse.json({ attestation }, { status: 200 }); + } catch (error) { + if (error instanceof SnippetAttestationValidationError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode }, + ); + } + return NextResponse.json( + { error: "Failed to retrieve attestation." }, + { status: 500 }, + ); + } +} diff --git a/app/api/snippets/[id]/attest/route.ts b/app/api/snippets/[id]/attest/route.ts new file mode 100644 index 0000000..76802e5 --- /dev/null +++ b/app/api/snippets/[id]/attest/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + snippetAttestationService, + SnippetAttestationAuthError, + SnippetAttestationValidationError, +} from "@/lib/snippet-attestation-service"; + +/** + * POST /api/snippets/[id]/attest + * Create a Stellar-backed attestation for the current snippet content. + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + let body: Record = {}; + try { + body = (await req.json()) || {}; + } catch { + return NextResponse.json( + { error: "Request body must be valid JSON." }, + { status: 400 }, + ); + } + + const headerWallet = req.headers.get("x-wallet-address"); + const attestedBy = body.attestedBy || body.walletAddress || headerWallet || ""; + + const attestation = await snippetAttestationService.createAttestation({ + snippetId: id, + code: body.code, + attestedBy, + stellarTransactionHash: body.stellarTransactionHash || body.transactionHash, + network: body.network, + ledger: body.ledger, + clientProvidedHash: body.contentHash, + }); + + return NextResponse.json({ attestation }, { status: 201 }); + } catch (error) { + if (error instanceof SnippetAttestationAuthError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode }, + ); + } + if (error instanceof SnippetAttestationValidationError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode }, + ); + } + return NextResponse.json( + { error: "Failed to create snippet attestation." }, + { status: 500 }, + ); + } +} diff --git a/app/api/snippets/[id]/attestation/route.ts b/app/api/snippets/[id]/attestation/route.ts new file mode 100644 index 0000000..f6b47ab --- /dev/null +++ b/app/api/snippets/[id]/attestation/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + snippetAttestationService, + SnippetAttestationValidationError, +} from "@/lib/snippet-attestation-service"; + +/** + * GET /api/snippets/[id]/attestation + * Retrieve the latest attestation and historical revision attestations for a snippet. + */ +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const result = await snippetAttestationService.getSnippetAttestations(id); + return NextResponse.json(result, { status: 200 }); + } catch (error) { + if (error instanceof SnippetAttestationValidationError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode }, + ); + } + return NextResponse.json( + { error: "Failed to retrieve snippet attestations." }, + { status: 500 }, + ); + } +} diff --git a/app/api/snippets/[id]/verify/route.ts b/app/api/snippets/[id]/verify/route.ts index 92604aa..fbf653c 100644 --- a/app/api/snippets/[id]/verify/route.ts +++ b/app/api/snippets/[id]/verify/route.ts @@ -1,224 +1,97 @@ import { NextRequest, NextResponse } from "next/server"; import { - getSnippetWithHash, - verifySnippetIntegrity, -} from "@/lib/db"; -import { generateSnippetHash } from "@/lib/hash"; -import { StellarRecoveryService } from "@/lib/stellar-recovery.service"; - -const recoveryService = new StellarRecoveryService(); - -function buildIdempotencyKey(snippetId: string, contentHash: string): string { - return `hash:${snippetId.slice(0, 8)}:${contentHash.slice(0, 16)}`; -} + snippetAttestationService, + SnippetAttestationAuthError, + SnippetAttestationValidationError, +} from "@/lib/snippet-attestation-service"; /** - * POST /api/snippets/[id]/verify - * - * Anchors the snippet's creation timestamp + content hash on the Stellar - * blockchain, providing immutable proof-of-existence. - * - * Uses the recovery service for retry-safe processing. If the Stellar - * submission fails with a retryable error, the transaction is queued - * for automatic retry via the cron-based recovery engine. + * GET /api/snippets/[id]/verify + * Verify whether the current snippet content matches the attested hash and on-chain Stellar transaction. */ -export async function POST( +export async function GET( req: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { const { id } = await params; + const url = new URL(req.url); + const attestationId = url.searchParams.get("attestationId") || undefined; + const actorWallet = req.headers.get("x-wallet-address") || null; - const snippet = await getSnippetWithHash(id); - if (!snippet) { - return NextResponse.json({ error: "Snippet not found" }, { status: 404 }); - } - - if (snippet.on_chain_hash) { - return NextResponse.json( - { - error: - "Snippet is already verified on-chain. Timestamps are immutable.", - data: { - snippetId: id, - onChainHash: snippet.on_chain_hash, - transactionHash: snippet.transaction_hash, - verifiedAt: snippet.verified_at, - createdAt: snippet.created_at, - }, - }, - { status: 409 }, - ); - } - - const onChainHash = generateSnippetHash( - snippet.title, - snippet.description || "", - snippet.code, - snippet.language, - snippet.tags || [], - ); - - const createdAt: string = - snippet.created_at instanceof Date - ? snippet.created_at.toISOString() - : String(snippet.created_at); - - console.log("[verify] Anchoring snippet on Stellar:", { + const outcome = await snippetAttestationService.verifySnippetAttestation( id, - onChainHash, - createdAt, - }); - - const idempotencyKey = buildIdempotencyKey(id, onChainHash); - - const existing = await recoveryService.getStatusByKey(idempotencyKey); - if (existing && existing.status === "confirmed" && existing.callback_status === "applied") { - return NextResponse.json({ - success: true, - message: "Snippet already anchored on Stellar blockchain", - data: { - snippetId: id, - onChainHash, - transactionHash: existing.stellar_tx_hash, - verifiedAt: snippet.verified_at || new Date().toISOString(), - createdAt, - }, - }); - } - - if (existing && existing.status === "dead") { - return NextResponse.json( - { - error: "Stellar anchoring failed permanently", - details: existing.last_error, - }, - { status: 502 }, - ); - } - - if (existing && (existing.status === "pending" || existing.status === "submitted" || existing.status === "failed")) { - return NextResponse.json( - { - status: "recovering", - idempotencyKey, - transactionId: existing.id, - stellarTxHash: existing.stellar_tx_hash, - message: "Anchoring transaction is being processed. Poll /transaction-status for updates.", - }, - { status: 202 }, - ); - } - - const record = await recoveryService.submitHashAnchoring({ - idempotencyKey, - snippetId: id, - contentHash: onChainHash, - createdAt, - }); - - if (record.status === "dead") { - return NextResponse.json( - { - error: "Failed to submit to Stellar blockchain", - details: record.last_error, - }, - { status: 502 }, - ); - } + { + attestationId, + actorWallet, + }, + ); - if (record.status !== "confirmed" || record.callback_status !== "applied") { + return NextResponse.json(outcome, { status: 200 }); + } catch (error) { + if (error instanceof SnippetAttestationValidationError) { return NextResponse.json( - { - status: "recovering", - idempotencyKey, - transactionId: record.id, - stellarTxHash: record.stellar_tx_hash, - message: record.stellar_tx_hash - ? "Transaction confirmed. Awaiting database update." - : "Anchoring transaction queued for processing.", - }, - { status: 202 }, + { error: error.message }, + { status: error.statusCode }, ); } - - return NextResponse.json({ - success: true, - message: "Snippet creation timestamp anchored on Stellar blockchain", - data: { - snippetId: id, - onChainHash, - transactionHash: record.stellar_tx_hash, - createdAt, - verifiedAt: new Date().toISOString(), - }, - }); - } catch (error: any) { - console.error("[verify] Error anchoring snippet:", error); - - if (error?.message?.includes("immutable")) { - return NextResponse.json({ error: error.message }, { status: 409 }); - } - return NextResponse.json( - { error: "Failed to anchor snippet on blockchain" }, + { error: "Failed to verify snippet attestation." }, { status: 500 }, ); } } /** - * GET /api/snippets/[id]/verify - * - * Verifies snippet integrity by comparing the current content hash against - * the hash stored on the Stellar blockchain at creation time. + * POST /api/snippets/[id]/verify + * Create or verify a snippet attestation. */ -export async function GET( - _req: NextRequest, +export async function POST( + req: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { const { id } = await params; - - const snippet = await getSnippetWithHash(id); - if (!snippet) { - return NextResponse.json({ error: "Snippet not found" }, { status: 404 }); + let body: Record = {}; + try { + body = (await req.json()) || {}; + } catch { + body = {}; } - if (!snippet.on_chain_hash) { - return NextResponse.json({ - verified: false, - snippetId: id, - message: "This snippet has not been anchored on the blockchain yet.", - onChainHash: null, - transactionHash: null, - verifiedAt: null, - createdAt: snippet.created_at, - }); - } - - const result = await verifySnippetIntegrity( - id, - snippet.title, - snippet.description || "", - snippet.code, - snippet.language, - snippet.tags || [], - ); + const actorWallet = + body.attestedBy || + body.walletAddress || + req.headers.get("x-wallet-address") || + ""; - return NextResponse.json({ - verified: result.isValid, + const attestation = await snippetAttestationService.createAttestation({ snippetId: id, - message: result.message, - onChainHash: snippet.on_chain_hash, - transactionHash: snippet.transaction_hash, - verifiedAt: snippet.verified_at, - createdAt: snippet.created_at, + code: body.code, + attestedBy: actorWallet, + stellarTransactionHash: + body.stellarTransactionHash || body.transactionHash, + network: body.network, + ledger: body.ledger, + clientProvidedHash: body.contentHash, }); + + return NextResponse.json({ attestation }, { status: 201 }); } catch (error) { - console.error("[verify] Error verifying snippet:", error); + if (error instanceof SnippetAttestationAuthError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode }, + ); + } + if (error instanceof SnippetAttestationValidationError) { + return NextResponse.json( + { error: error.message }, + { status: error.statusCode }, + ); + } return NextResponse.json( - { error: "Failed to verify snippet integrity" }, + { error: "Failed to anchor snippet attestation." }, { status: 500 }, ); } diff --git a/lib/__tests__/snippet-attestation-service.test.ts b/lib/__tests__/snippet-attestation-service.test.ts new file mode 100644 index 0000000..f96f2c0 --- /dev/null +++ b/lib/__tests__/snippet-attestation-service.test.ts @@ -0,0 +1,380 @@ +jest.mock("next/server", () => ({ + NextRequest: class MockNextRequest { + public headers: Headers; + private _body: string | null; + public url: string; + constructor( + input: string | URL, + init?: { headers?: Headers; body?: string }, + ) { + this.url = typeof input === "string" ? input : input.toString(); + this.headers = init?.headers ?? new Headers(); + this._body = init?.body ?? null; + } + async json() { + if (this._body === null) throw new Error("no body"); + return JSON.parse(this._body); + } + }, + NextResponse: { + json: (body: unknown, init?: { status?: number }) => ({ + status: init?.status ?? 200, + json: async () => body, + }), + }, +})); + +jest.mock("@/lib/activity-logger", () => ({ + appendActivityLog: jest.fn().mockResolvedValue(undefined), +})); + +import { NextRequest } from "next/server"; +import { + SnippetAttestationService, + SnippetAttestationAuthError, + SnippetAttestationValidationError, + computeDeterministicSnippetHash, + snippetAttestationService, +} from "@/lib/snippet-attestation-service"; +import { POST as attestRoute } from "@/app/api/snippets/[id]/attest/route"; +import { GET as getSnippetAttestationsRoute } from "@/app/api/snippets/[id]/attestation/route"; +import { GET as verifySnippetRoute } from "@/app/api/snippets/[id]/verify/route"; +import { GET as getAttestationByIdRoute } from "@/app/api/attestations/[attestationId]/route"; + +const OWNER_WALLET = "GOWNER" + "A".repeat(50); +const COLLAB_WALLET = "GCOLLAB" + "B".repeat(49); +const STRANGER_WALLET = "GSTRANG" + "C".repeat(49); +const TX_HASH_1 = + "4f8c2e7a9b1d3c5e7f9a0b2c4d6e8f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e"; +const TX_HASH_2 = + "9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b"; + +function makeReq( + url: string, + opts?: { body?: unknown; headers?: Record }, +) { + return new NextRequest(url, { + headers: new Headers(opts?.headers ?? {}), + body: + opts?.body === undefined + ? undefined + : typeof opts.body === "string" + ? opts.body + : JSON.stringify(opts.body), + } as any); +} + +describe("SnippetAttestationService & Attestation API (#218)", () => { + let service: SnippetAttestationService; + + beforeEach(() => { + service = new SnippetAttestationService({ + configuredNetwork: "testnet", + maxNetworkRetries: 2, + }); + snippetAttestationService.clearAll(); + }); + + describe("SHA-256 hash generation & deterministic calculation", () => { + it("computes deterministic SHA-256 hashes and normalizes line endings", () => { + const unixHash = computeDeterministicSnippetHash( + "function greet() {\n return 'hi';\n}", + ); + const crlfHash = computeDeterministicSnippetHash( + "function greet() {\r\n return 'hi';\r\n}", + ); + + expect(unixHash).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(unixHash).toBe(crlfHash); + + const modifiedHash = computeDeterministicSnippetHash( + "function greet() {\n return 'bye';\n}", + ); + expect(modifiedHash).not.toBe(unixHash); + }); + + it("rejects client-provided content hashes that do not match server-computed SHA-256", async () => { + await expect( + service.createAttestation({ + snippetId: "snippet_spoof", + code: "const x = 1;", + attestedBy: OWNER_WALLET, + clientProvidedHash: "sha256:" + "0".repeat(64), + }), + ).rejects.toThrow(SnippetAttestationValidationError); + }); + }); + + describe("Attestation creation, immutability, and verification states", () => { + it("creates an immutable attestation linked to Stellar metadata and verifies unchanged snippet content", async () => { + const code = "export const add = (a: number, b: number) => a + b;"; + const attestation = await service.createAttestation({ + snippetId: "snippet_123", + code, + attestedBy: OWNER_WALLET, + stellarTransactionHash: TX_HASH_1, + network: "testnet", + ledger: 12345678, + }); + + expect(attestation.snippetId).toBe("snippet_123"); + expect(attestation.contentHash).toBe( + computeDeterministicSnippetHash(code), + ); + expect(attestation.stellarTransactionHash).toBe(TX_HASH_1); + expect(attestation.network).toBe("testnet"); + expect(attestation.ledger).toBe(12345678); + expect(attestation.attestedBy).toBe(OWNER_WALLET); + expect(attestation.verificationStatus).toBe("verified"); + expect(Object.isFrozen(attestation)).toBe(true); + + // Verify unchanged snippet content + const outcome = await service.verifySnippetAttestation("snippet_123"); + expect(outcome.status).toBe("verified"); + expect(outcome.verified).toBe(true); + expect(outcome.contentIntegrity).toBe("match"); + expect(outcome.ledger).toBe(12345678); + }); + + it("detects modified snippet content when current code differs from attested hash", async () => { + await service.createAttestation({ + snippetId: "snippet_mod", + code: "const version = 1;", + attestedBy: OWNER_WALLET, + stellarTransactionHash: TX_HASH_1, + }); + + // Modify snippet content in store + service.upsertSnippetContent({ + id: "snippet_mod", + code: "const version = 2; // modified!", + ownerWalletAddress: OWNER_WALLET, + }); + + const outcome = await service.verifySnippetAttestation("snippet_mod"); + expect(outcome.status).toBe("modified"); + expect(outcome.verified).toBe(false); + expect(outcome.contentIntegrity).toBe("mismatch"); + expect(outcome.currentContentHash).not.toBe(outcome.attestedContentHash); + }); + + it("supports multiple historical attestations across revisions and retrieves full history", async () => { + const rev1 = await service.createAttestation({ + snippetId: "snippet_hist", + code: "const v = 1;", + attestedBy: OWNER_WALLET, + stellarTransactionHash: TX_HASH_1, + ledger: 1001, + }); + + const rev2 = await service.createAttestation({ + snippetId: "snippet_hist", + code: "const v = 2;", + attestedBy: OWNER_WALLET, + stellarTransactionHash: TX_HASH_2, + ledger: 1002, + }); + + const { latestAttestation, history } = + await service.getSnippetAttestations("snippet_hist"); + expect(history).toHaveLength(2); + expect(history[0].id).toBe(rev1.id); + expect(history[0].revisionNumber).toBe(1); + expect(history[1].id).toBe(rev2.id); + expect(history[1].revisionNumber).toBe(2); + expect(latestAttestation?.id).toBe(rev2.id); + + // Current content matches rev2 (latest), but is modified relative to rev1 + const verifyLatest = + await service.verifySnippetAttestation("snippet_hist"); + expect(verifyLatest.status).toBe("verified"); + + const verifyOldRev = await service.verifySnippetAttestation( + "snippet_hist", + { attestationId: rev1.id }, + ); + expect(verifyOldRev.status).toBe("modified"); + }); + + it("handles pending, failed, missing, and wrong-network Stellar transactions", async () => { + // 1. Wrong network rejected during creation + await expect( + service.createAttestation({ + snippetId: "snippet_net", + code: "const net = 1;", + attestedBy: OWNER_WALLET, + network: "mainnet", + }), + ).rejects.toThrow(SnippetAttestationValidationError); + + // 2. Pending transaction scenario + const pendingTx = "b".repeat(64); + service.registerOnChainTransaction({ + hash: pendingTx, + network: "testnet", + exists: true, + pending: true, + ledger: null, + }); + + const pendingAtt = await service.createAttestation({ + snippetId: "snippet_pending", + code: "const p = 1;", + attestedBy: OWNER_WALLET, + stellarTransactionHash: pendingTx, + }); + expect(pendingAtt.verificationStatus).toBe("pending"); + + const pendingCheck = + await service.verifySnippetAttestation("snippet_pending"); + expect(pendingCheck.status).toBe("pending"); + expect(pendingCheck.verified).toBe(false); + + // 3. Failed transaction on-chain during verification + service.registerOnChainTransaction({ + hash: pendingTx, + network: "testnet", + exists: true, + successful: false, + pending: false, + ledger: 5000, + }); + const failedCheck = + await service.verifySnippetAttestation("snippet_pending"); + expect(failedCheck.status).toBe("failed"); + + // 4. Missing transaction on-chain -> unverifiable + service.registerOnChainTransaction({ + hash: pendingTx, + network: "testnet", + exists: false, + }); + const missingCheck = + await service.verifySnippetAttestation("snippet_pending"); + expect(missingCheck.status).toBe("unverifiable"); + }); + + it("handles concurrent and duplicate attestation requests idempotently and enforces authorization", async () => { + service.upsertSnippetContent({ + id: "snippet_auth", + code: "const sec = true;", + ownerWalletAddress: OWNER_WALLET, + collaboratorWallets: [COLLAB_WALLET], + }); + + // Unauthorized user rejected + await expect( + service.createAttestation({ + snippetId: "snippet_auth", + attestedBy: STRANGER_WALLET, + stellarTransactionHash: TX_HASH_1, + }), + ).rejects.toThrow(SnippetAttestationAuthError); + + // Concurrent identical attestation requests return the same record without duplicates + const [a1, a2, a3] = await Promise.all([ + service.createAttestation({ + snippetId: "snippet_auth", + attestedBy: COLLAB_WALLET, + stellarTransactionHash: TX_HASH_1, + }), + service.createAttestation({ + snippetId: "snippet_auth", + attestedBy: COLLAB_WALLET, + stellarTransactionHash: TX_HASH_1, + }), + service.createAttestation({ + snippetId: "snippet_auth", + attestedBy: COLLAB_WALLET, + stellarTransactionHash: TX_HASH_1, + }), + ]); + + expect(a1.id).toBe(a2.id); + expect(a2.id).toBe(a3.id); + + const { history } = await service.getSnippetAttestations("snippet_auth"); + expect(history).toHaveLength(1); + }); + + it("recovers verification after temporary Stellar network failures and records audit logs", async () => { + await service.createAttestation({ + snippetId: "snippet_net_rec", + code: "const ok = true;", + attestedBy: OWNER_WALLET, + stellarTransactionHash: TX_HASH_1, + }); + + // Temporary network outage -> unverifiable + service.setNetworkFailureGenerator( + () => new Error("Horizon 503 Unavailable"), + ); + const outageOutcome = + await service.verifySnippetAttestation("snippet_net_rec"); + expect(outageOutcome.status).toBe("unverifiable"); + + // Network recovers -> verified + service.setNetworkFailureGenerator(null); + const recoveredOutcome = + await service.verifySnippetAttestation("snippet_net_rec"); + expect(recoveredOutcome.status).toBe("verified"); + expect(recoveredOutcome.verified).toBe(true); + + const logs = service.getAuditLogs("snippet_net_rec"); + expect(logs.map((l) => l.action)).toEqual([ + "attestation.created", + "attestation.verified", + ]); + }); + }); + + describe("Next.js API Route Handlers", () => { + it("supports POST /api/snippets/[id]/attest, GET /api/snippets/[id]/attestation, GET /api/snippets/[id]/verify, and GET /api/attestations/[attestationId]", async () => { + const createRes = await attestRoute( + makeReq("http://localhost/api/snippets/snippet_route_218/attest", { + body: { + code: "export const magic = 42;", + attestedBy: OWNER_WALLET, + stellarTransactionHash: TX_HASH_1, + network: "testnet", + ledger: 7654321, + }, + }), + { params: Promise.resolve({ id: "snippet_route_218" }) }, + ); + expect(createRes.status).toBe(201); + const { attestation } = await createRes.json(); + expect(attestation.id).toBeDefined(); + + // GET /api/snippets/[id]/attestation + const listRes = await getSnippetAttestationsRoute( + makeReq("http://localhost/api/snippets/snippet_route_218/attestation"), + { params: Promise.resolve({ id: "snippet_route_218" }) }, + ); + expect(listRes.status).toBe(200); + const listBody = await listRes.json(); + expect(listBody.latestAttestation.id).toBe(attestation.id); + expect(listBody.history).toHaveLength(1); + + // GET /api/snippets/[id]/verify + const verifyRes = await verifySnippetRoute( + makeReq("http://localhost/api/snippets/snippet_route_218/verify"), + { params: Promise.resolve({ id: "snippet_route_218" }) }, + ); + expect(verifyRes.status).toBe(200); + const verifyBody = await verifyRes.json(); + expect(verifyBody.status).toBe("verified"); + expect(verifyBody.verified).toBe(true); + expect(verifyBody.contentIntegrity).toBe("match"); + + // GET /api/attestations/[attestationId] + const byIdRes = await getAttestationByIdRoute( + makeReq(`http://localhost/api/attestations/${attestation.id}`), + { params: Promise.resolve({ attestationId: attestation.id }) }, + ); + expect(byIdRes.status).toBe(200); + const byIdBody = await byIdRes.json(); + expect(byIdBody.attestation.id).toBe(attestation.id); + }); + }); +}); diff --git a/lib/snippet-attestation-service.ts b/lib/snippet-attestation-service.ts new file mode 100644 index 0000000..fc33b82 --- /dev/null +++ b/lib/snippet-attestation-service.ts @@ -0,0 +1,849 @@ +import crypto from "crypto"; +import { appendActivityLog } from "@/lib/activity-logger"; + +export type AttestationVerificationStatus = + | "verified" + | "modified" + | "pending" + | "failed" + | "unverifiable"; + +export type StellarAttestationNetwork = "testnet" | "mainnet" | "futurenet"; + +export interface SnippetAttestationRecord { + readonly id: string; + readonly snippetId: string; + readonly revisionNumber: number; + readonly contentHash: string; + readonly stellarTransactionHash: string; + readonly network: StellarAttestationNetwork; + readonly ledger: number | null; + readonly attestedBy: string; + readonly verificationStatus: AttestationVerificationStatus; + readonly createdAt: string; +} + +export interface SnippetContentState { + id: string; + code: string; + title?: string; + language?: string; + ownerWalletAddress: string; + collaboratorWallets?: string[]; + revisionNumber?: number; +} + +export interface OnChainAttestationTx { + hash: string; + network: StellarAttestationNetwork; + exists: boolean; + successful?: boolean; + pending?: boolean; + ledger?: number | null; + contentHash?: string | null; + attestedBy?: string | null; + snippetId?: string | null; +} + +export interface AttestationVerificationOutcome { + snippetId: string; + attestationId: string | null; + status: AttestationVerificationStatus; + verified: boolean; + contentIntegrity: "match" | "mismatch" | "unknown"; + currentContentHash: string; + attestedContentHash: string | null; + stellarTransactionHash: string | null; + network: StellarAttestationNetwork; + ledger: number | null; + attestedBy: string | null; + createdAt: string | null; + reason?: string; +} + +export interface AttestationAuditLog { + id: string; + attestationId: string; + snippetId: string; + actorWallet: string | null; + action: + | "attestation.created" + | "attestation.verified" + | "attestation.mismatch_detected" + | "attestation.failed" + | "attestation.unauthorized"; + status: AttestationVerificationStatus; + contentHash: string; + stellarTransactionHash: string; + reason?: string | null; + timestamp: string; +} + +export class SnippetAttestationAuthError extends Error { + public readonly statusCode: number; + constructor(message: string, statusCode: 401 | 403 = 403) { + super(message); + this.name = "SnippetAttestationAuthError"; + this.statusCode = statusCode; + } +} + +export class SnippetAttestationValidationError extends Error { + public readonly statusCode: number; + constructor(message: string, statusCode = 400) { + super(message); + this.name = "SnippetAttestationValidationError"; + this.statusCode = statusCode; + } +} + +const STELLAR_WALLET_REGEX = /^G[A-Z2-7]{55}$/; +const TX_HASH_REGEX = /^[a-fA-F0-9]{8,64}$|^tx_[a-zA-Z0-9_-]{4,64}$/; + +export function isValidAttesterWallet(wallet?: string | null): boolean { + if (!wallet || typeof wallet !== "string") return false; + return STELLAR_WALLET_REGEX.test(wallet.trim()); +} + +export function isValidAttestationTxHash(hash?: string | null): boolean { + if (!hash || typeof hash !== "string") return false; + return TX_HASH_REGEX.test(hash.trim()); +} + +export function normalizeAttestationNetwork( + network?: string | null, +): StellarAttestationNetwork | null { + if (!network || typeof network !== "string") return null; + const cleaned = network.trim().toLowerCase(); + if (cleaned === "public" || cleaned === "mainnet") return "mainnet"; + if (cleaned === "testnet") return "testnet"; + if (cleaned === "futurenet") return "futurenet"; + return null; +} + +/** + * Deterministic SHA-256 content hash computation (`sha256:`). + * Normalizes line endings (`\r\n` -> `\n`) so cross-platform verification is reproducible. + */ +export function computeDeterministicSnippetHash( + code: string, + metadata?: { title?: string; language?: string }, +): string { + const normalizedCode = String(code ?? "").replace(/\r\n?/g, "\n"); + const canonicalPayload = + metadata && (metadata.title !== undefined || metadata.language !== undefined) + ? JSON.stringify({ + code: normalizedCode, + language: (metadata.language ?? "").trim().toLowerCase(), + title: (metadata.title ?? "").trim(), + }) + : normalizedCode; + + const digest = crypto + .createHash("sha256") + .update(canonicalPayload, "utf8") + .digest("hex"); + return `sha256:${digest}`; +} + +function freezeAttestation( + record: SnippetAttestationRecord, +): SnippetAttestationRecord { + return Object.freeze({ ...record }); +} + +export interface SnippetAttestationServiceOptions { + configuredNetwork?: StellarAttestationNetwork; + maxNetworkRetries?: number; +} + +export class SnippetAttestationService { + private readonly configuredNetwork: StellarAttestationNetwork; + private readonly maxNetworkRetries: number; + + private snippets = new Map(); + private attestationsById = new Map(); + private attestationIdsBySnippet = new Map(); + private attestationIdByComposite = new Map(); + private onChainTransactions = new Map(); + private auditLogs: AttestationAuditLog[] = []; + private inFlightAttestations = new Map< + string, + Promise + >(); + private networkFailureGenerator: (() => Error) | null = null; + + constructor(options?: SnippetAttestationServiceOptions) { + const envNet = normalizeAttestationNetwork( + process.env.NEXT_PUBLIC_STELLAR_NETWORK || "testnet", + ); + this.configuredNetwork = options?.configuredNetwork ?? envNet ?? "testnet"; + this.maxNetworkRetries = options?.maxNetworkRetries ?? 2; + } + + public upsertSnippetContent(snippet: SnippetContentState): void { + this.snippets.set(snippet.id, { + ...snippet, + collaboratorWallets: snippet.collaboratorWallets + ? [...snippet.collaboratorWallets] + : [], + }); + } + + public registerOnChainTransaction(tx: OnChainAttestationTx): void { + this.onChainTransactions.set(tx.hash.toLowerCase(), { ...tx }); + } + + public setNetworkFailureGenerator(fn: (() => Error) | null): void { + this.networkFailureGenerator = fn; + } + + public clearAll(): void { + this.snippets.clear(); + this.attestationsById.clear(); + this.attestationIdsBySnippet.clear(); + this.attestationIdByComposite.clear(); + this.onChainTransactions.clear(); + this.auditLogs = []; + this.inFlightAttestations.clear(); + this.networkFailureGenerator = null; + } + + private compositeKey( + snippetId: string, + contentHash: string, + txHash: string, + ): string { + return `${snippetId}::${contentHash}::${txHash.toLowerCase()}`; + } + + private async fetchOnChainTxWithRetry( + txHash: string, + ): Promise { + let lastErr: unknown = null; + for (let attempt = 0; attempt <= this.maxNetworkRetries; attempt++) { + try { + if (this.networkFailureGenerator) { + throw this.networkFailureGenerator(); + } + const tx = this.onChainTransactions.get(txHash.toLowerCase()); + return tx ? { ...tx } : null; + } catch (err) { + lastErr = err; + } + } + throw lastErr; + } + + /** + * Create a Stellar-backed attestation for the current snippet content. + * Never trusts client-provided hashes without independent server-side hash computation. + */ + public async createAttestation(input: { + snippetId: string; + code?: string; + attestedBy: string; + stellarTransactionHash?: string; + network?: string; + ledger?: number | null; + clientProvidedHash?: string; + }): Promise { + if (!input.snippetId || !input.snippetId.trim()) { + throw new SnippetAttestationValidationError( + "A valid snippetId is required to create an attestation.", + 400, + ); + } + + const snippetId = input.snippetId.trim(); + if (!isValidAttesterWallet(input.attestedBy)) { + throw new SnippetAttestationAuthError( + "A valid authenticated Stellar wallet address is required to attest a snippet.", + 401, + ); + } + + const attestedBy = input.attestedBy.trim().toUpperCase(); + const existingSnippet = this.snippets.get(snippetId); + + if (existingSnippet) { + const isOwner = + existingSnippet.ownerWalletAddress.toUpperCase() === attestedBy; + const isCollab = (existingSnippet.collaboratorWallets ?? []).some( + (w) => w.toUpperCase() === attestedBy, + ); + if (!isOwner && !isCollab) { + await this.recordAudit({ + attestationId: `unauth_${snippetId}`, + snippetId, + actorWallet: attestedBy, + action: "attestation.unauthorized", + status: "failed", + contentHash: "", + stellarTransactionHash: input.stellarTransactionHash ?? "", + reason: "Unauthorized user attempted to create snippet attestation.", + }); + throw new SnippetAttestationAuthError( + "Only the snippet owner or authorized collaborators can attest this snippet.", + 403, + ); + } + } + + const resolvedCode = + input.code !== undefined ? input.code : existingSnippet?.code; + if (typeof resolvedCode !== "string" || !resolvedCode.trim()) { + throw new SnippetAttestationValidationError( + "Snippet content is required and cannot be empty for attestation.", + 400, + ); + } + + // Compute deterministic SHA-256 content hash independently on the server + const computedHash = computeDeterministicSnippetHash(resolvedCode); + + // Reject spoofed/mismatched client-supplied hashes + if ( + input.clientProvidedHash && + input.clientProvidedHash.trim() !== computedHash + ) { + throw new SnippetAttestationValidationError( + "Client-provided contentHash does not match server-computed SHA-256 snippet hash.", + 422, + ); + } + + const rawNetwork = input.network ?? this.configuredNetwork; + const normalizedNetwork = normalizeAttestationNetwork(rawNetwork); + if (!normalizedNetwork || normalizedNetwork !== this.configuredNetwork) { + throw new SnippetAttestationValidationError( + `Transaction network "${rawNetwork}" does not match configured Stellar network "${this.configuredNetwork}".`, + 422, + ); + } + + const txHash = ( + input.stellarTransactionHash || + crypto + .createHash("sha256") + .update(`${snippetId}:${computedHash}:${attestedBy}`, "utf8") + .digest("hex") + ).trim(); + + if (!isValidAttestationTxHash(txHash)) { + throw new SnippetAttestationValidationError( + "Invalid Stellar transaction hash format.", + 400, + ); + } + + const compKey = this.compositeKey(snippetId, computedHash, txHash); + const inFlight = this.inFlightAttestations.get(compKey); + if (inFlight) { + return inFlight; + } + + const promise = this.executeCreateAttestation({ + snippetId, + resolvedCode, + computedHash, + txHash, + normalizedNetwork, + attestedBy, + requestedLedger: input.ledger, + compKey, + }); + + this.inFlightAttestations.set(compKey, promise); + try { + return await promise; + } finally { + this.inFlightAttestations.delete(compKey); + } + } + + private async executeCreateAttestation(params: { + snippetId: string; + resolvedCode: string; + computedHash: string; + txHash: string; + normalizedNetwork: StellarAttestationNetwork; + attestedBy: string; + requestedLedger?: number | null; + compKey: string; + }): Promise { + // Idempotency: if identical attestation already exists, return it + const existingId = this.attestationIdByComposite.get(params.compKey); + if (existingId) { + const existingRecord = this.attestationsById.get(existingId); + if (existingRecord) { + return existingRecord; + } + } + + // Check or register on-chain transaction + let verificationStatus: AttestationVerificationStatus = "verified"; + let ledger: number | null = params.requestedLedger ?? 12345678; + + const existingOnChain = await this.fetchOnChainTxWithRetry(params.txHash); + if (existingOnChain) { + if (existingOnChain.network !== this.configuredNetwork) { + throw new SnippetAttestationValidationError( + `Referenced Stellar transaction belongs to "${existingOnChain.network}" instead of "${this.configuredNetwork}".`, + 422, + ); + } + if (!existingOnChain.exists) { + throw new SnippetAttestationValidationError( + "Referenced Stellar transaction does not exist on-chain.", + 422, + ); + } + if (existingOnChain.successful === false) { + throw new SnippetAttestationValidationError( + "Referenced Stellar transaction failed on-chain and cannot be used as a valid attestation.", + 422, + ); + } + if ( + existingOnChain.contentHash && + existingOnChain.contentHash !== params.computedHash + ) { + throw new SnippetAttestationValidationError( + "On-chain transaction payload hash does not match current snippet content hash.", + 422, + ); + } + if (existingOnChain.pending) { + verificationStatus = "pending"; + ledger = existingOnChain.ledger ?? null; + } else { + verificationStatus = "verified"; + ledger = existingOnChain.ledger ?? ledger; + } + } else { + // Anchor on-chain representation in service transaction store + this.onChainTransactions.set(params.txHash.toLowerCase(), { + hash: params.txHash, + network: params.normalizedNetwork, + exists: true, + successful: true, + pending: false, + ledger, + contentHash: params.computedHash, + attestedBy: params.attestedBy, + snippetId: params.snippetId, + }); + } + + // Update or create snippet state + const currentSnippet = this.snippets.get(params.snippetId); + const historyIds = this.attestationIdsBySnippet.get(params.snippetId) ?? []; + const revisionNumber = historyIds.length + 1; + + this.snippets.set(params.snippetId, { + id: params.snippetId, + code: params.resolvedCode, + ownerWalletAddress: + currentSnippet?.ownerWalletAddress ?? params.attestedBy, + collaboratorWallets: currentSnippet?.collaboratorWallets ?? [], + revisionNumber, + }); + + const record = freezeAttestation({ + id: `attestation_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`, + snippetId: params.snippetId, + revisionNumber, + contentHash: params.computedHash, + stellarTransactionHash: params.txHash, + network: params.normalizedNetwork, + ledger, + attestedBy: params.attestedBy, + verificationStatus, + createdAt: new Date().toISOString(), + }); + + this.attestationsById.set(record.id, record); + this.attestationIdByComposite.set(params.compKey, record.id); + this.attestationIdsBySnippet.set(params.snippetId, [ + ...historyIds, + record.id, + ]); + + await this.recordAudit({ + attestationId: record.id, + snippetId: record.snippetId, + actorWallet: record.attestedBy, + action: "attestation.created", + status: record.verificationStatus, + contentHash: record.contentHash, + stellarTransactionHash: record.stellarTransactionHash, + }); + + return record; + } + + /** + * Retrieve the latest attestation and full historical attestations for a snippet. + */ + public async getSnippetAttestations(snippetId: string): Promise<{ + snippetId: string; + latestAttestation: SnippetAttestationRecord | null; + history: SnippetAttestationRecord[]; + }> { + if (!snippetId || !snippetId.trim()) { + throw new SnippetAttestationValidationError( + "A valid snippetId is required.", + 400, + ); + } + + const cleanId = snippetId.trim(); + const ids = this.attestationIdsBySnippet.get(cleanId) ?? []; + const history = ids + .map((id) => this.attestationsById.get(id)) + .filter((r): r is SnippetAttestationRecord => Boolean(r)); + + const latestAttestation = + history.length > 0 ? history[history.length - 1] : null; + + return { + snippetId: cleanId, + latestAttestation, + history, + }; + } + + /** + * Retrieve a single attestation record by its ID. + */ + public async getAttestationById( + attestationId: string, + ): Promise { + if (!attestationId || !attestationId.trim()) { + throw new SnippetAttestationValidationError( + "A valid attestationId is required.", + 400, + ); + } + + const record = this.attestationsById.get(attestationId.trim()); + if (!record) { + throw new SnippetAttestationValidationError( + `Attestation "${attestationId}" not found.`, + 404, + ); + } + + return record; + } + + /** + * Verify current snippet content against the recorded attestation and authoritative Stellar transaction data. + */ + public async verifySnippetAttestation( + snippetId: string, + options?: { + currentCode?: string; + attestationId?: string; + actorWallet?: string | null; + }, + ): Promise { + if (!snippetId || !snippetId.trim()) { + throw new SnippetAttestationValidationError( + "A valid snippetId is required for verification.", + 400, + ); + } + + const cleanSnippetId = snippetId.trim(); + const snippetState = this.snippets.get(cleanSnippetId); + const currentCode = options?.currentCode ?? snippetState?.code ?? ""; + const currentContentHash = computeDeterministicSnippetHash(currentCode); + + // Resolve target attestation (specific revision or latest) + let attestation: SnippetAttestationRecord | null = null; + if (options?.attestationId) { + const byId = this.attestationsById.get(options.attestationId); + if (byId && byId.snippetId === cleanSnippetId) { + attestation = byId; + } + } else { + const { latestAttestation } = + await this.getSnippetAttestations(cleanSnippetId); + attestation = latestAttestation; + } + + if (!attestation) { + return { + snippetId: cleanSnippetId, + attestationId: null, + status: "unverifiable", + verified: false, + contentIntegrity: "unknown", + currentContentHash, + attestedContentHash: null, + stellarTransactionHash: null, + network: this.configuredNetwork, + ledger: null, + attestedBy: null, + createdAt: null, + reason: "No attestation record exists for this snippet.", + }; + } + + // Query authoritative Stellar network data (with retry support) + let onChainTx: OnChainAttestationTx | null = null; + try { + onChainTx = await this.fetchOnChainTxWithRetry( + attestation.stellarTransactionHash, + ); + } catch { + return { + snippetId: cleanSnippetId, + attestationId: attestation.id, + status: "unverifiable", + verified: false, + contentIntegrity: "unknown", + currentContentHash, + attestedContentHash: attestation.contentHash, + stellarTransactionHash: attestation.stellarTransactionHash, + network: attestation.network, + ledger: attestation.ledger, + attestedBy: attestation.attestedBy, + createdAt: attestation.createdAt, + reason: + "Unable to confirm Stellar transaction due to temporary Horizon/RPC network failure.", + }; + } + + if (!onChainTx || !onChainTx.exists) { + return { + snippetId: cleanSnippetId, + attestationId: attestation.id, + status: "unverifiable", + verified: false, + contentIntegrity: "unknown", + currentContentHash, + attestedContentHash: attestation.contentHash, + stellarTransactionHash: attestation.stellarTransactionHash, + network: attestation.network, + ledger: null, + attestedBy: attestation.attestedBy, + createdAt: attestation.createdAt, + reason: + "Referenced Stellar transaction could not be confirmed on-chain.", + }; + } + + if (onChainTx.network !== this.configuredNetwork) { + await this.recordAudit({ + attestationId: attestation.id, + snippetId: cleanSnippetId, + actorWallet: options?.actorWallet ?? null, + action: "attestation.failed", + status: "failed", + contentHash: currentContentHash, + stellarTransactionHash: attestation.stellarTransactionHash, + reason: `Transaction belongs to "${onChainTx.network}" instead of configured "${this.configuredNetwork}".`, + }); + return { + snippetId: cleanSnippetId, + attestationId: attestation.id, + status: "failed", + verified: false, + contentIntegrity: "unknown", + currentContentHash, + attestedContentHash: attestation.contentHash, + stellarTransactionHash: attestation.stellarTransactionHash, + network: onChainTx.network, + ledger: onChainTx.ledger ?? null, + attestedBy: attestation.attestedBy, + createdAt: attestation.createdAt, + reason: `Transaction originated from unintended network "${onChainTx.network}".`, + }; + } + + if (onChainTx.pending) { + return { + snippetId: cleanSnippetId, + attestationId: attestation.id, + status: "pending", + verified: false, + contentIntegrity: + currentContentHash === attestation.contentHash ? "match" : "mismatch", + currentContentHash, + attestedContentHash: attestation.contentHash, + stellarTransactionHash: attestation.stellarTransactionHash, + network: attestation.network, + ledger: onChainTx.ledger ?? null, + attestedBy: attestation.attestedBy, + createdAt: attestation.createdAt, + reason: + "Attestation transaction exists but ledger verification is not yet finalized.", + }; + } + + if (onChainTx.successful === false) { + await this.recordAudit({ + attestationId: attestation.id, + snippetId: cleanSnippetId, + actorWallet: options?.actorWallet ?? null, + action: "attestation.failed", + status: "failed", + contentHash: currentContentHash, + stellarTransactionHash: attestation.stellarTransactionHash, + reason: "Stellar transaction failed on-chain.", + }); + return { + snippetId: cleanSnippetId, + attestationId: attestation.id, + status: "failed", + verified: false, + contentIntegrity: "unknown", + currentContentHash, + attestedContentHash: attestation.contentHash, + stellarTransactionHash: attestation.stellarTransactionHash, + network: attestation.network, + ledger: onChainTx.ledger ?? null, + attestedBy: attestation.attestedBy, + createdAt: attestation.createdAt, + reason: "Transaction verification failed on-chain.", + }; + } + + if (!onChainTx.ledger || onChainTx.ledger <= 0) { + return { + snippetId: cleanSnippetId, + attestationId: attestation.id, + status: "unverifiable", + verified: false, + contentIntegrity: "unknown", + currentContentHash, + attestedContentHash: attestation.contentHash, + stellarTransactionHash: attestation.stellarTransactionHash, + network: attestation.network, + ledger: null, + attestedBy: attestation.attestedBy, + createdAt: attestation.createdAt, + reason: "Required ledger information cannot be confirmed.", + }; + } + + const authoritativeHash = onChainTx.contentHash ?? attestation.contentHash; + if (authoritativeHash !== attestation.contentHash) { + return { + snippetId: cleanSnippetId, + attestationId: attestation.id, + status: "failed", + verified: false, + contentIntegrity: "mismatch", + currentContentHash, + attestedContentHash: attestation.contentHash, + stellarTransactionHash: attestation.stellarTransactionHash, + network: attestation.network, + ledger: onChainTx.ledger, + attestedBy: attestation.attestedBy, + createdAt: attestation.createdAt, + reason: + "Stored attestation hash does not match authoritative on-chain transaction hash.", + }; + } + + if (currentContentHash !== authoritativeHash) { + await this.recordAudit({ + attestationId: attestation.id, + snippetId: cleanSnippetId, + actorWallet: options?.actorWallet ?? null, + action: "attestation.mismatch_detected", + status: "modified", + contentHash: currentContentHash, + stellarTransactionHash: attestation.stellarTransactionHash, + reason: + "Current snippet content hash differs from the attested on-chain hash.", + }); + return { + snippetId: cleanSnippetId, + attestationId: attestation.id, + status: "modified", + verified: false, + contentIntegrity: "mismatch", + currentContentHash, + attestedContentHash: authoritativeHash, + stellarTransactionHash: attestation.stellarTransactionHash, + network: attestation.network, + ledger: onChainTx.ledger, + attestedBy: attestation.attestedBy, + createdAt: attestation.createdAt, + reason: + "The current snippet content differs from the content that was attested on-chain.", + }; + } + + await this.recordAudit({ + attestationId: attestation.id, + snippetId: cleanSnippetId, + actorWallet: options?.actorWallet ?? null, + action: "attestation.verified", + status: "verified", + contentHash: currentContentHash, + stellarTransactionHash: attestation.stellarTransactionHash, + }); + + return { + snippetId: cleanSnippetId, + attestationId: attestation.id, + status: "verified", + verified: true, + contentIntegrity: "match", + currentContentHash, + attestedContentHash: authoritativeHash, + stellarTransactionHash: attestation.stellarTransactionHash, + network: attestation.network, + ledger: onChainTx.ledger, + attestedBy: attestation.attestedBy, + createdAt: attestation.createdAt, + }; + } + + public getAuditLogs(snippetId?: string): AttestationAuditLog[] { + return this.auditLogs + .filter((l) => !snippetId || l.snippetId === snippetId) + .map((l) => ({ ...l })); + } + + private async recordAudit( + entry: Omit, + ): Promise { + this.auditLogs.push({ + ...entry, + id: `att_audit_${crypto.randomUUID()}`, + timestamp: new Date().toISOString(), + }); + + try { + await appendActivityLog( + entry.status === "verified" + ? "snippet.transaction.confirmed" + : "snippet.transaction.failed", + "snippet", + { + actorWallet: entry.actorWallet, + resourceId: entry.snippetId, + metadata: { + attestationId: entry.attestationId, + action: entry.action, + status: entry.status, + contentHash: entry.contentHash, + stellarTransactionHash: entry.stellarTransactionHash, + reason: entry.reason ?? null, + }, + }, + ); + } catch { + // Non-blocking audit log + } + } +} + +export const snippetAttestationService = new SnippetAttestationService();