diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ddf49a..2e1aba3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,28 +55,84 @@ Violations can be reported by opening a private GitHub issue or contacting a mai - Node.js >= 18 (we recommend [nvm](https://github.com/nvm-sh/nvm)) - npm >= 9 +- Git (for cloning the repository) -### Install Dependencies +### 1. Clone the Repository + +If you haven't already, fork the repository on GitHub, then clone your fork locally: ```bash -npm install +git clone https://github.com/YOUR_USERNAME/open-audit.git +cd open-audit ``` -### Environment Variables +### 2. Install Dependencies + +Install the required Node.js dependencies: ```bash -cp .env.example .env.local +npm install ``` -The defaults point to Stellar **testnet**, which is safe for development. No changes are required for running tests. +### 3. Environment Configuration -### Start the Dev Server +Copy the example environment configuration file to `.env`: ```bash -npm run dev +cp .env.example .env ``` -The app will be available at [http://localhost:3000](http://localhost:3000). +#### Minimum Required Variables + +For basic local development, the default values in `.env` are pre-configured to connect to the Stellar **testnet**. The minimum required variables are: + +- `NEXT_PUBLIC_HORIZON_URL`: The Horizon REST API endpoint (defaults to `https://horizon-testnet.stellar.org`). +- `NEXT_PUBLIC_SOROBAN_RPC_URL`: The Soroban RPC endpoint (defaults to `https://soroban-testnet.stellar.org`). +- `NEXT_PUBLIC_NETWORK_PASSPHRASE`: The passphrase matching the target network (defaults to `"Test SDF Network ; September 2015"`). +- `NEXT_PUBLIC_NETWORK`: The network identifier (`testnet`, `mainnet`, or `futurenet`, defaults to `testnet`). + +#### Optional Services: PostgreSQL & Redis + +For basic development with the in-memory mock data path, **PostgreSQL and Redis are optional**. +- If `DATABASE_URL` is not configured, the app automatically falls back to the in-memory mock data path. +- If `REDIS_URL` is not configured, the app falls back to an in-process memory cache. + +#### Setting Up PostgreSQL (Optional) + +If you need to test database persistence or work on features requiring the database: +1. Ensure PostgreSQL is running and create a local database: + ```bash + createdb open_audit + ``` +2. Configure the `DATABASE_URL` variable in your `.env` file, for example: + ```env + DATABASE_URL="postgresql://user:password@localhost:5432/open_audit" + ``` +3. Run the database migrations: + ```bash + npm run db:migrate + ``` +4. Seed the database with test data: + ```bash + npm run db:seed + ``` + +### 4. Start the Development Server + +You can run the application in two modes depending on your needs: + +- **Basic Dashboard**: To run the Next.js development server for the frontend dashboard: + ```bash + npm run dev + ``` + The app will be available at [http://localhost:3000](http://localhost:3000). + +- **Full WebSocket Server**: To run the monolithic server which includes both the frontend and WebSocket event streaming capabilities: + ```bash + npm run dev:ws + ``` + The app will be available at [http://localhost:3000](http://localhost:3000). + --- diff --git a/app/api/v1/events/[id]/route.test.ts b/app/api/v1/events/[id]/route.test.ts new file mode 100644 index 0000000..91b8b43 --- /dev/null +++ b/app/api/v1/events/[id]/route.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GET } from "./route"; +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { authenticateAndRateLimit } from "@/lib/api/middleware"; + +vi.mock("@/lib/api/middleware", () => ({ + authenticateAndRateLimit: vi.fn(), +})); + +vi.mock("@/lib/db/client", () => ({ + db: { + event: { + findUnique: vi.fn(), + }, + }, +})); + +describe("GET /api/v1/events/[id]", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + const createRequest = () => { + return new NextRequest("http://localhost/api/v1/events/some-id", { + headers: { + authorization: "Bearer test_key", + }, + }); + }; + + it("returns 401 when authentication fails", async () => { + const mockAuthResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(authenticateAndRateLimit).mockResolvedValue(mockAuthResponse); + + const req = createRequest(); + const res = await GET(req, { params: Promise.resolve({ id: "some-id" }) }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: "Unauthorized" }); + expect(db.event.findUnique).not.toHaveBeenCalled(); + }); + + it("returns 404 when the event is not found", async () => { + vi.mocked(authenticateAndRateLimit).mockResolvedValue(null); + vi.mocked(db.event.findUnique).mockResolvedValue(null); + + const req = createRequest(); + const res = await GET(req, { params: Promise.resolve({ id: "some-id" }) }); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body).toEqual({ error: 'Event with ID "some-id" not found' }); + expect(db.event.findUnique).toHaveBeenCalledWith({ + where: { id: "some-id" }, + }); + }); + + it("returns 200 and the event when found", async () => { + const mockEvent = { + id: "some-id", + contractId: "C123", + ledger: 100, + timestamp: 1626000000, + txHash: "hash123", + topics: ["topic1"], + data: "0xdata", + description: "Transferred tokens", + status: "translated", + blueprintName: "Token", + eventType: "transfer", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + vi.mocked(authenticateAndRateLimit).mockResolvedValue(null); + vi.mocked(db.event.findUnique).mockResolvedValue(mockEvent as any); + + const req = createRequest(); + const res = await GET(req, { params: Promise.resolve({ id: "some-id" }) }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.id).toBe("some-id"); + expect(body.contractId).toBe("C123"); + expect(body.description).toBe("Transferred tokens"); + expect(db.event.findUnique).toHaveBeenCalledWith({ + where: { id: "some-id" }, + }); + }); + + it("returns 500 or appropriate error response when database query fails", async () => { + vi.mocked(authenticateAndRateLimit).mockResolvedValue(null); + vi.mocked(db.event.findUnique).mockRejectedValue(new Error("Database connection lost")); + + const req = createRequest(); + const res = await GET(req, { params: Promise.resolve({ id: "some-id" }) }); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.code).toBe("INTERNAL_ERROR"); + expect(body.message).toBe("Database connection lost"); + }); +}); diff --git a/app/api/v1/events/[id]/route.ts b/app/api/v1/events/[id]/route.ts new file mode 100644 index 0000000..8d9468c --- /dev/null +++ b/app/api/v1/events/[id]/route.ts @@ -0,0 +1,73 @@ +/** + * GET /api/v1/events/[id] + * + * Fetch a single event by its unique ID. + * + * Path Parameters: + * - id: The unique string ID of the event + * + * Response Schema (200 OK): + * { + * id: string; // Unique event identifier + * contractId: string; // Soroban contract address + * ledger: number; // Ledger sequence number + * timestamp: number; // Unix timestamp of the event + * txHash: string; // Transaction hash + * topics: any; // Array of event topics (stored as JSON array) + * data: string; // Hex-encoded event data + * description: string | null; // Translated human-readable description + * status: string; // Translation status ("translated" | "cryptic") + * blueprintName: string | null; // Blueprint/Contract name + * eventType: string | null; // Event type (e.g., "transfer", "mint") + * createdAt: string; // Database creation timestamp + * updatedAt: string; // Database update timestamp + * } + * + * Response (404 Not Found): + * { + * error: string; + * } + */ + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db/client"; +import { authenticateAndRateLimit } from "@/lib/api/middleware"; +import { toErrorResponse } from "@/lib/api/error-response"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + try { + // Authenticate the request and check rate limits + const authError = await authenticateAndRateLimit(request); + if (authError) return authError; + + // Await the route params in Next.js 15+ + const { id } = await params; + + if (!id) { + return NextResponse.json( + { error: "Event ID parameter is missing" }, + { status: 400 } + ); + } + + // Query Prisma Event table for an exact match on id field + const event = await db.event.findUnique({ + where: { id }, + }); + + if (!event) { + return NextResponse.json( + { error: `Event with ID "${id}" not found` }, + { status: 404 } + ); + } + + // Return the event as JSON + return NextResponse.json(event); + } catch (error) { + return toErrorResponse(error, { fallbackMessage: "Failed to retrieve event" }); + } +} diff --git a/lib/translator/registry.ts b/lib/translator/registry.ts index 2fac9d9..e149457 100644 --- a/lib/translator/registry.ts +++ b/lib/translator/registry.ts @@ -216,14 +216,56 @@ function buildRegistry(): BlueprintRegistry { } } - // 3. Load SDEX (Stellar Classic Order Book) Blueprints - for (const blueprint of createAllSdexBlueprints()) { - register(blueprint); - } - return registry; } +function interpolate(template: string, values: Record): string { + return template.replace(/\{([^}]+)\}/g, (match, key) => { + const [path, format] = key.split("."); + const val = values[path]; + if (val && typeof val === "object" && format) { + return val[format] ?? match; + } + return val ?? match; + }); +} + +function createTranslateFromMapping(mapping: any) { + return (event: RawEvent, lang: Language): TranslationResult | null => { + // 1. Match topics + for (let i = 0; i < mapping.topics.length; i++) { + if (i === 0) { + if (decodeEventName(event.topics[0]) !== mapping.topics[0]) return null; + } + // Future: support matching other topics too + } + + const fields: Record = {}; + + // 2. Extract topics[1..] + mapping.event_structure.topics.forEach((t: any, i: number) => { + const hex = event.topics[i + 1]; + if (!hex) return; + if (t.type === "address") fields[t.name] = decodeAddress(hex); + else if (t.type === "i128") fields[t.name] = decodeAmount(hex); + else fields[t.name] = hex; + }); + + // 3. Extract data + if (mapping.event_structure.data) { + const d = mapping.event_structure.data; + if (d.type === "i128") fields[d.name] = decodeAmount(event.data); + else if (d.type === "address") fields[d.name] = decodeAddress(event.data); + else fields[d.name] = event.data; + } + + return { + description: interpolate(mapping.english_template, fields), + eventType: mapping.topics[0], + }; + }; +} + /** * Builds a `translate` function from a single event-mapping declaration. * Called by registerUpgrade (eventMappings). Required for the module to load.