diff --git a/README.md b/README.md index 466d19e..a29f640 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ The server will start on `http://localhost:8069` ## Endpoints -- **gRPC services**: `http://localhost:8069` (all gRPC endpoints) +- **Connect / gRPC-Web / gRPC (h2c / HTTP/2 cleartext)**: `http://localhost:8069` - **Webhook**: `http://localhost:8069/webhooks/lemonsqueezy/createdCheckout` ## Documentation diff --git a/bun.lock b/bun.lock index 8ab4eca..ef08d9c 100644 --- a/bun.lock +++ b/bun.lock @@ -11,13 +11,16 @@ "@connectrpc/validate": "^0.2.0", "@lemonsqueezy/lemonsqueezy.js": "^4.0.0", "@libsql/client": "^0.15.15", + "@types/expr-eval": "^1.1.2", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", + "expr-eval": "^2.0.2", "luxon": "^3.7.2", "mysql2": "^3.15.3", "pino": "^10.1.0", "pino-pretty": "^13.1.2", "postgres": "^3.4.7", + "skills": "^1.3.1", "zod": "^4.1.12", }, "devDependencies": { @@ -207,6 +210,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/expr-eval": ["@types/expr-eval@1.1.2", "", { "dependencies": { "expr-eval": "*" } }, "sha512-rX4yEyVMxN2uFwJ16uVcx8e10xJ70NftDLZYtyP9gaOnaZ8pJLyR9FLi7i2SwucAafC+KjTs8/ZyoMvLLu/9ng=="], + "@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="], "@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], @@ -279,6 +284,8 @@ "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], + "expr-eval": ["expr-eval@2.0.2", "", {}, "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg=="], + "fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="], "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], @@ -385,6 +392,8 @@ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "skills": ["skills@1.3.1", "", { "bin": { "skills": "bin/cli.mjs", "add-skill": "bin/cli.mjs" } }, "sha512-Bi9R28oOVuUk6dB14QDcQYZjQ9t28fbZoQMIc+8vy+hkYVqBqS2HwVYfUWWMYWt5IhvvLn2QUfXLjK1GTDaaSQ=="], + "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/package.json b/package.json index c5bc526..1e48882 100644 --- a/package.json +++ b/package.json @@ -35,13 +35,16 @@ "@connectrpc/validate": "^0.2.0", "@lemonsqueezy/lemonsqueezy.js": "^4.0.0", "@libsql/client": "^0.15.15", + "@types/expr-eval": "^1.1.2", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", + "expr-eval": "^2.0.2", "luxon": "^3.7.2", "mysql2": "^3.15.3", "pino": "^10.1.0", "pino-pretty": "^13.1.2", "postgres": "^3.4.7", + "skills": "^1.3.1", "zod": "^4.1.12" } } diff --git a/proto b/proto index c6e9e9f..83447d5 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit c6e9e9f93add4e8980698153557bc622528bdbc1 +Subproject commit 83447d560777e189b47ee6c7736b6a013450664c diff --git a/src/__tests__/unit/context/requestContext.test.ts b/src/__tests__/unit/context/requestContext.test.ts new file mode 100644 index 0000000..b16b021 --- /dev/null +++ b/src/__tests__/unit/context/requestContext.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + WideEventBuilder, + createWideEventBuilder, + generateRequestId, +} from "../../../context/requestContext"; + +describe("requestContext", () => { + describe("generateRequestId", () => { + it("should generate a valid UUID v4", () => { + const requestId = generateRequestId(); + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(requestId).toMatch(uuidRegex); + }); + + it("should generate unique IDs", () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) { + ids.add(generateRequestId()); + } + expect(ids.size).toBe(100); + }); + }); + + describe("WideEventBuilder", () => { + let builder: WideEventBuilder; + const requestId = "test-request-id-123"; + const method = "unary"; + const url = "https://api.example.com/event.v1.EventService/RegisterEvent"; + + beforeEach(() => { + builder = new WideEventBuilder(requestId, method, url); + }); + + it("should initialize with request metadata", () => { + const event = builder.build(); + + expect(event.requestId).toBe(requestId); + expect(event.method).toBe(method); + expect(event.path).toBe("/event.v1.EventService/RegisterEvent"); + expect(event.timestamp).toBeDefined(); + expect(event.env).toBeDefined(); + }); + + it("should extract path from full URL", () => { + const event = builder.build(); + expect(event.path).toBe("/event.v1.EventService/RegisterEvent"); + }); + + it("should handle relative URLs", () => { + const relativeBuilder = new WideEventBuilder( + requestId, + method, + "/event.v1.EventService/RegisterEvent" + ); + const event = relativeBuilder.build(); + expect(event.path).toBe("/event.v1.EventService/RegisterEvent"); + }); + + describe("setAuth", () => { + it("should set auth context with cache hit", () => { + builder.setAuth("api-key-123", true); + const event = builder.build(); + + expect(event.apiKeyId).toBe("api-key-123"); + expect(event.cacheHit).toBe(true); + }); + + it("should set auth context with cache miss", () => { + builder.setAuth("api-key-456", false); + const event = builder.build(); + + expect(event.apiKeyId).toBe("api-key-456"); + expect(event.cacheHit).toBe(false); + }); + }); + + describe("setUser", () => { + it("should set user ID", () => { + builder.setUser("user-789"); + const event = builder.build(); + + expect(event.userId).toBe("user-789"); + }); + + it("should support numeric user IDs", () => { + builder.setUser(12345); + const event = builder.build(); + + expect(event.userId).toBe(12345); + }); + }); + + describe("setEventContext", () => { + it("should set event type", () => { + builder.setEventContext({ eventType: "SDK_CALL" }); + const event = builder.build(); + + expect(event.eventType).toBe("SDK_CALL"); + }); + + it("should set event count", () => { + builder.setEventContext({ eventCount: 10 }); + const event = builder.build(); + + expect(event.eventCount).toBe(10); + }); + + it("should set both", () => { + builder.setEventContext({ eventType: "AI_TOKEN_USAGE", eventCount: 5 }); + const event = builder.build(); + + expect(event.eventType).toBe("AI_TOKEN_USAGE"); + expect(event.eventCount).toBe(5); + }); + }); + + describe("setPaymentContext", () => { + it("should set credit amount", () => { + builder.setPaymentContext({ creditAmount: 5000 }); + const event = builder.build(); + + expect(event.creditAmount).toBe(5000); + }); + + it("should set debit amount", () => { + builder.setPaymentContext({ debitAmount: 100 }); + const event = builder.build(); + + expect(event.debitAmount).toBe(100); + }); + + it("should set price amount", () => { + builder.setPaymentContext({ priceAmount: 2500 }); + const event = builder.build(); + + expect(event.priceAmount).toBe(2500); + }); + }); + + describe("setApiKeyContext", () => { + it("should set API key name", () => { + builder.setApiKeyContext({ name: "production-key" }); + const event = builder.build(); + + expect(event.apiKeyName).toBe("production-key"); + }); + + it("should set API key expiration", () => { + builder.setApiKeyContext({ expiration: "2027-01-31T00:00:00.000Z" }); + const event = builder.build(); + + expect(event.apiKeyExpiration).toBe("2027-01-31T00:00:00.000Z"); + }); + }); + + describe("setWebhookContext", () => { + it("should set webhook event", () => { + builder.setWebhookContext({ webhookEvent: "order_created" }); + const event = builder.build(); + + expect(event.webhookEvent).toBe("order_created"); + }); + + it("should set order ID", () => { + builder.setWebhookContext({ orderId: "order-123" }); + const event = builder.build(); + + expect(event.orderId).toBe("order-123"); + }); + }); + + describe("addContext", () => { + it("should add arbitrary context", () => { + builder.addContext({ customField: "custom-value", count: 42 }); + const event = builder.build(); + + expect(event.customField).toBe("custom-value"); + expect(event.count).toBe(42); + }); + }); + + describe("setSuccess", () => { + it("should set success outcome with default status code", () => { + builder.setSuccess(); + const event = builder.build(); + + expect(event.outcome).toBe("success"); + expect(event.statusCode).toBe(200); + }); + + it("should set success outcome with custom status code", () => { + builder.setSuccess(201); + const event = builder.build(); + + expect(event.outcome).toBe("success"); + expect(event.statusCode).toBe(201); + }); + }); + + describe("setError", () => { + it("should set error outcome with details", () => { + builder.setError(400, { + type: "VALIDATION_FAILED", + message: "userId is required", + }); + const event = builder.build(); + + expect(event.outcome).toBe("error"); + expect(event.statusCode).toBe(400); + expect(event.error?.type).toBe("VALIDATION_FAILED"); + expect(event.error?.message).toBe("userId is required"); + }); + + it("should set error with cause", () => { + builder.setError(500, { + type: "DATABASE_ERROR", + message: "Query failed", + cause: "Connection timeout", + }); + const event = builder.build(); + + expect(event.error?.cause).toBe("Connection timeout"); + }); + }); + + describe("build", () => { + it("should calculate duration", async () => { + // Wait a small amount to ensure some time passes + await new Promise((resolve) => setTimeout(resolve, 10)); + const event = builder.build(); + + expect(event.durationMs).toBeGreaterThanOrEqual(0); + }); + + it("should default outcome to success", () => { + const event = builder.build(); + expect(event.outcome).toBe("success"); + }); + }); + + describe("chaining", () => { + it("should support method chaining", () => { + const event = builder + .setAuth("api-123", true) + .setUser("user-456") + .setEventContext({ eventType: "SDK_CALL" }) + .setSuccess(200) + .build(); + + expect(event.apiKeyId).toBe("api-123"); + expect(event.userId).toBe("user-456"); + expect(event.eventType).toBe("SDK_CALL"); + expect(event.outcome).toBe("success"); + }); + }); + }); + + describe("createWideEventBuilder", () => { + it("should create a new WideEventBuilder", () => { + const builder = createWideEventBuilder("req-123", "POST", "/api/events"); + expect(builder).toBeInstanceOf(WideEventBuilder); + }); + }); +}); diff --git a/src/__tests__/unit/errors/logger.test.ts b/src/__tests__/unit/errors/logger.test.ts new file mode 100644 index 0000000..7feba22 --- /dev/null +++ b/src/__tests__/unit/errors/logger.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from "vitest"; +import type { WideEvent } from "../../../errors/logger"; + +describe("WideEvent interface", () => { + describe("WideEvent structure", () => { + it("should have required fields", () => { + const event: WideEvent = { + requestId: "test-request-id", + method: "unary", + path: "/event.v1.EventService/RegisterEvent", + timestamp: "2026-01-31T10:00:00.000Z", + env: "test", + outcome: "success", + durationMs: 50, + }; + + expect(event.requestId).toBe("test-request-id"); + expect(event.method).toBe("unary"); + expect(event.path).toBe("/event.v1.EventService/RegisterEvent"); + expect(event.timestamp).toBe("2026-01-31T10:00:00.000Z"); + expect(event.env).toBe("test"); + expect(event.outcome).toBe("success"); + expect(event.durationMs).toBe(50); + }); + + it("should support success outcome", () => { + const event: WideEvent = { + requestId: "test-id", + method: "POST", + path: "/test", + timestamp: new Date().toISOString(), + env: "test", + outcome: "success", + durationMs: 100, + statusCode: 200, + }; + + expect(event.outcome).toBe("success"); + expect(event.statusCode).toBe(200); + }); + + it("should support error outcome with error details", () => { + const event: WideEvent = { + requestId: "test-id", + method: "POST", + path: "/test", + timestamp: new Date().toISOString(), + env: "test", + outcome: "error", + durationMs: 25, + statusCode: 400, + error: { + type: "VALIDATION_FAILED", + message: "userId: Required", + }, + }; + + expect(event.outcome).toBe("error"); + expect(event.statusCode).toBe(400); + expect(event.error?.type).toBe("VALIDATION_FAILED"); + expect(event.error?.message).toBe("userId: Required"); + }); + + it("should support auth context", () => { + const event: WideEvent = { + requestId: "test-id", + method: "POST", + path: "/test", + timestamp: new Date().toISOString(), + env: "test", + outcome: "success", + durationMs: 50, + apiKeyId: "api-key-123", + cacheHit: true, + }; + + expect(event.apiKeyId).toBe("api-key-123"); + expect(event.cacheHit).toBe(true); + }); + + it("should support user and event context", () => { + const event: WideEvent = { + requestId: "test-id", + method: "POST", + path: "/test", + timestamp: new Date().toISOString(), + env: "test", + outcome: "success", + durationMs: 50, + userId: "user-456", + eventType: "SDK_CALL", + eventCount: 5, + }; + + expect(event.userId).toBe("user-456"); + expect(event.eventType).toBe("SDK_CALL"); + expect(event.eventCount).toBe(5); + }); + + it("should support payment context", () => { + const event: WideEvent = { + requestId: "test-id", + method: "POST", + path: "/test", + timestamp: new Date().toISOString(), + env: "test", + outcome: "success", + durationMs: 50, + creditAmount: 5000, + debitAmount: 100, + priceAmount: 2500, + }; + + expect(event.creditAmount).toBe(5000); + expect(event.debitAmount).toBe(100); + expect(event.priceAmount).toBe(2500); + }); + + it("should support API key context", () => { + const event: WideEvent = { + requestId: "test-id", + method: "POST", + path: "/test", + timestamp: new Date().toISOString(), + env: "test", + outcome: "success", + durationMs: 50, + apiKeyName: "production-key", + apiKeyExpiration: "2027-01-31T00:00:00.000Z", + }; + + expect(event.apiKeyName).toBe("production-key"); + expect(event.apiKeyExpiration).toBe("2027-01-31T00:00:00.000Z"); + }); + + it("should support webhook context", () => { + const event: WideEvent = { + requestId: "test-id", + method: "POST", + path: "/test", + timestamp: new Date().toISOString(), + env: "test", + outcome: "success", + durationMs: 50, + webhookEvent: "order_created", + orderId: "order-123", + }; + + expect(event.webhookEvent).toBe("order_created"); + expect(event.orderId).toBe("order-123"); + }); + + it("should support extensible fields", () => { + const event: WideEvent = { + requestId: "test-id", + method: "POST", + path: "/test", + timestamp: new Date().toISOString(), + env: "test", + outcome: "success", + durationMs: 50, + customField: "custom-value", + anotherField: 42, + }; + + expect(event.customField).toBe("custom-value"); + expect(event.anotherField).toBe(42); + }); + }); +}); diff --git a/src/__tests__/unit/http/createdCheckout.test.ts b/src/__tests__/unit/http/createdCheckout.test.ts index 5875420..6f08a97 100644 --- a/src/__tests__/unit/http/createdCheckout.test.ts +++ b/src/__tests__/unit/http/createdCheckout.test.ts @@ -2,17 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import { createHmac } from "node:crypto"; - -// Shared mocks - initialize functions after vi is available -const loggerMock = { - logOperationInfo: vi.fn(), - logOperationError: vi.fn(), - logWarning: vi.fn(), - logDebug: vi.fn(), -}; - -const getStorageAdapterMock = vi.fn(); -const lemonSqueezySetupMock = vi.fn(); +import { WideEventBuilder } from "../../../context/requestContext"; // Track Payment constructor calls const paymentConstructorCalls: Array<{ userId: string; data: unknown }> = []; @@ -41,16 +31,6 @@ class PaymentMock { } } -// Mock modules -vi.mock("../../../errors/logger.ts", () => ({ - logger: { - logOperationInfo: vi.fn(), - logOperationError: vi.fn(), - logWarning: vi.fn(), - logDebug: vi.fn(), - }, -})); - vi.mock("../../../factory/StorageAdapterFactory.ts", () => ({ StorageAdapterFactory: { getStorageAdapter: vi.fn(), @@ -120,18 +100,26 @@ function emitBody(req: MockRequest, body: string): void { }); } +/** + * Create a mock WideEventBuilder for testing. + */ +function createMockBuilder(): WideEventBuilder { + return new WideEventBuilder( + "test-request-id", + "POST", + "/webhooks/lemonsqueezy/createdCheckout" + ); +} + describe("handleLemonSqueezyWebhook", () => { - let loggerModule: any; let storageModule: any; let lsModule: any; beforeEach(async () => { - vi.resetModules(); vi.clearAllMocks(); paymentConstructorCalls.length = 0; // Import mocked modules - loggerModule = await import("../../../errors/logger.ts"); storageModule = await import("../../../factory/StorageAdapterFactory.ts"); lsModule = await import("@lemonsqueezy/lemonsqueezy.js"); @@ -147,6 +135,7 @@ describe("handleLemonSqueezyWebhook", () => { const req = new MockRequest() as unknown as IncomingMessage; const res = new TestResponse() as unknown as ServerResponse; + const builder = createMockBuilder(); const payload = JSON.stringify({ meta: { event_name: "order_created" }, @@ -156,18 +145,10 @@ describe("handleLemonSqueezyWebhook", () => { (req as any).headers["x-signature"] = "any"; emitBody(req as MockRequest, payload); - await handleWebhook(req, res); + await handleWebhook(req, res, builder); expect((res as any).statusCode).toBe(500); expect((res as any).body).toContain("Webhook secret not configured"); - expect(loggerModule.logger.logOperationError).toHaveBeenCalledWith( - "LemonSqueezyWebhook", - "config", - "MISSING_WEBHOOK_SECRET", - "Webhook secret not configured", - undefined, - {} - ); }); it("returns 401 for invalid signature", async () => { @@ -175,6 +156,7 @@ describe("handleLemonSqueezyWebhook", () => { const req = new MockRequest() as unknown as IncomingMessage; const res = new TestResponse() as unknown as ServerResponse; + const builder = createMockBuilder(); const payload = JSON.stringify({ meta: { event_name: "order_created" }, @@ -184,18 +166,10 @@ describe("handleLemonSqueezyWebhook", () => { (req as any).headers["x-signature"] = "invalid-signature"; emitBody(req as MockRequest, payload); - await handleWebhook(req, res); + await handleWebhook(req, res, builder); expect((res as any).statusCode).toBe(401); expect((res as any).body).toContain("Invalid signature"); - expect(loggerModule.logger.logOperationError).toHaveBeenCalledWith( - "LemonSqueezyWebhook", - "validate_signature", - "INVALID_SIGNATURE", - "Invalid webhook signature", - undefined, - {} - ); }); it("returns 400 for invalid JSON payload", async () => { @@ -206,6 +180,7 @@ describe("handleLemonSqueezyWebhook", () => { const req = new MockRequest() as unknown as IncomingMessage; const res = new TestResponse() as unknown as ServerResponse; + const builder = createMockBuilder(); const rawBody = "{"; // invalid JSON const signature = createHmac("sha256", secret) @@ -215,18 +190,10 @@ describe("handleLemonSqueezyWebhook", () => { (req as any).headers["x-signature"] = signature; emitBody(req as MockRequest, rawBody); - await handleWebhook(req, res); + await handleWebhook(req, res, builder); expect((res as any).statusCode).toBe(400); expect((res as any).body).toContain("Invalid JSON payload"); - expect(loggerModule.logger.logOperationError).toHaveBeenCalledWith( - "LemonSqueezyWebhook", - "parse_payload", - "INVALID_JSON", - "Invalid JSON payload", - expect.any(Error), - {} - ); }); it("ignores non-order_created events", async () => { @@ -237,6 +204,7 @@ describe("handleLemonSqueezyWebhook", () => { const req = new MockRequest() as unknown as IncomingMessage; const res = new TestResponse() as unknown as ServerResponse; + const builder = createMockBuilder(); const payload = JSON.stringify({ meta: { event_name: "subscription_created" }, @@ -250,7 +218,7 @@ describe("handleLemonSqueezyWebhook", () => { (req as any).headers["x-signature"] = signature; emitBody(req as MockRequest, payload); - await handleWebhook(req, res); + await handleWebhook(req, res, builder); expect((res as any).statusCode).toBe(200); expect((res as any).body).toContain("Event ignored"); @@ -268,6 +236,7 @@ describe("handleLemonSqueezyWebhook", () => { const req = new MockRequest() as unknown as IncomingMessage; const res = new TestResponse() as unknown as ServerResponse; + const builder = createMockBuilder(); const payload = JSON.stringify({ meta: { @@ -285,18 +254,10 @@ describe("handleLemonSqueezyWebhook", () => { (req as any).headers["x-signature"] = signature; emitBody(req as MockRequest, payload); - await handleWebhook(req, res); + await handleWebhook(req, res, builder); expect((res as any).statusCode).toBe(400); expect((res as any).body).toContain("Missing user_id in webhook payload"); - expect(loggerModule.logger.logOperationError).toHaveBeenCalledWith( - "LemonSqueezyWebhook", - "validate_payload", - "MISSING_USER_ID", - "Missing user_id in webhook payload", - undefined, - {} - ); }); it("returns 400 when apiKeyId is missing", async () => { @@ -307,6 +268,7 @@ describe("handleLemonSqueezyWebhook", () => { const req = new MockRequest() as unknown as IncomingMessage; const res = new TestResponse() as unknown as ServerResponse; + const builder = createMockBuilder(); const payload = JSON.stringify({ meta: { @@ -326,18 +288,10 @@ describe("handleLemonSqueezyWebhook", () => { (req as any).headers["x-signature"] = signature; emitBody(req as MockRequest, payload); - await handleWebhook(req, res); + await handleWebhook(req, res, builder); expect((res as any).statusCode).toBe(400); expect((res as any).body).toContain("Missing apiKeyId in webhook payload"); - expect(loggerModule.logger.logOperationError).toHaveBeenCalledWith( - "LemonSqueezyWebhook", - "validate_payload", - "MISSING_API_KEY_ID", - "Missing apiKeyId in webhook payload", - undefined, - { userId: "user-123" } - ); }); it("stores payment and returns 200 on success", async () => { @@ -345,9 +299,9 @@ describe("handleLemonSqueezyWebhook", () => { process.env.LEMON_SQUEEZY_WEBHOOK_SECRET = secret; const adapterAddMock = vi.fn().mockResolvedValue(undefined); - vi.mocked( - storageModule.StorageAdapterFactory.getStorageAdapter - ).mockResolvedValue({ + const getStorageAdapterMock = storageModule.StorageAdapterFactory + .getStorageAdapter as ReturnType; + getStorageAdapterMock.mockResolvedValue({ add: adapterAddMock, } as any); @@ -355,6 +309,7 @@ describe("handleLemonSqueezyWebhook", () => { const req = new MockRequest() as unknown as IncomingMessage; const res = new TestResponse() as unknown as ServerResponse; + const builder = createMockBuilder(); const payload = JSON.stringify({ meta: { @@ -385,7 +340,7 @@ describe("handleLemonSqueezyWebhook", () => { (req as any).headers["x-signature"] = signature; emitBody(req as MockRequest, payload); - await handleWebhook(req, res); + await handleWebhook(req, res, builder); expect(paymentConstructorCalls.length).toBe(1); expect(paymentConstructorCalls[0]).toEqual({ @@ -396,10 +351,7 @@ describe("handleLemonSqueezyWebhook", () => { expect( storageModule.StorageAdapterFactory.getStorageAdapter ).toHaveBeenCalledTimes(1); - const adapterCall = vi.mocked( - storageModule.StorageAdapterFactory.getStorageAdapter - ).mock.calls[0]; - expect(adapterCall[1]).toBe("api-key-456"); + expect(getStorageAdapterMock.mock.calls[0]?.[1]).toBe("api-key-456"); expect(adapterAddMock).toHaveBeenCalledTimes(1); @@ -413,9 +365,9 @@ describe("handleLemonSqueezyWebhook", () => { const dbError = new Error("DB error"); const adapterAddMock = vi.fn().mockRejectedValue(dbError); - vi.mocked( - storageModule.StorageAdapterFactory.getStorageAdapter - ).mockResolvedValue({ + const getStorageAdapterMock = storageModule.StorageAdapterFactory + .getStorageAdapter as ReturnType; + getStorageAdapterMock.mockResolvedValue({ add: adapterAddMock, } as any); @@ -423,6 +375,7 @@ describe("handleLemonSqueezyWebhook", () => { const req = new MockRequest() as unknown as IncomingMessage; const res = new TestResponse() as unknown as ServerResponse; + const builder = createMockBuilder(); const payload = JSON.stringify({ meta: { @@ -453,19 +406,10 @@ describe("handleLemonSqueezyWebhook", () => { (req as any).headers["x-signature"] = signature; emitBody(req as MockRequest, payload); - await handleWebhook(req, res); + await handleWebhook(req, res, builder); expect((res as any).statusCode).toBe(500); expect((res as any).body).toContain("Database error"); - - expect(loggerModule.logger.logOperationError).toHaveBeenCalledWith( - "LemonSqueezyWebhook", - "database", - "DATABASE_ERROR", - "Database error while storing payment", - dbError, - { userId: "user-123", apiKeyId: "api-key-456" } - ); }); it("returns 500 on unexpected errors (e.g. readBody error)", async () => { @@ -476,23 +420,16 @@ describe("handleLemonSqueezyWebhook", () => { const req = new MockRequest() as unknown as IncomingMessage; const res = new TestResponse() as unknown as ServerResponse; + const builder = createMockBuilder(); // Emit an error instead of data/end so readBody rejects setImmediate(() => { (req as MockRequest).emit("error", new Error("read error")); }); - await handleWebhook(req, res); + await handleWebhook(req, res, builder); expect((res as any).statusCode).toBe(500); expect((res as any).body).toContain("Internal server error"); - expect(loggerModule.logger.logOperationError).toHaveBeenCalledWith( - "LemonSqueezyWebhook", - "failed", - "UNEXPECTED_ERROR", - "Unexpected error in webhook handler", - expect.any(Error), - {} - ); }); }); diff --git a/src/__tests__/unit/interceptors/auth.test.ts b/src/__tests__/unit/interceptors/auth.test.ts index d012792..43189e3 100644 --- a/src/__tests__/unit/interceptors/auth.test.ts +++ b/src/__tests__/unit/interceptors/auth.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { authInterceptor, no_auth } from "../../../interceptors/auth"; import * as dbModule from "../../../storage/db/postgres/db"; import * as hashModule from "../../../utils/hashAPIKey"; @@ -12,6 +12,10 @@ describe("authInterceptor", () => { contextValues: new Map(), }); + afterEach(() => { + vi.restoreAllMocks(); + }); + beforeEach(() => { vi.clearAllMocks(); diff --git a/src/__tests__/unit/utils/hashAPIKey.test.ts b/src/__tests__/unit/utils/hashAPIKey.test.ts index 68c3f07..7f23868 100644 --- a/src/__tests__/unit/utils/hashAPIKey.test.ts +++ b/src/__tests__/unit/utils/hashAPIKey.test.ts @@ -1,6 +1,11 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeAll } from "vitest"; import { hashAPIKey, verifyAPIKey } from "../../../utils/hashAPIKey"; +beforeAll(() => { + // Set up test environment variable for HMAC hashing + process.env.HMAC_SECRET = "test-hmac-secret-for-unit-tests"; +}); + describe("hashAPIKey", () => { it("produces deterministic hash for same input", () => { const key = "scrn_test_12345678901234567890123456"; diff --git a/src/__tests__/unit/utils/parseExpr.test.ts b/src/__tests__/unit/utils/parseExpr.test.ts new file mode 100644 index 0000000..04cbd5e --- /dev/null +++ b/src/__tests__/unit/utils/parseExpr.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { EventError } from "../../../errors/event"; + +// Mock fetchTagAmount before importing parseExpr +vi.mock("../../../utils/fetchTagAmount", () => ({ + fetchTagAmount: vi.fn(), +})); + +import { + parseAndEvaluateExpr, + extractTagNames, + validateExprSyntax, +} from "../../../utils/parseExpr"; +import { fetchTagAmount } from "../../../utils/fetchTagAmount"; + +const mockFetchTagAmount = fetchTagAmount as Mock; + +describe("parseExpr", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("extractTagNames", () => { + it("extracts single tag name", () => { + const tags = extractTagNames("tag(PREMIUM_CALL)"); + expect(tags).toEqual(["PREMIUM_CALL"]); + }); + + it("extracts multiple tag names", () => { + const tags = extractTagNames("add(tag(PREMIUM),tag(FEE),100)"); + expect(tags).toEqual(["PREMIUM", "FEE"]); + }); + + it("extracts unique tag names when duplicates exist", () => { + const tags = extractTagNames("add(tag(FEE),mul(tag(FEE),2))"); + expect(tags).toEqual(["FEE"]); + }); + + it("returns empty array when no tags present", () => { + const tags = extractTagNames("add(100,200,300)"); + expect(tags).toEqual([]); + }); + + it("handles complex nested expressions", () => { + const tags = extractTagNames( + "add(mul(tag(PREMIUM_CALL),3),tag(EXTRA_FEE),250)" + ); + expect(tags).toContain("PREMIUM_CALL"); + expect(tags).toContain("EXTRA_FEE"); + expect(tags).toHaveLength(2); + }); + }); + + describe("validateExprSyntax", () => { + it("accepts valid simple expressions", () => { + expect(() => validateExprSyntax("250")).not.toThrow(); + expect(() => validateExprSyntax("add(100,200)")).not.toThrow(); + expect(() => validateExprSyntax("tag(PREMIUM)")).not.toThrow(); + }); + + it("accepts valid complex expressions", () => { + expect(() => + validateExprSyntax("add(mul(tag(PREMIUM_CALL),3),tag(EXTRA_FEE),250)") + ).not.toThrow(); + }); + + it("rejects empty expressions", () => { + expect(() => validateExprSyntax("")).toThrow(/cannot be empty/); + expect(() => validateExprSyntax(" ")).toThrow(/cannot be empty/); + }); + + it("rejects unmatched opening parenthesis", () => { + expect(() => validateExprSyntax("add(100,200")).toThrow( + /unmatched opening parenthesis/ + ); + }); + + it("rejects unmatched closing parenthesis", () => { + expect(() => validateExprSyntax("add100,200)")).toThrow( + /unmatched closing parenthesis/ + ); + }); + + it("rejects unknown functions", () => { + expect(() => validateExprSyntax("unknown(100)")).toThrow( + /Unknown function in expression: unknown/ + ); + }); + + it("rejects invalid tag name format", () => { + expect(() => validateExprSyntax("tag(lowercase)")).toThrow( + /Invalid tag name format/ + ); + expect(() => validateExprSyntax("tag(123INVALID)")).toThrow( + /Invalid tag name format/ + ); + }); + + it("accepts valid tag name formats", () => { + expect(() => validateExprSyntax("tag(VALID_TAG)")).not.toThrow(); + expect(() => validateExprSyntax("tag(TAG123)")).not.toThrow(); + expect(() => validateExprSyntax("tag(_UNDERSCORE_START)")).not.toThrow(); + }); + }); + + describe("parseAndEvaluateExpr", () => { + describe("simple amounts", () => { + it("evaluates plain numbers", async () => { + const result = await parseAndEvaluateExpr("250"); + expect(result).toBe(250); + }); + + it("evaluates decimal numbers and floors result", async () => { + const result = await parseAndEvaluateExpr("250.7"); + expect(result).toBe(250); + }); + }); + + describe("add operation", () => { + it("adds two numbers", async () => { + const result = await parseAndEvaluateExpr("add(100,200)"); + expect(result).toBe(300); + }); + + it("adds multiple numbers", async () => { + const result = await parseAndEvaluateExpr("add(100,200,300,400)"); + expect(result).toBe(1000); + }); + }); + + describe("sub operation", () => { + it("subtracts two numbers", async () => { + const result = await parseAndEvaluateExpr("sub(500,200)"); + expect(result).toBe(300); + }); + + it("handles negative results", async () => { + const result = await parseAndEvaluateExpr("sub(100,250)"); + expect(result).toBe(-150); + }); + }); + + describe("mul operation", () => { + it("multiplies two numbers", async () => { + const result = await parseAndEvaluateExpr("mul(10,20)"); + expect(result).toBe(200); + }); + + it("multiplies multiple numbers", async () => { + const result = await parseAndEvaluateExpr("mul(2,3,4,5)"); + expect(result).toBe(120); + }); + }); + + describe("div operation", () => { + it("divides two numbers", async () => { + const result = await parseAndEvaluateExpr("div(100,4)"); + expect(result).toBe(25); + }); + + it("floors the result of division", async () => { + const result = await parseAndEvaluateExpr("div(100,3)"); + expect(result).toBe(33); // 100/3 = 33.333... → 33 + }); + + it("throws error on division by zero", async () => { + await expect(parseAndEvaluateExpr("div(100,0)")).rejects.toThrow( + /Division by zero/ + ); + }); + }); + + describe("tag resolution", () => { + it("resolves single tag", async () => { + mockFetchTagAmount.mockResolvedValue(500); + + const result = await parseAndEvaluateExpr("tag(PREMIUM_CALL)"); + + expect(result).toBe(500); + expect(mockFetchTagAmount).toHaveBeenCalledWith( + "PREMIUM_CALL", + "Tag not found: PREMIUM_CALL" + ); + }); + + it("resolves multiple tags", async () => { + mockFetchTagAmount + .mockResolvedValueOnce(100) // PREMIUM + .mockResolvedValueOnce(50); // FEE + + const result = await parseAndEvaluateExpr("add(tag(PREMIUM),tag(FEE))"); + + expect(result).toBe(150); + expect(mockFetchTagAmount).toHaveBeenCalledTimes(2); + }); + + it("throws error when tag not found", async () => { + mockFetchTagAmount.mockRejectedValue( + EventError.validationFailed("Tag not found: UNKNOWN_TAG") + ); + + await expect(parseAndEvaluateExpr("tag(UNKNOWN_TAG)")).rejects.toThrow( + /Tag not found/ + ); + }); + }); + + describe("complex expressions", () => { + it("evaluates nested operations", async () => { + // add(mul(10,3),sub(100,20),50) = 30 + 80 + 50 = 160 + const result = await parseAndEvaluateExpr( + "add(mul(10,3),sub(100,20),50)" + ); + expect(result).toBe(160); + }); + + it("evaluates expression with tags and operations", async () => { + mockFetchTagAmount + .mockResolvedValueOnce(100) // PREMIUM_CALL + .mockResolvedValueOnce(50); // EXTRA_FEE + + // add(mul(tag(PREMIUM_CALL),3),tag(EXTRA_FEE),250) = 300 + 50 + 250 = 600 + const result = await parseAndEvaluateExpr( + "add(mul(tag(PREMIUM_CALL),3),tag(EXTRA_FEE),250)" + ); + + expect(result).toBe(600); + }); + + it("handles deeply nested expressions", async () => { + // div(mul(add(10,20),sub(50,10)),4) = div(mul(30,40),4) = div(1200,4) = 300 + const result = await parseAndEvaluateExpr( + "div(mul(add(10,20),sub(50,10)),4)" + ); + expect(result).toBe(300); + }); + }); + + describe("error handling", () => { + it("throws EventError for invalid syntax", async () => { + await expect(parseAndEvaluateExpr("add(100,")).rejects.toThrow( + /unmatched opening parenthesis/ + ); + }); + + it("throws EventError for unknown functions", async () => { + await expect(parseAndEvaluateExpr("unknown(100)")).rejects.toThrow( + /Unknown function/ + ); + }); + + it("throws EventError for empty expression", async () => { + await expect(parseAndEvaluateExpr("")).rejects.toThrow( + /cannot be empty/ + ); + }); + }); + }); +}); diff --git a/src/context/requestContext.ts b/src/context/requestContext.ts new file mode 100644 index 0000000..b892ba3 --- /dev/null +++ b/src/context/requestContext.ts @@ -0,0 +1,170 @@ +import { createContextKey } from "@connectrpc/connect"; +import { randomUUID } from "node:crypto"; +import type { WideEvent } from "../errors/logger"; + +/** + * Context key for accessing the WideEventBuilder during request processing. + */ +export const wideEventContextKey = createContextKey( + null +); + +/** + * Generate a unique request ID using UUID v4. + */ +export function generateRequestId(): string { + return randomUUID(); +} + +/** + * Extract the service method path from a full URL. + * Converts "https://example.com/event.v1.EventService/RegisterEvent" to "/event.v1.EventService/RegisterEvent" + */ +function extractPath(url: string): string { + try { + const urlObj = new URL(url); + return urlObj.pathname; + } catch { + // If URL parsing fails, try to extract path manually + const pathMatch = url.match(/(?:https?:\/\/[^/]+)?(\/.+)/); + return pathMatch?.[1] || url; + } +} + +/** + * Builder class for constructing wide events during request processing. + * Each request gets one builder instance that accumulates context throughout + * the request lifecycle, then emits a single wide event at completion. + */ +export class WideEventBuilder { + private event: Partial; + private startTime: number; + + constructor(requestId: string, method: string, url: string) { + this.startTime = Date.now(); + this.event = { + requestId, + method, + path: extractPath(url), + timestamp: new Date().toISOString(), + env: process.env.NODE_ENV || "development", + }; + } + + /** + * Set authentication context after successful auth. + */ + setAuth(apiKeyId: string | number, cacheHit: boolean): this { + this.event.apiKeyId = apiKeyId; + this.event.cacheHit = cacheHit; + return this; + } + + /** + * Set user context. + */ + setUser(userId: string | number): this { + this.event.userId = userId; + return this; + } + + /** + * Set event processing context. + */ + setEventContext(data: { eventType?: string; eventCount?: number }): this { + if (data.eventType !== undefined) this.event.eventType = data.eventType; + if (data.eventCount !== undefined) this.event.eventCount = data.eventCount; + return this; + } + + /** + * Set payment/pricing context. + */ + setPaymentContext(data: { + creditAmount?: number; + debitAmount?: number; + priceAmount?: number; + }): this { + if (data.creditAmount !== undefined) + this.event.creditAmount = data.creditAmount; + if (data.debitAmount !== undefined) + this.event.debitAmount = data.debitAmount; + if (data.priceAmount !== undefined) + this.event.priceAmount = data.priceAmount; + return this; + } + + /** + * Set API key creation context. + */ + setApiKeyContext(data: { name?: string; expiration?: string }): this { + if (data.name !== undefined) this.event.apiKeyName = data.name; + if (data.expiration !== undefined) + this.event.apiKeyExpiration = data.expiration; + return this; + } + + /** + * Set webhook processing context. + */ + setWebhookContext(data: { webhookEvent?: string; orderId?: string }): this { + if (data.webhookEvent !== undefined) + this.event.webhookEvent = data.webhookEvent; + if (data.orderId !== undefined) this.event.orderId = data.orderId; + return this; + } + + /** + * Add arbitrary business context. + */ + addContext(data: Record): this { + Object.assign(this.event, data); + return this; + } + + /** + * Set the request outcome on success. + */ + setSuccess(statusCode: number = 200): this { + this.event.outcome = "success"; + this.event.statusCode = statusCode; + return this; + } + + /** + * Set the request outcome on error. + */ + setError( + statusCode: number, + error: { type: string; message: string; cause?: string; stack?: string } + ): this { + this.event.outcome = "error"; + this.event.statusCode = statusCode; + this.event.error = error; + return this; + } + + /** + * Build the final wide event with duration calculation. + */ + build(): WideEvent { + const durationMs = Date.now() - this.startTime; + + return { + ...this.event, + outcome: this.event.outcome || "success", + durationMs, + } as WideEvent; + } +} + +/** + * Factory function to create a new WideEventBuilder for a request. + */ +export function createWideEventBuilder( + requestId: string, + method: string, + url: string +): WideEventBuilder { + return new WideEventBuilder(requestId, method, url); +} diff --git a/src/errors/apikey.ts b/src/errors/apikey.ts index a2c3092..01f7691 100644 --- a/src/errors/apikey.ts +++ b/src/errors/apikey.ts @@ -99,9 +99,10 @@ export class APIKeyError extends ConnectError { } static unknown(originalError?: Error): APIKeyError { + const details = originalError?.message || "No details available"; return new APIKeyError({ type: APIKeyErrorType.UNKNOWN, - message: "An unknown API key processing error occurred", + message: `Unexpected API key error: ${details}`, code: Code.Internal, originalError, }); diff --git a/src/errors/auth.ts b/src/errors/auth.ts index bfec51a..25026ae 100644 --- a/src/errors/auth.ts +++ b/src/errors/auth.ts @@ -84,9 +84,10 @@ export class AuthError extends ConnectError { } static unknown(originalError?: Error): AuthError { + const details = originalError?.message || "No details available"; return new AuthError({ type: AuthErrorType.UNKNOWN, - message: "An unknown authentication error occurred", + message: `Unexpected authentication error: ${details}`, code: Code.Internal, originalError, }); diff --git a/src/errors/event.ts b/src/errors/event.ts index 9c23c7e..32b068b 100644 --- a/src/errors/event.ts +++ b/src/errors/event.ts @@ -110,9 +110,10 @@ export class EventError extends ConnectError { } static unknown(originalError?: Error): EventError { + const details = originalError?.message || "No details available"; return new EventError({ type: EventErrorType.UNKNOWN, - message: "An unknown event processing error occurred", + message: `Unexpected event processing error: ${details}`, code: Code.Internal, originalError, }); diff --git a/src/errors/logger.ts b/src/errors/logger.ts index f4a0892..0b5a201 100644 --- a/src/errors/logger.ts +++ b/src/errors/logger.ts @@ -1,32 +1,74 @@ import pino, { type Logger as PinoLogger } from "pino"; -interface LogContext { - errorType?: string; - location?: string; - endpoint?: string; - [key: string]: unknown; -} +/** + * Wide Event interface for structured logging. + * Each request emits exactly one wide event at completion containing all relevant context. + * + * @see https://stripe.com/blog/canonical-log-lines + * @see https://loggingsucks.com + */ +export interface WideEvent { + // Request identification + requestId: string; + method: string; + path: string; + timestamp: string; + + // Environment context + env: string; + + // Auth context (added during request processing) + apiKeyId?: string | number; + cacheHit?: boolean; -interface OperationContext extends LogContext { - operation: string; - stage?: string; - endpoint?: string; + // User/business context (added during request processing) userId?: string | number; - apiKeyId?: string | number; - eventId?: string | number; - requestId?: string; + eventType?: string; + eventCount?: number; + creditAmount?: number; + debitAmount?: number; + priceAmount?: number; + + // API key creation context + apiKeyName?: string; + apiKeyExpiration?: string; + + // Webhook context + webhookEvent?: string; + orderId?: string; + + // Outcome (added at request completion) + statusCode?: number; + outcome: "success" | "error"; + durationMs: number; + + // Error details (if applicable) + error?: { + type: string; + message: string; + cause?: string; + stack?: string; // Included in development mode only + }; + + // Extensible for additional context + [key: string]: unknown; } -class ErrorLogger { - private logger: PinoLogger; - private errorCounts = new Map(); - private suppressedErrors = new Set(); - private maxDuplicates = 3; +/** + * Wide Event Logger following the canonical log lines pattern. + * Emits one structured JSON event per request with all relevant context. + * + * Uses only two log levels: + * - info: successful requests + * - error: failed requests + */ +class WideEventLogger { + private pino: PinoLogger; constructor() { const isDev = process.env.NODE_ENV !== "production"; - this.logger = pino({ + this.pino = pino({ level: process.env.LOG_LEVEL || "info", transport: isDev ? { @@ -39,119 +81,54 @@ class ErrorLogger { }, } : undefined, + // In production, output raw JSON for log aggregation systems + formatters: { + level: (label) => ({ level: label }), + }, }); } - private getErrorKey(errorType: string, message: string): string { - return `${errorType}:${message}`; - } - - private shouldLog(errorType: string, message: string): boolean { - const key = this.getErrorKey(errorType, message); - const count = this.errorCounts.get(key) || 0; - - if (count >= this.maxDuplicates) { - this.suppressedErrors.add(key); - return false; - } - - this.errorCounts.set(key, count + 1); - return true; - } - - logError( - errorType: string, - message: string, - originalError?: Error, - context?: LogContext - ): void { - if (!this.shouldLog(errorType, message)) { - return; + /** + * Emit a wide event. Uses error level for failed requests, info for successful. + */ + emit(event: WideEvent): void { + // Remove undefined values for cleaner output + const cleanEvent = Object.fromEntries( + Object.entries(event).filter(([, value]) => value !== undefined) + ); + + if (event.outcome === "error") { + this.pino.error(cleanEvent); + } else { + this.pino.info(cleanEvent); } - - const logContext: Record = { - errorType, - ...context, - }; - - if (originalError) { - logContext.cause = originalError.message; - logContext.location = this.extractLocation(originalError); - } - - this.logger.error(logContext, message); - } - - logWarning(message: string, context?: LogContext): void { - this.logger.warn(context || {}, message); - } - - logInfo(message: string, context?: LogContext): void { - this.logger.info(context || {}, message); - } - - logDebug(message: string, context?: LogContext): void { - this.logger.debug(context || {}, message); - } - - private extractLocation(error: Error): string { - if (!error?.stack) return "unknown location"; - - const lines = error.stack.split("\n"); - for (let i = 1; i < lines.length; i++) { - const line = lines[i]?.trim(); - if (line?.startsWith("at ")) { - return line.replace("at ", "").split(" ")[0] || "unknown location"; - } - } - return "unknown location"; - } - - resetErrorCounts(): void { - this.errorCounts.clear(); - this.suppressedErrors.clear(); - } - - getSuppressedErrorCount(): number { - return this.suppressedErrors.size; - } - - getSuppressedErrors(): string[] { - return Array.from(this.suppressedErrors); - } - - logOperationError( - operation: string, - stage: string, - errorType: string, - message: string, - originalError?: Error, - extra?: Omit - ): void { - this.logError(errorType, message, originalError, { - operation, - stage, - ...extra, - }); } - logOperationInfo( - operation: string, - stage: string, - message: string, - extra?: Omit - ): void { - this.logInfo(message, { operation, stage, ...extra }); + /** + * Log server lifecycle events (startup, shutdown). + * These are the only non-request logs allowed. + */ + lifecycle(message: string, context?: Record): void { + this.pino.info({ ...context, lifecycle: true }, message); } - logOperationDebug( - operation: string, - stage: string, - message: string, - extra?: Omit - ): void { - this.logDebug(message, { operation, stage, ...extra }); + /** + * Log fatal errors that prevent the server from operating. + */ + fatal(message: string, error?: Error): void { + this.pino.fatal( + { + error: error + ? { + type: error.name, + message: error.message, + stack: error.stack, + } + : undefined, + }, + message + ); } } -export const logger = new ErrorLogger(); +export const logger = new WideEventLogger(); diff --git a/src/errors/payment.ts b/src/errors/payment.ts index acf1e20..39ae81f 100644 --- a/src/errors/payment.ts +++ b/src/errors/payment.ts @@ -169,9 +169,10 @@ export class PaymentError extends ConnectError { } static unknown(originalError?: Error): PaymentError { + const details = originalError?.message || "No details available"; return new PaymentError({ type: PaymentErrorType.UNKNOWN, - message: "An unknown payment processing error occurred", + message: `Unexpected payment error: ${details}`, code: Code.Internal, originalError, }); diff --git a/src/errors/storage.ts b/src/errors/storage.ts index ae8c91e..6262c26 100644 --- a/src/errors/storage.ts +++ b/src/errors/storage.ts @@ -229,9 +229,10 @@ export class StorageError extends ConnectError { } static unknown(originalError?: Error): StorageError { + const details = originalError?.message || "No details available"; return new StorageError({ type: StorageErrorType.UNKNOWN, - message: "An unknown storage error occurred", + message: `Unexpected storage error: ${details}`, code: Code.Internal, originalError, }); diff --git a/src/gen/event/v1/event_pb.ts b/src/gen/event/v1/event_pb.ts index a67a220..4f15c6d 100644 --- a/src/gen/event/v1/event_pb.ts +++ b/src/gen/event/v1/event_pb.ts @@ -22,7 +22,7 @@ import type { Message } from "@bufbuild/protobuf"; export const file_event_v1_event: GenFile = /*@__PURE__*/ fileDesc( - "ChRldmVudC92MS9ldmVudC5wcm90bxIIZXZlbnQudjEidwoUUmVnaXN0ZXJFdmVudFJlcXVlc3QSIQoEdHlwZRgBIAEoDjITLmV2ZW50LnYxLkV2ZW50VHlwZRIOCgZ1c2VySWQYAiABKAkSJAoHc2RrQ2FsbBgDIAEoCzIRLmV2ZW50LnYxLlNES0NhbGxIAEIGCgRkYXRhIl8KB1NES0NhbGwSKgoLc2RrQ2FsbFR5cGUYASABKA4yFS5ldmVudC52MS5TREtDYWxsVHlwZRIQCgZhbW91bnQYAiABKAJIABINCgN0YWcYAyABKAlIAEIHCgVkZWJpdCInChVSZWdpc3RlckV2ZW50UmVzcG9uc2USDgoGcmFuZG9tGAEgASgJIn8KElN0cmVhbUV2ZW50UmVxdWVzdBIhCgR0eXBlGAEgASgOMhMuZXZlbnQudjEuRXZlbnRUeXBlEg4KBnVzZXJJZBgCIAEoCRIuCgxhaVRva2VuVXNhZ2UYBCABKAsyFi5ldmVudC52MS5BSVRva2VuVXNhZ2VIAEIGCgRkYXRhIr0BCgxBSVRva2VuVXNhZ2USDQoFbW9kZWwYASABKAkSEwoLaW5wdXRUb2tlbnMYAiABKAUSFAoMb3V0cHV0VG9rZW5zGAMgASgFEhUKC2lucHV0QW1vdW50GAQgASgCSAASEgoIaW5wdXRUYWcYBSABKAlIABIWCgxvdXRwdXRBbW91bnQYBiABKAJIARITCglvdXRwdXRUYWcYByABKAlIAUIMCgppbnB1dERlYml0Qg0KC291dHB1dERlYml0Ij8KE1N0cmVhbUV2ZW50UmVzcG9uc2USFwoPZXZlbnRzUHJvY2Vzc2VkGAEgASgFEg8KB21lc3NhZ2UYAiABKAkqSQoJRXZlbnRUeXBlEhoKFkVWRU5UX1RZUEVfVU5TUEVDSUZJRUQQABIMCghTREtfQ0FMTBABEhIKDkFJX1RPS0VOX1VTQUdFEAIqSAoLU0RLQ2FsbFR5cGUSGwoXU0RLQ2FsbFR5cGVfVU5TUEVDSUZJRUQQABIHCgNSQVcQARITCg9NSURETEVXQVJFX0NBTEwQAjKzAQoMRXZlbnRTZXJ2aWNlElIKDVJlZ2lzdGVyRXZlbnQSHi5ldmVudC52MS5SZWdpc3RlckV2ZW50UmVxdWVzdBofLmV2ZW50LnYxLlJlZ2lzdGVyRXZlbnRSZXNwb25zZSIAEk8KDFN0cmVhbUV2ZW50cxIcLmV2ZW50LnYxLlN0cmVhbUV2ZW50UmVxdWVzdBodLmV2ZW50LnYxLlN0cmVhbUV2ZW50UmVzcG9uc2UiACgBYgZwcm90bzM" + "ChRldmVudC92MS9ldmVudC5wcm90bxIIZXZlbnQudjEidwoUUmVnaXN0ZXJFdmVudFJlcXVlc3QSIQoEdHlwZRgBIAEoDjITLmV2ZW50LnYxLkV2ZW50VHlwZRIOCgZ1c2VySWQYAiABKAkSJAoHc2RrQ2FsbBgDIAEoCzIRLmV2ZW50LnYxLlNES0NhbGxIAEIGCgRkYXRhIm8KB1NES0NhbGwSKgoLc2RrQ2FsbFR5cGUYASABKA4yFS5ldmVudC52MS5TREtDYWxsVHlwZRIQCgZhbW91bnQYAiABKAJIABINCgN0YWcYAyABKAlIABIOCgRleHByGAQgASgJSABCBwoFZGViaXQiJwoVUmVnaXN0ZXJFdmVudFJlc3BvbnNlEg4KBnJhbmRvbRgBIAEoCSKlAQoSU3RyZWFtRXZlbnRSZXF1ZXN0EiEKBHR5cGUYASABKA4yEy5ldmVudC52MS5FdmVudFR5cGUSDgoGdXNlcklkGAIgASgJEiQKB3Nka0NhbGwYAyABKAsyES5ldmVudC52MS5TREtDYWxsSAASLgoMYWlUb2tlblVzYWdlGAQgASgLMhYuZXZlbnQudjEuQUlUb2tlblVzYWdlSABCBgoEZGF0YSLoAQoMQUlUb2tlblVzYWdlEg0KBW1vZGVsGAEgASgJEhMKC2lucHV0VG9rZW5zGAIgASgFEhQKDG91dHB1dFRva2VucxgDIAEoBRIVCgtpbnB1dEFtb3VudBgEIAEoAkgAEhIKCGlucHV0VGFnGAUgASgJSAASEwoJaW5wdXRFeHByGAggASgJSAASFgoMb3V0cHV0QW1vdW50GAYgASgCSAESEwoJb3V0cHV0VGFnGAcgASgJSAESFAoKb3V0cHV0RXhwchgJIAEoCUgBQgwKCmlucHV0RGViaXRCDQoLb3V0cHV0RGViaXQiPwoTU3RyZWFtRXZlbnRSZXNwb25zZRIXCg9ldmVudHNQcm9jZXNzZWQYASABKAUSDwoHbWVzc2FnZRgCIAEoCSpJCglFdmVudFR5cGUSGgoWRVZFTlRfVFlQRV9VTlNQRUNJRklFRBAAEgwKCFNES19DQUxMEAESEgoOQUlfVE9LRU5fVVNBR0UQAipICgtTREtDYWxsVHlwZRIbChdTREtDYWxsVHlwZV9VTlNQRUNJRklFRBAAEgcKA1JBVxABEhMKD01JRERMRVdBUkVfQ0FMTBACMrMBCgxFdmVudFNlcnZpY2USUgoNUmVnaXN0ZXJFdmVudBIeLmV2ZW50LnYxLlJlZ2lzdGVyRXZlbnRSZXF1ZXN0Gh8uZXZlbnQudjEuUmVnaXN0ZXJFdmVudFJlc3BvbnNlIgASTwoMU3RyZWFtRXZlbnRzEhwuZXZlbnQudjEuU3RyZWFtRXZlbnRSZXF1ZXN0Gh0uZXZlbnQudjEuU3RyZWFtRXZlbnRSZXNwb25zZSIAKAFiBnByb3RvMw" ); /** @@ -88,6 +88,15 @@ export type SDKCall = Message<"event.v1.SDKCall"> & { value: string; case: "tag"; } + | { + /** + * Pricing expression (e.g., "add(mul(tag('PREMIUM'),3),250)") + * + * @generated from field: string expr = 4; + */ + value: string; + case: "expr"; + } | { case: undefined; value?: undefined }; }; @@ -136,6 +145,13 @@ export type StreamEventRequest = Message<"event.v1.StreamEventRequest"> & { * @generated from oneof event.v1.StreamEventRequest.data */ data: + | { + /** + * @generated from field: event.v1.SDKCall sdkCall = 3; + */ + value: SDKCall; + case: "sdkCall"; + } | { /** * @generated from field: event.v1.AITokenUsage aiTokenUsage = 4; @@ -191,6 +207,15 @@ export type AITokenUsage = Message<"event.v1.AITokenUsage"> & { value: string; case: "inputTag"; } + | { + /** + * Pricing expression for input tokens + * + * @generated from field: string inputExpr = 8; + */ + value: string; + case: "inputExpr"; + } | { case: undefined; value?: undefined }; /** @@ -211,6 +236,15 @@ export type AITokenUsage = Message<"event.v1.AITokenUsage"> & { value: string; case: "outputTag"; } + | { + /** + * Pricing expression for output tokens + * + * @generated from field: string outputExpr = 9; + */ + value: string; + case: "outputExpr"; + } | { case: undefined; value?: undefined }; }; diff --git a/src/interceptors/auth.ts b/src/interceptors/auth.ts index 14935c2..c701451 100644 --- a/src/interceptors/auth.ts +++ b/src/interceptors/auth.ts @@ -1,183 +1,92 @@ -import { type Interceptor } from "@connectrpc/connect"; +import type { Interceptor } from "@connectrpc/connect"; import { apiKeyContextKey } from "../context/auth"; -import { AuthError, AuthErrorType } from "../errors/auth"; -import { logger } from "../errors/logger"; +import { wideEventContextKey } from "../context/requestContext"; +import { AuthError } from "../errors/auth"; import { apiKeyCache } from "../utils/apiKeyCache"; import { getPostgresDB } from "../storage/db/postgres/db"; import { apiKeysTable } from "../storage/db/postgres/schema"; import { eq } from "drizzle-orm"; import { hashAPIKey } from "../utils/hashAPIKey"; -export const no_auth: string[] = [] as const; // No endpoints bypass authentication +export const no_auth: string[] = [] as const; export function authInterceptor(): Interceptor { return (next) => async (req) => { + // Skip auth for whitelisted endpoints for (const path of no_auth) { if (req.url.endsWith(path)) { return await next(req); } } - // Extract endpoint for context - let endpoint = req.url; - try { - const split = req.url.split("/"); - endpoint = `${split[split.length - 2]}/${split[split.length - 1]}`; - logger.logDebug(`Processing request to ${endpoint}`, { - endpoint: req.url, - }); - } catch (e) { - logger.logDebug("Could not parse endpoint for logging", { url: req.url }); + const wideEventBuilder = req.contextValues.get(wideEventContextKey); + + // Extract and validate authorization header + const authorization = req.header.get("Authorization"); + if (!authorization) { + throw AuthError.missingHeader(); } - try { - // Extract and validate authorization header - const authorization = req.header.get("Authorization"); - if (!authorization) { - const error = AuthError.missingHeader(); - logger.logError( - AuthErrorType.MISSING_HEADER, - error.message, - undefined, - { endpoint: req.url } - ); - throw error; - } + if (!authorization.startsWith("Bearer ")) { + throw AuthError.invalidHeaderFormat(); + } - if (!authorization.startsWith("Bearer ")) { - const error = AuthError.invalidHeaderFormat(); - logger.logError( - AuthErrorType.INVALID_HEADER_FORMAT, - error.message, - undefined, - { headerValue: authorization.substring(0, 20) + "..." } - ); - throw error; - } + const apiKey = authorization.slice("Bearer ".length).trim(); - const apiKey = authorization.slice("Bearer ".length).trim(); - - // Validate API key format - if (!apiKey.startsWith("scrn_") || apiKey.length !== 37) { - const error = AuthError.invalidAPIKey("Invalid API key format"); - logger.logError( - AuthErrorType.INVALID_API_KEY, - error.message, - undefined, - { endpoint: req.url } - ); - throw error; - } + // Validate API key format + if (!apiKey.startsWith("scrn_") || apiKey.length !== 37) { + throw AuthError.invalidAPIKey("Invalid API key format"); + } - // Hash the API key for lookup - const apiKeyHash = hashAPIKey(apiKey); + const apiKeyHash = hashAPIKey(apiKey); - // Check cache first (using hash as key) - const cached = apiKeyCache.get(apiKeyHash); - if (cached) { - logger.logDebug("Cache hit for API key", { apiKeyId: cached.id }); - req.contextValues.set(apiKeyContextKey, cached.id); - return await next(req); - } + // Check cache first + const cached = apiKeyCache.get(apiKeyHash); + if (cached) { + req.contextValues.set(apiKeyContextKey, cached.id); + wideEventBuilder?.setAuth(cached.id, true); + return await next(req); + } - logger.logDebug("Cache miss, querying database", {}); - - // Query database for API key by hash - let apiKeyRecord; - try { - const db = getPostgresDB(); - const result = await db - .select({ - id: apiKeysTable.id, - expiresAt: apiKeysTable.expiresAt, - revoked: apiKeysTable.revoked, - }) - .from(apiKeysTable) - .where(eq(apiKeysTable.key, apiKeyHash)) - .limit(1); - - apiKeyRecord = result[0]; - } catch (err) { - const error = AuthError.databaseError( - err instanceof Error ? err : undefined - ); - logger.logError( - AuthErrorType.DATABASE_ERROR, - error.message, - err instanceof Error ? err : undefined, - { endpoint: req.url } - ); - throw error; - } + // Query database for API key + const apiKeyRecord = await lookupApiKey(apiKeyHash); - // Check if API key exists - if (!apiKeyRecord) { - const error = AuthError.invalidAPIKey("API key not found"); - logger.logError( - AuthErrorType.INVALID_API_KEY, - error.message, - undefined, - { endpoint: req.url } - ); - throw error; - } - - // Check if API key is revoked - if (apiKeyRecord.revoked) { - const error = AuthError.revokedAPIKey(); - logger.logError( - AuthErrorType.REVOKED_API_KEY, - error.message, - undefined, - { apiKeyId: apiKeyRecord.id } - ); - throw error; - } + if (!apiKeyRecord) { + throw AuthError.invalidAPIKey("API key not found"); + } - // Check if API key has expired - const now = new Date(); - const expiresAt = new Date(apiKeyRecord.expiresAt); - if (now > expiresAt) { - const error = AuthError.expiredAPIKey(); - logger.logError( - AuthErrorType.EXPIRED_API_KEY, - error.message, - undefined, - { - apiKeyId: apiKeyRecord.id, - expiresAt: apiKeyRecord.expiresAt, - } - ); - throw error; - } + if (apiKeyRecord.revoked) { + throw AuthError.revokedAPIKey(); + } - // Store in cache (using hash as key) - apiKeyCache.set(apiKeyHash, { - id: apiKeyRecord.id, - expiresAt: apiKeyRecord.expiresAt, - }); - - logger.logDebug("Valid API key from database", { - apiKeyId: apiKeyRecord.id, - }); - - // Attach API key ID to context for use in handlers - req.contextValues.set(apiKeyContextKey, apiKeyRecord.id); - } catch (err) { - // Re-throw AuthError as-is, wrap other errors - if (err instanceof AuthError || (err as any)?.type in AuthErrorType) { - throw err; - } - const error = AuthError.unknown(err instanceof Error ? err : undefined); - logger.logError( - AuthErrorType.UNKNOWN, - error.message, - err instanceof Error ? err : undefined, - { endpoint: req.url } - ); - throw error; + if (new Date() > new Date(apiKeyRecord.expiresAt)) { + throw AuthError.expiredAPIKey(); } + // Cache and set context + apiKeyCache.set(apiKeyHash, { + id: apiKeyRecord.id, + expiresAt: apiKeyRecord.expiresAt, + }); + + req.contextValues.set(apiKeyContextKey, apiKeyRecord.id); + wideEventBuilder?.setAuth(apiKeyRecord.id, false); + return await next(req); }; } + +async function lookupApiKey(apiKeyHash: string) { + const db = getPostgresDB(); + const result = await db + .select({ + id: apiKeysTable.id, + expiresAt: apiKeysTable.expiresAt, + revoked: apiKeysTable.revoked, + }) + .from(apiKeysTable) + .where(eq(apiKeysTable.key, apiKeyHash)) + .limit(1); + + return result[0]; +} diff --git a/src/interceptors/logging.ts b/src/interceptors/logging.ts new file mode 100644 index 0000000..b032643 --- /dev/null +++ b/src/interceptors/logging.ts @@ -0,0 +1,161 @@ +import type { Interceptor } from "@connectrpc/connect"; +import { ConnectError, Code } from "@connectrpc/connect"; +import { logger } from "../errors/logger"; +import { + wideEventContextKey, + generateRequestId, + createWideEventBuilder, +} from "../context/requestContext"; + +/** + * Map Connect error codes to HTTP status codes for logging. + * Note: Code.OK (0) doesn't exist in Connect - successful responses don't throw. + */ +function connectCodeToHttpStatus(code: Code): number { + switch (code) { + case Code.Canceled: + return 499; + case Code.Unknown: + return 500; + case Code.InvalidArgument: + return 400; + case Code.DeadlineExceeded: + return 504; + case Code.NotFound: + return 404; + case Code.AlreadyExists: + return 409; + case Code.PermissionDenied: + return 403; + case Code.ResourceExhausted: + return 429; + case Code.FailedPrecondition: + return 400; + case Code.Aborted: + return 409; + case Code.OutOfRange: + return 400; + case Code.Unimplemented: + return 501; + case Code.Internal: + return 500; + case Code.Unavailable: + return 503; + case Code.DataLoss: + return 500; + case Code.Unauthenticated: + return 401; + default: + return 500; + } +} + +interface ErrorDetails { + type: string; + message: string; + cause?: string; + code: Code; + stack?: string; +} + +const isDev = process.env.NODE_ENV !== "production"; + +/** + * Extract error details from various error types. + * Includes originalError from custom error classes and stack traces in development. + */ +function extractErrorDetails(error: unknown): ErrorDetails { + // Handle our custom error classes (AuthError, APIKeyError, EventError, PaymentError, StorageError) + // They extend ConnectError and have `type` and `originalError` properties + if (error instanceof ConnectError) { + const customError = error as ConnectError & { + type?: string; + originalError?: Error; + }; + + // Build cause chain: prefer originalError (our custom errors), fallback to cause + let causeMessage: string | undefined; + if (customError.originalError) { + causeMessage = customError.originalError.message; + // If originalError itself has a cause, include it + if (customError.originalError.cause instanceof Error) { + causeMessage += ` -> ${customError.originalError.cause.message}`; + } + } else if (error.cause instanceof Error) { + causeMessage = error.cause.message; + } + + return { + type: customError.type || error.name, + message: error.message, + cause: causeMessage, + code: error.code, + stack: isDev + ? customError.originalError?.stack || error.stack + : undefined, + }; + } + + if (error instanceof Error) { + return { + type: error.name, + message: error.message, + cause: error.cause instanceof Error ? error.cause.message : undefined, + code: Code.Internal, + stack: isDev ? error.stack : undefined, + }; + } + + return { + type: "UnknownError", + message: String(error), + cause: undefined, + code: Code.Internal, + }; +} + +/** + * Logging interceptor that implements the wide events pattern. + * + * This interceptor: + * 1. Generates a unique request ID + * 2. Creates a WideEventBuilder and attaches it to the request context + * 3. Captures timing information + * 4. Emits a single wide event at request completion (success or failure) + * + * Place this interceptor FIRST in the chain to capture all requests, + * including those that fail authentication. + */ +export function loggingInterceptor(): Interceptor { + return (next) => async (req) => { + const requestId = generateRequestId(); + const method = req.method.kind; // "unary", "server_streaming", "client_streaming", "bidi_streaming" + const url = req.url; + + const builder = createWideEventBuilder(requestId, method, url); + + // Attach builder to request context for other interceptors and handlers + req.contextValues.set(wideEventContextKey, builder); + + try { + const response = await next(req); + builder.setSuccess(200); + return response; + } catch (error) { + const errorDetails = extractErrorDetails(error); + const statusCode = connectCodeToHttpStatus(errorDetails.code); + + builder.setError(statusCode, { + type: errorDetails.type, + message: errorDetails.message, + cause: errorDetails.cause, + stack: errorDetails.stack, + }); + + throw error; + } finally { + const event = builder.build(); + logger.emit(event); + } + }; +} diff --git a/src/middleware/httpLogging.ts b/src/middleware/httpLogging.ts new file mode 100644 index 0000000..0387053 --- /dev/null +++ b/src/middleware/httpLogging.ts @@ -0,0 +1,145 @@ +import type { + IncomingMessage, + ServerResponse, + OutgoingHttpHeaders, + OutgoingHttpHeader, +} from "node:http"; +import { logger } from "../errors/logger"; +import { + generateRequestId, + createWideEventBuilder, + WideEventBuilder, +} from "../context/requestContext"; + +/** + * HTTP handler type that receives a WideEventBuilder for adding context. + */ +export type LoggingHttpHandler = ( + req: IncomingMessage, + res: ServerResponse, + builder: WideEventBuilder +) => Promise; + +/** + * Wraps an HTTP handler with wide event logging. + * + * This middleware: + * 1. Generates a unique request ID + * 2. Creates a WideEventBuilder for the request + * 3. Captures timing information + * 4. Intercepts the response to capture status code + * 5. Emits a single wide event when the response is sent + * + * Usage: + * ```typescript + * const handler = withHttpLogging(async (req, res, builder) => { + * builder.setUser(userId); + * builder.setWebhookContext({ webhookEvent: "order_created" }); + * // ... handler logic ... + * res.writeHead(200); + * res.end(JSON.stringify({ success: true })); + * }); + * ``` + */ +export function withHttpLogging( + handler: LoggingHttpHandler +): (req: IncomingMessage, res: ServerResponse) => Promise { + return async (req: IncomingMessage, res: ServerResponse) => { + const requestId = generateRequestId(); + const method = req.method || "UNKNOWN"; + const url = req.url || "/"; + + const builder = createWideEventBuilder(requestId, method, url); + + // Track if the response has been logged to avoid double-logging + let logged = false; + + /** + * Emit the wide event with current state. + */ + const emitLog = () => { + if (logged) return; + logged = true; + + // Set outcome based on status code + const statusCode = res.statusCode || 500; + if (statusCode >= 400) { + builder.setError(statusCode, { + type: "HttpError", + message: `HTTP ${statusCode}`, + }); + } else { + builder.setSuccess(statusCode); + } + + const event = builder.build(); + logger.emit(event); + }; + + // Intercept writeHead to capture status code earlier + const originalWriteHead = res.writeHead.bind(res); + res.writeHead = function ( + statusCode: number, + statusMessage?: string | OutgoingHttpHeaders | OutgoingHttpHeader[], + headers?: OutgoingHttpHeaders | OutgoingHttpHeader[] + ): ServerResponse { + // Handle overloaded signatures + if (typeof statusMessage === "object") { + return originalWriteHead(statusCode, statusMessage); + } + return originalWriteHead(statusCode, statusMessage, headers); + } as typeof res.writeHead; + + // Intercept end to emit log when response completes + const originalEnd = res.end.bind(res); + res.end = function ( + chunk?: unknown, + encoding?: BufferEncoding | (() => void), + callback?: () => void + ): ServerResponse { + // Emit log before ending + emitLog(); + + // Handle overloaded signatures + if (typeof encoding === "function") { + return originalEnd(chunk, encoding); + } + if (encoding !== undefined) { + return originalEnd(chunk, encoding, callback); + } + return originalEnd(chunk, callback); + } as typeof res.end; + + // Handle connection close without proper response + res.on("close", () => { + if (!logged) { + // Connection closed before response completed + builder.setError(499, { + type: "ConnectionClosed", + message: "Client closed connection", + }); + emitLog(); + } + }); + + try { + await handler(req, res, builder); + } catch (error) { + // Handle uncaught errors in the handler + if (!logged) { + const err = error instanceof Error ? error : new Error(String(error)); + builder.setError(500, { + type: err.name, + message: err.message, + }); + emitLog(); + } + + // If response hasn't been sent, send error response + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Internal server error" })); + } + } + }; +} diff --git a/src/routes/gRPC/auth/createAPIKey.ts b/src/routes/gRPC/auth/createAPIKey.ts index 44395b8..5ae67a4 100644 --- a/src/routes/gRPC/auth/createAPIKey.ts +++ b/src/routes/gRPC/auth/createAPIKey.ts @@ -13,118 +13,76 @@ import { StorageAdapterFactory } from "../../../factory"; import { AddKey } from "../../../events/RawEvents/AddKey"; import type { HandlerContext } from "@connectrpc/connect"; import { apiKeyContextKey } from "../../../context/auth"; +import { wideEventContextKey } from "../../../context/requestContext"; import { hashAPIKey } from "../../../utils/hashAPIKey"; -import { logger } from "../../../errors/logger"; - -const OPERATION = "CreateAPIKey"; export async function createAPIKey( req: CreateAPIKeyRequest, context: HandlerContext ): Promise { - try { - // Get API key ID from context (set by auth interceptor) - const apiKeyId = context.values.get(apiKeyContextKey); - if (!apiKeyId) { - throw AuthError.invalidAPIKey("API key ID not found in context"); - } + const wideEventBuilder = context.values.get(wideEventContextKey); - logger.logOperationInfo( - OPERATION, - "authenticated", - "Request authenticated", - { - apiKeyId, - } - ); + // Get API key ID from context (set by auth interceptor) + const apiKeyId = context.values.get(apiKeyContextKey); + if (!apiKeyId) { + throw AuthError.invalidAPIKey("API key ID not found in context"); + } - // Validate the incoming request against the schema - let validatedData; - try { - validatedData = createAPIKeySchema.parse(req); - } catch (error) { - if (error instanceof ZodError) { - const issues = error.issues - .map((issue) => `${issue.path.join(".")}: ${issue.message}`) - .join("; "); - throw APIKeyError.validationFailed(issues, error); - } - throw APIKeyError.validationFailed( - "Unknown validation error", - error as Error - ); - } + // Validate the incoming request + const validatedData = validateRequest(req); - // Generate the actual API key - const apiKey = generateAPIKey(); + // Add business context to wide event + wideEventBuilder?.setApiKeyContext({ name: validatedData.name }); - // Hash the API key before storing - const apiKeyHash = hashAPIKey(apiKey); + // Generate and hash the API key + const apiKey = generateAPIKey(); + const apiKeyHash = hashAPIKey(apiKey); - // Calculate expiration date - const now = new Date(); - const expiresInSeconds = - typeof validatedData.expiresIn === "bigint" - ? Number(validatedData.expiresIn) - : validatedData.expiresIn; - const expiresAt = new Date(now.getTime() + expiresInSeconds * 1000); + // Calculate expiration date + const now = new Date(); + const expiresInSeconds = + typeof validatedData.expiresIn === "bigint" + ? Number(validatedData.expiresIn) + : validatedData.expiresIn; + const expiresAt = new Date(now.getTime() + expiresInSeconds * 1000); - // Create AddKey event (store hash, not plaintext) - const addKeyEvent = new AddKey({ - name: validatedData.name, - key: apiKeyHash, - expiresAt: expiresAt.toISOString(), - }); + wideEventBuilder?.setApiKeyContext({ expiration: expiresAt.toISOString() }); - // Use storage adapter factory to persist the event - let keyEventData: { id: string } | void; - try { - const adapter = - await StorageAdapterFactory.getStorageAdapter(addKeyEvent); - keyEventData = await adapter.add(addKeyEvent.serialize()); - if (!keyEventData) { - throw APIKeyError.creationFailed("No ID returned"); - } - } catch (error) { - throw APIKeyError.creationFailed( - "Failed to store API key", - error as Error - ); - } + // Create and store the key + const addKeyEvent = new AddKey({ + name: validatedData.name, + key: apiKeyHash, + expiresAt: expiresAt.toISOString(), + }); - logger.logOperationInfo( - OPERATION, - "completed", - "API key created successfully", - { - apiKeyId: keyEventData.id, - name: validatedData.name, - } - ); + const adapter = await StorageAdapterFactory.getStorageAdapter(addKeyEvent); + const keyEventData = await adapter.add(addKeyEvent.serialize()); - return create(CreateAPIKeyResponseSchema, { - apiKeyId: keyEventData.id, - apiKey: apiKey, - name: validatedData.name, - createdAt: now.toISOString(), - expiresAt: expiresAt.toISOString(), - }); - } catch (error) { - logger.logOperationError( - OPERATION, - "failed", - error instanceof APIKeyError ? error.type : "UNKNOWN", - "CreateAPIKey handler failed", - error instanceof Error ? error : undefined, - { apiKeyId: context.values.get(apiKeyContextKey) } - ); + if (!keyEventData) { + throw APIKeyError.creationFailed("Storage returned no ID"); + } - // Re-throw APIKeyError as-is - if (error instanceof APIKeyError) { - throw error; - } + return create(CreateAPIKeyResponseSchema, { + apiKeyId: keyEventData.id, + apiKey: apiKey, + name: validatedData.name, + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + }); +} - // Wrap unexpected errors - throw APIKeyError.unknown(error as Error); +function validateRequest(req: CreateAPIKeyRequest) { + try { + return createAPIKeySchema.parse(req); + } catch (error) { + if (error instanceof ZodError) { + const issues = error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("; "); + throw APIKeyError.validationFailed(issues); + } + throw APIKeyError.validationFailed( + error instanceof Error ? error.message : String(error) + ); } } diff --git a/src/routes/gRPC/events/registerEvent.ts b/src/routes/gRPC/events/registerEvent.ts index e129709..83c3688 100644 --- a/src/routes/gRPC/events/registerEvent.ts +++ b/src/routes/gRPC/events/registerEvent.ts @@ -4,10 +4,8 @@ import type { } from "../../../gen/event/v1/event_pb"; import { RegisterEventResponseSchema } from "../../../gen/event/v1/event_pb"; import { create } from "@bufbuild/protobuf"; -import { EventError } from "../../../errors/event"; import type { HandlerContext } from "@connectrpc/connect"; -import { apiKeyContextKey } from "../../../context/auth"; -import { logger } from "../../../errors/logger"; +import { wideEventContextKey } from "../../../context/requestContext"; import { extractApiKeyFromContext, validateAndParseRegisterEvent, @@ -15,63 +13,29 @@ import { storeEvent, } from "../../../utils/eventHelpers"; -const OPERATION = "RegisterEvent"; - export async function registerEvent( req: RegisterEventRequest, context: HandlerContext ): Promise { - try { - // Extract API key ID from context - const apiKeyId = extractApiKeyFromContext(context); - - logger.logOperationInfo( - OPERATION, - "authenticated", - "Request authenticated", - { - apiKeyId, - } - ); - - // Validate and parse the incoming event - const eventSkeleton = await validateAndParseRegisterEvent(req); + const wideEventBuilder = context.values.get(wideEventContextKey); - // Create the appropriate event instance - const event = createEventInstance(eventSkeleton); + // Extract API key ID from context + const apiKeyId = extractApiKeyFromContext(context); - // Store the event - await storeEvent(event, apiKeyId); + // Validate and parse the incoming event + const eventSkeleton = await validateAndParseRegisterEvent(req); - logger.logOperationInfo( - OPERATION, - "completed", - "Event stored successfully", - { - apiKeyId, - userId: eventSkeleton.userId, - } - ); + // Add business context to wide event + wideEventBuilder?.setUser(eventSkeleton.userId); + wideEventBuilder?.setEventContext({ eventType: eventSkeleton.type }); - return create(RegisterEventResponseSchema, { - random: "Event stored successfully", - }); - } catch (error) { - logger.logOperationError( - OPERATION, - "failed", - error instanceof EventError ? error.type : "UNKNOWN", - "RegisterEvent handler failed", - error instanceof Error ? error : undefined, - { apiKeyId: context.values.get(apiKeyContextKey) } - ); + // Create the appropriate event instance + const event = createEventInstance(eventSkeleton); - // Re-throw EventError as-is - if (error instanceof EventError) { - throw error; - } + // Store the event + await storeEvent(event, apiKeyId); - // Wrap unexpected errors - throw EventError.unknown(error as Error); - } + return create(RegisterEventResponseSchema, { + random: "Event stored successfully", + }); } diff --git a/src/routes/gRPC/events/streamEvents.ts b/src/routes/gRPC/events/streamEvents.ts index 2b35f53..ade4c4a 100644 --- a/src/routes/gRPC/events/streamEvents.ts +++ b/src/routes/gRPC/events/streamEvents.ts @@ -7,8 +7,7 @@ import { StreamEventResponseSchema } from "../../../gen/event/v1/event_pb"; import { create } from "@bufbuild/protobuf"; import { EventError } from "../../../errors/event"; import type { HandlerContext } from "@connectrpc/connect"; -import { apiKeyContextKey } from "../../../context/auth"; -import { logger } from "../../../errors/logger"; +import { wideEventContextKey } from "../../../context/requestContext"; import { extractApiKeyFromContext, validateAndParseStreamEvent, @@ -16,33 +15,29 @@ import { storeEvent, } from "../../../utils/eventHelpers"; -const OPERATION = "StreamEvents"; - export async function streamEvents( requestStream: AsyncIterable, context: HandlerContext ): Promise { let eventsProcessed = 0; + let userId: string | undefined; - try { - // Extract API key ID from context - const apiKeyId = extractApiKeyFromContext(context); + const wideEventBuilder = context.values.get(wideEventContextKey); - logger.logOperationInfo( - OPERATION, - "authenticated", - "Stream authenticated", - { - apiKeyId, - } - ); + // Extract API key ID from context + const apiKeyId = extractApiKeyFromContext(context); - // Collect all events from the stream + try { for await (const req of requestStream) { - // Validate and parse the incoming event const eventSkeleton = await validateAndParseStreamEvent(req); - // Create the appropriate event instance + // Capture userId from first event for logging + if (!userId) { + userId = eventSkeleton.userId; + wideEventBuilder?.setUser(userId); + wideEventBuilder?.setEventContext({ eventType: "AI_TOKEN_USAGE" }); + } + const event = createEventInstance(eventSkeleton); if (event.type !== "AI_TOKEN_USAGE") { @@ -51,52 +46,14 @@ export async function streamEvents( await storeEvent(event, apiKeyId); eventsProcessed += 1; - - logger.logOperationInfo( - OPERATION, - "event_processed", - "Event processed and stored", - { - apiKeyId, - userId: eventSkeleton.userId, - eventNumber: eventsProcessed, - } - ); } - logger.logOperationInfo( - OPERATION, - "completed", - "Stream processing completed", - { - apiKeyId: context.values.get(apiKeyContextKey), - eventsProcessed, - } - ); - return create(StreamEventResponseSchema, { eventsProcessed, message: `Successfully processed ${eventsProcessed} events`, }); - } catch (error) { - logger.logOperationError( - OPERATION, - "failed", - error instanceof EventError ? error.type : "UNKNOWN", - "StreamEvents handler failed", - error instanceof Error ? error : undefined, - { - apiKeyId: context.values.get(apiKeyContextKey), - eventsProcessed, - } - ); - - // Re-throw EventError as-is - if (error instanceof EventError) { - throw error; - } - - // Wrap unexpected errors - throw EventError.unknown(error as Error); + } finally { + // Always update the count, even on error + wideEventBuilder?.setEventContext({ eventCount: eventsProcessed }); } } diff --git a/src/routes/gRPC/payment/createCheckoutLink.ts b/src/routes/gRPC/payment/createCheckoutLink.ts index 2bfd31c..9e8867b 100644 --- a/src/routes/gRPC/payment/createCheckoutLink.ts +++ b/src/routes/gRPC/payment/createCheckoutLink.ts @@ -10,7 +10,7 @@ import { } from "../../../zod/payment"; import { PaymentError } from "../../../errors/payment"; import { AuthError } from "../../../errors/auth"; -import { custom, ZodError } from "zod"; +import { ZodError } from "zod"; import type { HandlerContext } from "@connectrpc/connect"; import { lemonSqueezySetup, @@ -19,291 +19,152 @@ import { import { StorageAdapterFactory } from "../../../factory"; import { RequestPayment } from "../../../events/RequestEvents/RequestPayment"; import { apiKeyContextKey } from "../../../context/auth"; -import { logger } from "../../../errors/logger"; - -const OPERATION = "CreateCheckoutLink"; +import { wideEventContextKey } from "../../../context/requestContext"; export async function createCheckoutLink( req: CreateCheckoutLinkRequest, context: HandlerContext ): Promise { - try { - const apiKeyId = context.values.get(apiKeyContextKey); - if (!apiKeyId) { - throw AuthError.invalidAPIKey("API key ID not found in context"); - } + const wideEventBuilder = context.values.get(wideEventContextKey); - logger.logOperationInfo( - OPERATION, - "authenticated", - "Request authenticated", - { - apiKeyId, - } - ); - - // Read environment configuration - const LEMON_SQUEEZY_API_KEY = process.env.LEMON_SQUEEZY_API_KEY; - const LEMON_SQUEEZY_STORE_ID = process.env.LEMON_SQUEEZY_STORE_ID; - const LEMON_SQUEEZY_VARIANT_ID = process.env.LEMON_SQUEEZY_VARIANT_ID; - - // Validate environment configuration - if (!LEMON_SQUEEZY_API_KEY) { - throw PaymentError.missingApiKey(); - } - - if (!LEMON_SQUEEZY_STORE_ID) { - throw PaymentError.missingStoreId(); - } - - if (!LEMON_SQUEEZY_VARIANT_ID) { - throw PaymentError.missingVariantId(); - } - - // Validate the incoming request against the schema - let validatedData: CreateCheckoutLinkSchemaType; - try { - validatedData = createCheckoutLinkSchema.parse(req); - } catch (error) { - if (error instanceof ZodError) { - const issues = error.issues - .map((issue) => `${issue.path.join(".")}: ${issue.message}`) - .join("; "); - throw PaymentError.validationFailed(issues, error); - } - throw PaymentError.validationFailed( - "Unknown validation error", - error as Error - ); - } - - // Configure Lemon Squeezy SDK - lemonSqueezySetup({ - apiKey: LEMON_SQUEEZY_API_KEY, - onError: (error) => { - logger.logOperationError( - OPERATION, - "lemon_squeezy_sdk", - "LEMON_SQUEEZY_SDK_ERROR", - "Lemon Squeezy SDK error", - error as Error, - {} - ); - }, - }); - - logger.logOperationInfo(OPERATION, "validated", "Request validated", { - userId: validatedData.userId, - apiKeyId, - }); + const apiKeyId = context.values.get(apiKeyContextKey); + if (!apiKeyId) { + throw AuthError.invalidAPIKey("API key ID not found in context"); + } - // Get custom price from storage - let custom_price: number; - try { - const event = new RequestPayment(validatedData.userId, null); - const storageAdapter = - await StorageAdapterFactory.getStorageAdapter(event); + // Validate environment configuration + const config = getConfig(); - if (!storageAdapter) { - throw PaymentError.storageAdapterFailed( - "Storage adapter factory returned null or undefined" - ); - } + // Validate the incoming request + const validatedData = validateRequest(req); + wideEventBuilder?.setUser(validatedData.userId); - custom_price = await storageAdapter.price(event.serialize()); + // Configure Lemon Squeezy SDK + lemonSqueezySetup({ apiKey: config.apiKey }); - if ( - typeof custom_price !== "number" || - isNaN(custom_price) || - custom_price < 0 - ) { - throw PaymentError.priceCalculationFailed( - validatedData.userId, - new Error(`Invalid price value: ${custom_price}`) - ); - } - } catch (error) { - logger.logOperationError( - OPERATION, - "fetch_price", - "PRICE_CALCULATION_FAILED", - "Failed to calculate price", - error as Error, - { userId: validatedData.userId, apiKeyId } - ); + // Get custom price from storage + const custom_price = await calculatePrice(validatedData.userId); + wideEventBuilder?.setPaymentContext({ priceAmount: custom_price }); - // Use duck typing instead of instanceof to work with mocked modules - if ( - error && - typeof error === "object" && - "type" in error && - (error as any).name === "PaymentError" - ) { - throw error; - } + // Create checkout session + const checkoutUrl = await createCheckoutSession( + config, + custom_price, + validatedData.userId, + apiKeyId + ); - throw PaymentError.priceCalculationFailed( - validatedData.userId, - error as Error - ); - } + return create(CreateCheckoutLinkResponseSchema, { + checkoutLink: checkoutUrl, + }); +} - logger.logOperationInfo(OPERATION, "price_resolved", "Price calculated", { - userId: validatedData.userId, - price: custom_price, - apiKeyId, - }); +interface LemonSqueezyConfig { + apiKey: string; + storeId: string; + variantId: string; +} - // Create checkout session - // Create checkout session with detailed error context - let checkoutResponse; - try { - checkoutResponse = await createCheckout( - LEMON_SQUEEZY_STORE_ID, - LEMON_SQUEEZY_VARIANT_ID, - { - customPrice: custom_price, - checkoutData: { - custom: { - user_id: String(validatedData.userId), - api_key_id: String(apiKeyId), - }, - }, - } - ); - } catch (error) { - let errorMessage = "Unknown error"; - if (error instanceof Error) { - errorMessage = error.message; - } else if (typeof error === "string") { - errorMessage = error; - } else if (error && typeof error === "object" && "message" in error) { - errorMessage = String(error.message); - } +function getConfig(): LemonSqueezyConfig { + const apiKey = process.env.LEMON_SQUEEZY_API_KEY; + const storeId = process.env.LEMON_SQUEEZY_STORE_ID; + const variantId = process.env.LEMON_SQUEEZY_VARIANT_ID; - logger.logOperationError( - OPERATION, - "create_checkout", - "LEMON_SQUEEZY_API_ERROR", - "Lemon Squeezy API call failed", - error instanceof Error ? error : new Error(errorMessage), - { - userId: validatedData.userId, - apiKeyId, - price: custom_price, - storeId: LEMON_SQUEEZY_STORE_ID, - variantId: LEMON_SQUEEZY_VARIANT_ID, - } - ); + if (!apiKey) throw PaymentError.missingApiKey(); + if (!storeId) throw PaymentError.missingStoreId(); + if (!variantId) throw PaymentError.missingVariantId(); - throw PaymentError.lemonSqueezyApiError( - errorMessage, - error instanceof Error ? error : new Error(String(error)) - ); - } + return { apiKey, storeId, variantId }; +} - // Validate response from Lemon Squeezy with comprehensive checks - if (!checkoutResponse) { - throw PaymentError.invalidCheckoutResponse( - "Checkout response is null or undefined" - ); - } +function validateRequest( + req: CreateCheckoutLinkRequest +): CreateCheckoutLinkSchemaType { + try { + return createCheckoutLinkSchema.parse(req); + } catch (error) { + if (error instanceof ZodError) { + const issues = error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("; "); + throw PaymentError.validationFailed(issues); + } + throw PaymentError.validationFailed( + error instanceof Error ? error.message : String(error) + ); + } +} - if (checkoutResponse.error) { - const errorMsg = - checkoutResponse.error?.message || - JSON.stringify(checkoutResponse.error); - throw PaymentError.checkoutCreationFailed(errorMsg); - } +async function calculatePrice(userId: string): Promise { + const event = new RequestPayment(userId, null); + const storageAdapter = await StorageAdapterFactory.getStorageAdapter(event); - // Validate response structure - if (!checkoutResponse.data) { - throw PaymentError.invalidCheckoutResponse( - "Missing 'data' field in checkout response" - ); - } + if (!storageAdapter) { + throw PaymentError.storageAdapterFailed("Storage adapter not available"); + } - if (!checkoutResponse.data.data) { - throw PaymentError.invalidCheckoutResponse( - "Missing nested 'data' field in checkout response" - ); - } + const price = await storageAdapter.price(event.serialize()); - if (!checkoutResponse.data.data.attributes) { - throw PaymentError.invalidCheckoutResponse( - "Missing 'attributes' field in checkout response" - ); - } + if (typeof price !== "number" || isNaN(price) || price < 0) { + throw PaymentError.priceCalculationFailed( + userId, + new Error(`Invalid price: ${price}`) + ); + } - const checkoutUrl = checkoutResponse.data.data.attributes.url; - if (!checkoutUrl) { - throw PaymentError.invalidCheckoutResponse( - "No checkout URL found in response attributes" - ); - } + return price; +} - if (typeof checkoutUrl !== "string" || checkoutUrl.trim().length === 0) { - throw PaymentError.invalidCheckoutResponse( - `Invalid checkout URL format: ${typeof checkoutUrl}` - ); +async function createCheckoutSession( + config: LemonSqueezyConfig, + customPrice: number, + userId: string, + apiKeyId: string +): Promise { + const checkoutResponse = await createCheckout( + config.storeId, + config.variantId, + { + customPrice, + checkoutData: { + custom: { + user_id: String(userId), + api_key_id: String(apiKeyId), + }, + }, } + ); - // Validate URL format - try { - new URL(checkoutUrl); - } catch (urlError) { - throw PaymentError.invalidCheckoutResponse( - `Checkout URL is not a valid URL: ${checkoutUrl}`, - urlError instanceof Error ? urlError : undefined - ); - } + if (!checkoutResponse) { + throw PaymentError.invalidCheckoutResponse("Response is null"); + } - logger.logOperationInfo( - OPERATION, - "completed", - "Checkout link created successfully", - { userId: validatedData.userId, apiKeyId, checkoutUrl } + if (checkoutResponse.error) { + throw PaymentError.checkoutCreationFailed( + checkoutResponse.error?.message || JSON.stringify(checkoutResponse.error) ); + } - return create(CreateCheckoutLinkResponseSchema, { - checkoutLink: checkoutUrl, - }); - } catch (error) { - const apiKeyId = context.values.get(apiKeyContextKey); + const checkoutUrl = checkoutResponse.data?.data?.attributes?.url; - logger.logOperationError( - OPERATION, - "failed", - error instanceof PaymentError - ? error.type - : error instanceof AuthError - ? error.type - : "UNKNOWN", - "CreateCheckoutLink handler failed", - error instanceof Error ? error : undefined, - { apiKeyId } + if ( + !checkoutUrl || + typeof checkoutUrl !== "string" || + checkoutUrl.trim().length === 0 + ) { + throw PaymentError.invalidCheckoutResponse( + "No valid checkout URL in response" ); + } - // Re-throw PaymentError as-is - // Use duck typing instead of instanceof to work with mocked modules - if ( - error && - typeof error === "object" && - "type" in error && - "name" in error && - error.name === "PaymentError" - ) { - throw error; - } - - // Re-throw AuthError as-is - if (error instanceof AuthError) { - throw error; - } - - // Wrap unexpected errors with context - throw PaymentError.unknown( - error instanceof Error ? error : new Error(String(error)) + // Validate URL format + try { + new URL(checkoutUrl); + } catch { + throw PaymentError.invalidCheckoutResponse( + `Invalid URL format: ${checkoutUrl}` ); } + + return checkoutUrl; } diff --git a/src/routes/http/createdCheckout.ts b/src/routes/http/createdCheckout.ts index 2a18a29..9236b51 100644 --- a/src/routes/http/createdCheckout.ts +++ b/src/routes/http/createdCheckout.ts @@ -1,30 +1,18 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; import crypto from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; import { lemonSqueezySetup } from "@lemonsqueezy/lemonsqueezy.js"; import { Payment } from "../../events/RawEvents/Payment.ts"; import { StorageAdapterFactory } from "../../factory/StorageAdapterFactory.ts"; -import { logger } from "../../errors/logger.ts"; +import type { WideEventBuilder } from "../../context/requestContext.ts"; -const OPERATION = "LemonSqueezyWebhook"; +const isDev = process.env.NODE_ENV !== "production"; // Initialize Lemon Squeezy SDK if API key is available const LEMON_SQUEEZY_API_KEY = process.env.LEMON_SQUEEZY_API_KEY; -if (!LEMON_SQUEEZY_API_KEY) { - logger.logWarning("LEMON_SQUEEZY_API_KEY not set - SDK not configured", {}); -} else { +if (LEMON_SQUEEZY_API_KEY) { lemonSqueezySetup({ apiKey: LEMON_SQUEEZY_API_KEY, - onError: (error) => { - logger.logOperationError( - OPERATION, - "lemon_squeezy_sdk", - "LEMON_SQUEEZY_SDK_ERROR", - "Lemon Squeezy SDK error in webhook handler", - error as Error, - {} - ); - }, }); } @@ -70,15 +58,7 @@ function verifyWebhookSignature( const digest = hmac.digest("hex"); return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest)); - } catch (error) { - logger.logOperationError( - OPERATION, - "signature_verification", - "SIGNATURE_VERIFICATION_ERROR", - "Signature verification error", - error as Error, - {} - ); + } catch { return false; } } @@ -89,33 +69,29 @@ function verifyWebhookSignature( function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ""; - req.on("data", (chunk) => { + req.on("data", (chunk: Buffer) => { body += chunk.toString(); }); req.on("end", () => { resolve(body); }); - req.on("error", (error) => { + req.on("error", (error: Error) => { reject(error); }); }); } /** - * Handles the Lemon Squeezy order-created webhook + * Handles the Lemon Squeezy order-created webhook. + * This handler is designed to work with the HTTP logging middleware, + * which provides a WideEventBuilder for adding business context. */ export async function handleLemonSqueezyWebhook( req: IncomingMessage, - res: ServerResponse + res: ServerResponse, + builder: WideEventBuilder ): Promise { try { - logger.logOperationInfo( - OPERATION, - "start", - "Processing webhook request", - {} - ); - // Read the raw body const rawBody = await readBody(req); @@ -127,14 +103,10 @@ export async function handleLemonSqueezyWebhook( process.env.LEMON_SQUEEZY_WEBHOOK_SECRET; if (!LEMON_SQUEEZY_WEBHOOK_SECRET) { - logger.logOperationError( - OPERATION, - "config", - "MISSING_WEBHOOK_SECRET", - "Webhook secret not configured", - undefined, - {} - ); + builder.setError(500, { + type: "ConfigurationError", + message: "Webhook secret not configured", + }); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Webhook secret not configured" })); return; @@ -147,104 +119,74 @@ export async function handleLemonSqueezyWebhook( ); if (!isValid) { - logger.logOperationError( - OPERATION, - "validate_signature", - "INVALID_SIGNATURE", - "Invalid webhook signature", - undefined, - {} - ); + builder.setError(401, { + type: "AuthenticationError", + message: "Invalid signature", + }); res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid signature" })); return; } - logger.logOperationInfo( - OPERATION, - "signature_validated", - "Signature validated successfully", - {} - ); - // Parse the payload let payload: LemonSqueezyWebhookPayload; try { payload = JSON.parse(rawBody); - } catch (error) { - logger.logOperationError( - OPERATION, - "parse_payload", - "INVALID_JSON", - "Invalid JSON payload", - error as Error, - {} - ); + } catch { + builder.setError(400, { + type: "ParseError", + message: "Invalid JSON payload", + }); res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Invalid JSON payload" })); return; } + // Add webhook event context + builder.setWebhookContext({ + webhookEvent: payload.meta.event_name, + orderId: payload.data.id, + }); + // Handle only order-created events if (payload.meta.event_name !== "order_created") { - logger.logOperationInfo( - OPERATION, - "ignored_event", - "Ignoring non-order_created event", - { eventName: payload.meta.event_name } - ); + builder.setSuccess(200); + builder.addContext({ ignored: true }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ message: "Event ignored" })); return; } - logger.logOperationInfo( - OPERATION, - "processing", - "Processing order_created event", - {} - ); - // Extract user ID from custom data const userId = payload.meta.custom_data?.user_id; const apiKeyId = payload.meta.custom_data?.api_key_id; if (!userId) { - logger.logOperationError( - OPERATION, - "validate_payload", - "MISSING_USER_ID", - "Missing user_id in webhook payload", - undefined, - {} - ); + builder.setError(400, { + type: "ValidationError", + message: "Missing user_id in webhook payload", + }); res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing user_id in webhook payload" })); return; } if (!apiKeyId) { - logger.logOperationError( - OPERATION, - "validate_payload", - "MISSING_API_KEY_ID", - "Missing apiKeyId in webhook payload", - undefined, - { userId } - ); + builder.setError(400, { + type: "ValidationError", + message: "Missing apiKeyId in webhook payload", + }); res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing apiKeyId in webhook payload" })); return; } + // Add user and payment context to wide event + builder.setUser(userId); + // Extract payment amount (convert from cents to the integer format used in DB) const creditAmount = Math.round(payload.data.attributes.total); - - logger.logOperationInfo(OPERATION, "payment_data", "Processing payment", { - userId, - apiKeyId, - creditAmount, - }); + builder.setPaymentContext({ creditAmount }); // Create and store the payment event try { @@ -256,36 +198,29 @@ export async function handleLemonSqueezyWebhook( await adapter.add(paymentEvent.serialize()); - logger.logOperationInfo( - OPERATION, - "completed", - "Payment event stored successfully", - { userId, apiKeyId, creditAmount } - ); - + builder.setSuccess(200); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ message: "Webhook processed successfully" })); } catch (dbError) { - logger.logOperationError( - OPERATION, - "database", - "DATABASE_ERROR", - "Database error while storing payment", - dbError as Error, - { userId, apiKeyId } - ); + const errorMessage = + dbError instanceof Error ? dbError.message : String(dbError); + builder.setError(500, { + type: "DatabaseError", + message: `Failed to store payment event: ${errorMessage}`, + cause: dbError instanceof Error ? dbError.message : undefined, + stack: isDev && dbError instanceof Error ? dbError.stack : undefined, + }); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Database error" })); } } catch (error) { - logger.logOperationError( - OPERATION, - "failed", - "UNEXPECTED_ERROR", - "Unexpected error in webhook handler", - error as Error, - {} - ); + const errorMessage = error instanceof Error ? error.message : String(error); + builder.setError(500, { + type: "InternalError", + message: `Unexpected webhook error: ${errorMessage}`, + cause: error instanceof Error ? error.message : undefined, + stack: isDev && error instanceof Error ? error.stack : undefined, + }); res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Internal server error" })); } diff --git a/src/server.ts b/src/server.ts index 803a275..b4e6608 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,33 +1,43 @@ import * as http from "node:http"; +import * as http2 from "node:http2"; import type { ConnectRouter } from "@connectrpc/connect"; import { connectNodeAdapter } from "@connectrpc/connect-node"; import { createValidateInterceptor } from "@connectrpc/validate"; import { EventService } from "./gen/event/v1/event_pb.ts"; import { AuthService } from "./gen/auth/v1/auth_pb.ts"; import { PaymentService } from "./gen/payment/v1/payment_pb.ts"; +import { loggingInterceptor } from "./interceptors/logging.ts"; import { authInterceptor } from "./interceptors/auth.ts"; import { registerEvent } from "./routes/gRPC/events/registerEvent.ts"; import { streamEvents } from "./routes/gRPC/events/streamEvents.ts"; import { createAPIKey } from "./routes/gRPC/auth/createAPIKey.ts"; import { createCheckoutLink } from "./routes/gRPC/payment/createCheckoutLink.ts"; import { getPostgresDB } from "./storage/db/postgres/db.ts"; -import { handleLemonSqueezyWebhook as createCheckout } from "./routes/http/createdCheckout.ts"; +import { handleLemonSqueezyWebhook } from "./routes/http/createdCheckout.ts"; +import { withHttpLogging } from "./middleware/httpLogging.ts"; +import { logger } from "./errors/logger.ts"; const DATABASE_URL = process.env.DATABASE_URL; const HMAC_SECRET = process.env.HMAC_SECRET; if (!DATABASE_URL) { + logger.fatal("DATABASE_URL is not defined in environment variables"); throw new Error("DATABASE_URL is not defined in environment variables"); } if (!HMAC_SECRET) { + logger.fatal("HMAC_SECRET environment variable is not set"); throw new Error("HMAC_SECRET environment variable is not set"); } getPostgresDB(DATABASE_URL); const grpcHandler = connectNodeAdapter({ - interceptors: [createValidateInterceptor(), authInterceptor()], + interceptors: [ + loggingInterceptor(), // First - captures all requests including auth failures + createValidateInterceptor(), + authInterceptor(), + ], routes: (router: ConnectRouter) => { // EventService implementation router.service(EventService, { @@ -47,17 +57,23 @@ const grpcHandler = connectNodeAdapter({ }, }); +// Wrap webhook handler with HTTP logging middleware +const webhookHandler = withHttpLogging(handleLemonSqueezyWebhook); + // Create a combined handler for both gRPC and HTTP webhooks const requestHandler = ( - req: http.IncomingMessage, - res: http.ServerResponse + req: http.IncomingMessage | http2.Http2ServerRequest, + res: http.ServerResponse | http2.Http2ServerResponse ) => { // Handle webhook endpoint if ( req.url === "/webhooks/lemonsqueezy/createdCheckout" && req.method === "POST" ) { - createCheckout(req, res); + webhookHandler( + req as unknown as http.IncomingMessage, + res as unknown as http.ServerResponse + ); return; } @@ -65,6 +81,17 @@ const requestHandler = ( grpcHandler(req, res); }; -http.createServer(requestHandler).listen(8069); -console.log("Server listening on http://localhost:8069"); -console.log("Webhook endpoint: http://localhost:8069/webhooks/lemonsqueezy"); +const PORT = Number(process.env.PORT ?? 8069); + +http2.createServer(requestHandler).listen(PORT); + +logger.lifecycle("Server started", { + grpcH2Port: PORT, + env: process.env.NODE_ENV || "development", +}); +logger.lifecycle("Webhook endpoint available", { + url: `http://localhost:${PORT}/webhooks/lemonsqueezy/createdCheckout`, +}); +logger.lifecycle("gRPC h2c endpoint available", { + url: `http://localhost:${PORT}`, +}); diff --git a/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts b/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts index 4f872b1..1f8f8af 100644 --- a/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts +++ b/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts @@ -7,9 +7,6 @@ import { import { StorageError } from "../../../../errors/storage"; import { type SqlRecord } from "../../../../interface/event/Event"; import type { UserId } from "../../../../config/identifiers"; -import { logger } from "../../../../errors/logger"; - -const OPERATION = "AddAiTokenUsage"; type AggregatedEvent = { userId: UserId; @@ -28,23 +25,10 @@ export async function handleAddAiTokenUsage( const connectionObject = getPostgresDB(); if (events.length === 0) { - logger.logOperationInfo(OPERATION, "skipped", "No events to process", { - apiKeyId, - }); return; } try { - logger.logOperationInfo( - OPERATION, - "start", - `Processing ${events.length} AI_TOKEN_USAGE event(s)`, - { - eventCount: events.length, - apiKeyId, - } - ); - // Validate all events before processing for (const event_data of events) { // Validate input tokens is not negative @@ -133,17 +117,6 @@ export async function handleAddAiTokenUsage( const aggregatedEvents = Array.from(aggregationMap.values()); - logger.logOperationInfo( - OPERATION, - "aggregated", - `Aggregated ${events.length} event(s) into ${aggregatedEvents.length} unique (userId, model) combination(s)`, - { - originalCount: events.length, - aggregatedCount: aggregatedEvents.length, - apiKeyId, - } - ); - await connectionObject.transaction(async (txn) => { // Collect unique user IDs const uniqueUserIds = Array.from( @@ -157,13 +130,6 @@ export async function handleAddAiTokenUsage( .insert(usersTable) .values(uniqueUserIds.map((id) => ({ id }))) .onConflictDoNothing(); - - logger.logOperationDebug( - OPERATION, - "users_ensured", - "Users ensured in database", - { userCount: uniqueUserIds.length } - ); } } catch (e) { if ( @@ -171,12 +137,6 @@ export async function handleAddAiTokenUsage( e.message.includes('Failed query: insert into "users" ("id")') ) { // Users already exist, ignore the error - logger.logOperationDebug( - OPERATION, - "users_exist", - "Users already exist, continuing", - { userCount: uniqueUserIds.length } - ); } else { throw StorageError.userInsertFailed( uniqueUserIds.join(", "), @@ -217,13 +177,6 @@ export async function handleAddAiTokenUsage( ); } - logger.logOperationInfo( - OPERATION, - "events_inserted", - `${eventIDs.length} event row(s) inserted`, - { eventCount: eventIDs.length, apiKeyId } - ); - // Prepare AI token usage values for batch insert const aiTokenUsageValues = aggregatedEvents.map((aggEvent, index) => { const eventId = eventIDs[index]; @@ -246,16 +199,6 @@ export async function handleAddAiTokenUsage( // Batch insert AI token usage events try { await txn.insert(aiTokenUsageEventsTable).values(aiTokenUsageValues); - - logger.logOperationInfo( - OPERATION, - "ai_token_usage_inserted", - `${aiTokenUsageValues.length} AI token usage event(s) inserted successfully`, - { - eventCount: aiTokenUsageValues.length, - apiKeyId, - } - ); } catch (e) { throw StorageError.insertFailed( `Failed to batch insert AI token usage events`, @@ -273,17 +216,6 @@ export async function handleAddAiTokenUsage( return { id: firstEvent.id }; }); - - logger.logOperationInfo( - OPERATION, - "completed", - `AI_TOKEN_USAGE batch transaction completed successfully - processed ${events.length} event(s), inserted ${aggregatedEvents.length} row(s)`, - { - originalCount: events.length, - aggregatedCount: aggregatedEvents.length, - apiKeyId, - } - ); } catch (e) { // Use duck typing instead of instanceof to work with mocked modules if ( diff --git a/src/storage/adapter/postgres/handlers/addKey.ts b/src/storage/adapter/postgres/handlers/addKey.ts index 5bf5639..95220e8 100644 --- a/src/storage/adapter/postgres/handlers/addKey.ts +++ b/src/storage/adapter/postgres/handlers/addKey.ts @@ -2,9 +2,6 @@ import { getPostgresDB } from "../../../db/postgres/db"; import { apiKeysTable } from "../../../db/postgres/schema"; import { StorageError } from "../../../../errors/storage"; import { type SqlRecord } from "../../../../interface/event/Event"; -import { logger } from "../../../../errors/logger"; - -const OPERATION = "AddKey"; export async function handleAddKey( event_data: SqlRecord<"ADD_KEY"> @@ -33,10 +30,6 @@ export async function handleAddKey( throw StorageError.invalidData("API key cannot be empty"); } - logger.logOperationInfo(OPERATION, "start", "Processing ADD_KEY event", { - keyName: event_data.data.name, - }); - return await connectionObject.transaction(async (txn) => { // Validate and prepare timestamp let reported_timestamp; @@ -95,13 +88,6 @@ export async function handleAddKey( ); } - logger.logOperationInfo( - OPERATION, - "key_inserted", - "API key inserted successfully", - { apiKeyId: apiKeyRecord.id, keyName: keyData.data.name } - ); - return apiKeyRecord; }); } catch (e) { diff --git a/src/storage/adapter/postgres/handlers/addPayment.ts b/src/storage/adapter/postgres/handlers/addPayment.ts index 09a29b3..b4e6175 100644 --- a/src/storage/adapter/postgres/handlers/addPayment.ts +++ b/src/storage/adapter/postgres/handlers/addPayment.ts @@ -6,9 +6,6 @@ import { } from "../../../db/postgres/schema"; import { StorageError } from "../../../../errors/storage"; import { type SqlRecord } from "../../../../interface/event/Event"; -import { logger } from "../../../../errors/logger"; - -const OPERATION = "AddPayment"; export async function handleAddPayment( event_data: SqlRecord<"PAYMENT">, @@ -34,11 +31,6 @@ export async function handleAddPayment( ); } - logger.logOperationInfo(OPERATION, "start", "Processing PAYMENT event", { - userId: event_data.userId, - apiKeyId, - }); - await connectionObject.transaction(async (txn) => { // Insert user if not exists try { @@ -48,25 +40,12 @@ export async function handleAddPayment( id: event_data.userId, }) .onConflictDoNothing(); - - logger.logOperationDebug( - OPERATION, - "user_ensured", - "User ensured in database", - { userId: event_data.userId } - ); } catch (e) { if ( e instanceof Error && e.message.includes('Failed query: insert into "users" ("id")') ) { // User already exists, ignore the error - logger.logOperationDebug( - OPERATION, - "user_exists", - "User already exists, continuing", - { userId: event_data.userId } - ); } else { throw StorageError.userInsertFailed( event_data.userId, @@ -114,30 +93,12 @@ export async function handleAddPayment( throw StorageError.emptyResult("Event insert returned no ID"); } - logger.logOperationInfo( - OPERATION, - "event_inserted", - "Event row inserted", - { eventId: eventID.id, userId: event_data.userId, apiKeyId } - ); - // Insert payment event try { await txn.insert(paymentEventsTable).values({ id: eventID.id, creditAmount: event_data.data.creditAmount, }); - - logger.logOperationInfo( - OPERATION, - "payment_inserted", - "Payment event inserted successfully", - { - eventId: eventID.id, - creditAmount: event_data.data.creditAmount, - userId: event_data.userId, - } - ); } catch (e) { throw StorageError.insertFailed( `Failed to insert payment event for event ID ${eventID.id}`, @@ -147,13 +108,6 @@ export async function handleAddPayment( return { id: eventID }; }); - - logger.logOperationInfo( - OPERATION, - "completed", - "PAYMENT transaction completed successfully", - { userId: event_data.userId, apiKeyId } - ); } catch (e) { // Use duck typing instead of instanceof to work with mocked modules if ( diff --git a/src/storage/adapter/postgres/handlers/addSdkCall.ts b/src/storage/adapter/postgres/handlers/addSdkCall.ts index 04b7f63..50d8f17 100644 --- a/src/storage/adapter/postgres/handlers/addSdkCall.ts +++ b/src/storage/adapter/postgres/handlers/addSdkCall.ts @@ -6,9 +6,6 @@ import { } from "../../../db/postgres/schema"; import { StorageError } from "../../../../errors/storage"; import { type SqlRecord } from "../../../../interface/event/Event"; -import { logger } from "../../../../errors/logger"; - -const OPERATION = "AddSdkCall"; export async function handleAddSdkCall( event_data: SqlRecord<"SDK_CALL">, @@ -17,11 +14,6 @@ export async function handleAddSdkCall( const connectionObject = getPostgresDB(); try { - logger.logOperationInfo(OPERATION, "start", "Processing SDK_CALL event", { - userId: event_data.userId, - apiKeyId, - }); - // Validate debit amount is not negative const debitAmount = event_data.data.debitAmount; if (typeof debitAmount === "number" && debitAmount < 0) { @@ -40,25 +32,12 @@ export async function handleAddSdkCall( id: event_data.userId, }) .onConflictDoNothing(); - - logger.logOperationDebug( - OPERATION, - "user_ensured", - "User ensured in database", - { userId: event_data.userId } - ); } catch (e) { if ( e instanceof Error && e.message.includes('Failed query: insert into "users" ("id")') ) { // User already exists, ignore the error - logger.logOperationDebug( - OPERATION, - "user_exists", - "User already exists, continuing", - { userId: event_data.userId } - ); } else { throw StorageError.userInsertFailed( event_data.userId, @@ -106,13 +85,6 @@ export async function handleAddSdkCall( throw StorageError.emptyResult("Event insert returned no ID"); } - logger.logOperationInfo( - OPERATION, - "event_inserted", - "Event row inserted", - { eventId: eventID.id, userId: event_data.userId, apiKeyId } - ); - // Insert SDK call event try { const sdkData = event_data; @@ -122,17 +94,6 @@ export async function handleAddSdkCall( type: sdkData.data.sdkCallType, debitAmount: sdkData.data.debitAmount, }); - - logger.logOperationInfo( - OPERATION, - "sdk_call_inserted", - "SDK call event inserted successfully", - { - eventId: eventID.id, - debitAmount: sdkData.data.debitAmount, - userId: event_data.userId, - } - ); } catch (e) { throw StorageError.insertFailed( `Failed to insert SDK call event for event ID ${eventID.id}`, @@ -142,13 +103,6 @@ export async function handleAddSdkCall( return { id: eventID }; }); - - logger.logOperationInfo( - OPERATION, - "completed", - "SDK_CALL transaction completed successfully", - { userId: event_data.userId, apiKeyId } - ); } catch (e) { // Use duck typing instead of instanceof to work with mocked modules if ( diff --git a/src/storage/adapter/postgres/handlers/priceRequestAiTokenUsage.ts b/src/storage/adapter/postgres/handlers/priceRequestAiTokenUsage.ts index e78aa0a..a55cb76 100644 --- a/src/storage/adapter/postgres/handlers/priceRequestAiTokenUsage.ts +++ b/src/storage/adapter/postgres/handlers/priceRequestAiTokenUsage.ts @@ -6,9 +6,6 @@ import { import { StorageError } from "../../../../errors/storage"; import { eq, sum, sql } from "drizzle-orm"; import { type SqlRecord } from "../../../../interface/event/Event"; -import { logger } from "../../../../errors/logger"; - -const OPERATION = "PriceRequestAiTokenUsage"; export async function handlePriceRequestAiTokenUsage( event_data: SqlRecord<"REQUEST_AI_TOKEN_USAGE"> @@ -31,13 +28,6 @@ export async function handlePriceRequestAiTokenUsage( ); } - logger.logOperationInfo( - OPERATION, - "start", - "Querying price for REQUEST_AI_TOKEN_USAGE", - { userId: event_data.userId } - ); - let result; try { result = await connectionObject @@ -70,24 +60,12 @@ export async function handlePriceRequestAiTokenUsage( } if (result.length === 0 || !result[0]) { - logger.logOperationInfo( - OPERATION, - "no_events", - "No AI token usage events found, returning 0", - { userId: event_data.userId } - ); return 0; } const priceValue = result[0].price; if (priceValue === null || priceValue === undefined) { - logger.logOperationInfo( - OPERATION, - "null_price", - "Price is null/undefined, returning 0", - { userId: event_data.userId } - ); return 0; } @@ -108,20 +86,6 @@ export async function handlePriceRequestAiTokenUsage( ); } - if (parsedPrice < 0) { - logger.logWarning("Negative price calculated", { - userId: event_data.userId, - price: parsedPrice, - }); - } - - logger.logOperationInfo( - OPERATION, - "completed", - "Price calculated successfully", - { userId: event_data.userId, price: parsedPrice } - ); - return parsedPrice; } catch (e) { // Use duck typing instead of instanceof to work with mocked modules diff --git a/src/storage/adapter/postgres/handlers/priceRequestPayment.ts b/src/storage/adapter/postgres/handlers/priceRequestPayment.ts index 0474a3b..eb409c0 100644 --- a/src/storage/adapter/postgres/handlers/priceRequestPayment.ts +++ b/src/storage/adapter/postgres/handlers/priceRequestPayment.ts @@ -3,9 +3,6 @@ import { RequestSDKCall } from "../../../../events/RequestEvents/RequestSDKCall" import { RequestAITokenUsage } from "../../../../events/RequestEvents/RequestAITokenUsage"; import { StorageAdapterFactory } from "../../../../factory"; import { type SqlRecord } from "../../../../interface/event/Event"; -import { logger } from "../../../../errors/logger"; - -const OPERATION = "PriceRequestPayment"; export async function handlePriceRequestPayment( event_data: SqlRecord<"REQUEST_PAYMENT"> @@ -15,13 +12,6 @@ export async function handlePriceRequestPayment( throw StorageError.invalidData("Missing userId in REQUEST_PAYMENT event"); } - logger.logOperationInfo( - OPERATION, - "start", - "Calculating price for REQUEST_PAYMENT", - { userId: event_data.userId } - ); - // Calculate SDK call price const sdkEvent = new RequestSDKCall(event_data.userId, null); const sdkStorageAdapter = @@ -44,13 +34,6 @@ export async function handlePriceRequestPayment( ); } - logger.logOperationInfo( - OPERATION, - "sdk_price_calculated", - "SDK call price calculated successfully", - { userId: event_data.userId, sdkPrice } - ); - // Calculate AI token usage price const aiEvent = new RequestAITokenUsage(event_data.userId, null); const aiStorageAdapter = @@ -73,23 +56,8 @@ export async function handlePriceRequestPayment( ); } - logger.logOperationInfo( - OPERATION, - "ai_price_calculated", - "AI token usage price calculated successfully", - { userId: event_data.userId, aiPrice } - ); - // Sum both prices const totalPrice = sdkPrice + aiPrice; - - logger.logOperationInfo( - OPERATION, - "completed", - "Total price calculated successfully", - { userId: event_data.userId, sdkPrice, aiPrice, totalPrice } - ); - return totalPrice; } catch (e) { // Use duck typing instead of instanceof to work with mocked modules diff --git a/src/storage/adapter/postgres/handlers/priceRequestSdkCall.ts b/src/storage/adapter/postgres/handlers/priceRequestSdkCall.ts index 515f517..5adaa9f 100644 --- a/src/storage/adapter/postgres/handlers/priceRequestSdkCall.ts +++ b/src/storage/adapter/postgres/handlers/priceRequestSdkCall.ts @@ -3,9 +3,6 @@ import { sdkCallEventsTable, eventsTable } from "../../../db/postgres/schema"; import { StorageError } from "../../../../errors/storage"; import { eq, sum } from "drizzle-orm"; import { type SqlRecord } from "../../../../interface/event/Event"; -import { logger } from "../../../../errors/logger"; - -const OPERATION = "PriceRequestSdkCall"; export async function handlePriceRequestSdkCall( event_data: SqlRecord<"REQUEST_SDK_CALL"> @@ -28,13 +25,6 @@ export async function handlePriceRequestSdkCall( ); } - logger.logOperationInfo( - OPERATION, - "start", - "Querying price for REQUEST_SDK_CALL", - { userId: event_data.userId } - ); - let result; try { result = await connectionObject @@ -65,24 +55,12 @@ export async function handlePriceRequestSdkCall( } if (result.length === 0 || !result[0]) { - logger.logOperationInfo( - OPERATION, - "no_events", - "No SDK call events found, returning 0", - { userId: event_data.userId } - ); return 0; } const priceValue = result[0].price; if (priceValue === null || priceValue === undefined) { - logger.logOperationInfo( - OPERATION, - "null_price", - "Price is null/undefined, returning 0", - { userId: event_data.userId } - ); return 0; } @@ -103,20 +81,6 @@ export async function handlePriceRequestSdkCall( ); } - if (parsedPrice < 0) { - logger.logWarning("Negative price calculated", { - userId: event_data.userId, - price: parsedPrice, - }); - } - - logger.logOperationInfo( - OPERATION, - "completed", - "Price calculated successfully", - { userId: event_data.userId, price: parsedPrice } - ); - return parsedPrice; } catch (e) { // Use duck typing instead of instanceof to work with mocked modules diff --git a/src/utils/cacheStore.ts b/src/utils/cacheStore.ts index 46f0533..95d6cdd 100644 --- a/src/utils/cacheStore.ts +++ b/src/utils/cacheStore.ts @@ -104,7 +104,7 @@ export class Cache { static getStore( name: string, - config?: Partial>, + config?: Partial> ): CacheStore { const existing = Cache.stores.get(name); if (existing) return existing as CacheStore; diff --git a/src/utils/eventHelpers.ts b/src/utils/eventHelpers.ts index 2a24a5b..af92432 100644 --- a/src/utils/eventHelpers.ts +++ b/src/utils/eventHelpers.ts @@ -2,12 +2,16 @@ import type { HandlerContext } from "@connectrpc/connect"; import { apiKeyContextKey } from "../context/auth"; import { AuthError } from "../errors/auth"; import { EventError } from "../errors/event"; -import { registerEventSchema, streamEventSchema } from "../zod/event"; +import { + registerEventSchema, + streamEventSchema, + type RegisterEventSchemaType, + type StreamEventSchemaType, +} from "../zod/event"; import { ZodError } from "zod"; import type { Event } from "../interface/event/Event"; import { SDKCall } from "../events/RawEvents/SDKCall"; import { AITokenUsage } from "../events/AIEvents/AITokenUsage"; -import { RequestAITokenUsage } from "../events/RequestEvents/RequestAITokenUsage"; import { StorageAdapterFactory } from "../factory"; import type { RegisterEventRequest, @@ -26,75 +30,77 @@ export function extractApiKeyFromContext(context: HandlerContext): string { } /** - * Validate and parse the incoming event request + * Validate and parse the incoming register event request. + * Handles Zod validation errors and extracts clean error messages. */ -export async function validateAndParseRegisterEvent(req: RegisterEventRequest) { +export async function validateAndParseRegisterEvent( + req: RegisterEventRequest +): Promise { try { return await registerEventSchema.parseAsync(req); } catch (error) { - if (error instanceof EventError) { - throw error; - } - if (error instanceof ZodError) { - const issues = error.issues - .map((issue) => `${issue.path.join(".")}: ${issue.message}`) - .join("; "); - throw EventError.validationFailed(issues, error); - } - throw EventError.validationFailed( - "Unknown validation error", - error as Error - ); + throw convertValidationError(error); } } -export async function validateAndParseStreamEvent(req: StreamEventRequest) { +/** + * Validate and parse the incoming stream event request. + */ +export async function validateAndParseStreamEvent( + req: StreamEventRequest +): Promise { try { return await streamEventSchema.parseAsync(req); } catch (error) { - if (error instanceof EventError) { - throw error; - } - if (error instanceof ZodError) { - const issues = error.issues - .map((issue) => `${issue.path.join(".")}: ${issue.message}`) - .join("; "); - throw EventError.validationFailed(issues, error); + throw convertValidationError(error); + } +} + +/** + * Convert Zod validation errors to EventError. + * Detects wrapped EventErrors from Zod transforms and extracts clean messages. + */ +function convertValidationError(error: unknown): EventError { + if (error instanceof EventError) { + return error; + } + + if (error instanceof ZodError) { + const firstIssue = error.issues[0]; + + // Check if Zod wrapped our custom EventError from a transform + if (firstIssue?.message.startsWith("Event validation failed:")) { + const cleanMessage = firstIssue.message.replace( + /^Event validation failed:\s*/, + "" + ); + return EventError.validationFailed(cleanMessage); } - throw EventError.validationFailed( - "Unknown validation error", - error as Error - ); + + const issues = error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("; "); + return EventError.validationFailed(issues); } + + return EventError.validationFailed( + error instanceof Error ? error.message : String(error) + ); } /** * Create the appropriate event instance based on the event skeleton */ -export function createEventInstance(eventSkeleton: { - type: string; - userId: string; - data: any; -}): Event { - try { - switch (eventSkeleton.type) { - case "SDK_CALL": - return new SDKCall(eventSkeleton.userId, eventSkeleton.data); - case "AI_TOKEN_USAGE": - return new AITokenUsage(eventSkeleton.userId, eventSkeleton.data); - case "REQUEST_AI_TOKEN_USAGE": - return new RequestAITokenUsage( - eventSkeleton.userId, - eventSkeleton.data - ); - default: - throw EventError.unsupportedEventType(eventSkeleton.type); - } - } catch (error) { - if (error instanceof EventError) { - throw error; - } - throw EventError.unknown(error as Error); +export function createEventInstance( + eventSkeleton: RegisterEventSchemaType | StreamEventSchemaType +): Event { + switch (eventSkeleton.type) { + case "SDK_CALL": + return new SDKCall(eventSkeleton.userId, eventSkeleton.data); + case "AI_TOKEN_USAGE": + return new AITokenUsage(eventSkeleton.userId, eventSkeleton.data); + default: + throw EventError.unsupportedEventType("Unknown event type"); } } @@ -105,20 +111,9 @@ export async function storeEvent( event: Event, apiKeyId: string ): Promise { - try { - const adapter = await StorageAdapterFactory.getStorageAdapter( - event, - apiKeyId - ); - await adapter.add(event.serialize()); - } catch (error) { - throw EventError.serializationError( - "Failed to store event", - error as Error - ); - } + const adapter = await StorageAdapterFactory.getStorageAdapter( + event, + apiKeyId + ); + await adapter.add(event.serialize()); } - -/** - * Store multiple events in a batch - groups by type and uses batch operations when possible - */ diff --git a/src/utils/fetchTagAmount.ts b/src/utils/fetchTagAmount.ts new file mode 100644 index 0000000..6da969f --- /dev/null +++ b/src/utils/fetchTagAmount.ts @@ -0,0 +1,29 @@ +import { eq } from "drizzle-orm"; +import { EventError } from "../errors/event"; +import { getPostgresDB } from "../storage/db/postgres/db"; +import { tagsTable } from "../storage/db/postgres/schema"; +import { tagCache } from "./tagCache"; + +export async function fetchTagAmount( + tag: string, + notFoundMessage: string +): Promise { + const cachedAmount = tagCache.get(tag); + if (cachedAmount !== undefined) { + return cachedAmount; + } + + const db = getPostgresDB(); + const [tagRow] = await db + .select() + .from(tagsTable) + .where(eq(tagsTable.tag, tag)) + .limit(1); + + if (!tagRow) { + throw EventError.validationFailed(notFoundMessage); + } + + tagCache.set(tag, tagRow.amount); + return tagRow.amount; +} diff --git a/src/utils/generateInitialAPIKey.ts b/src/utils/generateInitialAPIKey.ts index ce933b4..4f6e89e 100644 --- a/src/utils/generateInitialAPIKey.ts +++ b/src/utils/generateInitialAPIKey.ts @@ -4,10 +4,9 @@ import { randomUUID } from "crypto"; const HMAC_SECRET = process.env.HMAC_SECRET; if (!HMAC_SECRET) { - console.error( - "Error: HMAC_SECRET environment variable is not set. (check .env.example file)" + throw new Error( + "HMAC_SECRET environment variable is not set. (check .env.example file)" ); - process.exit(1); } // Type assertion after validation @@ -39,46 +38,47 @@ function hashAPIKey(apiKey: string): string { return createHmac("sha256", SECRET).update(apiKey).digest("hex"); } -// Generate initial API key data -const apiKeyId = randomUUID(); -const apiKey = generateAPIKey(); -const apiKeyHash = hashAPIKey(apiKey); -const name = "Dashboard Key"; -const createdAt = new Date().toISOString(); -const expiresAt = new Date( - Date.now() + 365 * 24 * 60 * 60 * 1000 -).toISOString(); // 1 year from now +export type InitialApiKeyData = { + apiKeyId: string; + apiKey: string; + apiKeyHash: string; + name: string; + createdAt: string; + expiresAt: string; + insertSql: string; + authorizationHeader: string; +}; -console.log("\n=== Initial API Key Generated ==="); -console.log("\nAPI Key Details:"); -console.log(` ID: ${apiKeyId}`); -console.log(` Key: ${apiKey}`); -console.log(` Name: ${name}`); -console.log(` Created At: ${createdAt}`); -console.log(` Expires At: ${expiresAt}`); -console.log("\n\n=== SQL INSERT Statement ===\n"); -console.log( - `INSERT INTO api_keys (id, name, key, created_at, expires_at, revoked, revoked_at)` -); -console.log(`VALUES (`); -console.log(` '${apiKeyId}',`); -console.log(` '${name}',`); -console.log(` '${apiKeyHash}',`); -console.log(` '${createdAt}',`); -console.log(` '${expiresAt}',`); -console.log(` false,`); -console.log(` NULL`); -console.log(`);\n`); -console.log("\n=== Usage ==="); -console.log(`Authorization: Bearer ${apiKey}`); -console.log("\n\n=== IMPORTANT ==="); -console.log("1. The key is stored as an HMAC-SHA256 hash in the database"); -console.log( - "2. Run the SQL INSERT statement above in your PostgreSQL database" -); -console.log("3. Use the PLAINTEXT API key (above) in the Authorization header"); -console.log( - "4. Keep this API key secure - it will be used to generate new API keys" -); -console.log("5. The plaintext key is shown only once - save it now!"); -console.log("=================\n"); +export function generateInitialApiKeyData(): InitialApiKeyData { + const apiKeyId = randomUUID(); + const apiKey = generateAPIKey(); + const apiKeyHash = hashAPIKey(apiKey); + const name = "Dashboard Key"; + const createdAt = new Date().toISOString(); + const expiresAt = new Date( + Date.now() + 365 * 24 * 60 * 60 * 1000 + ).toISOString(); // 1 year from now + + const insertSql = + "INSERT INTO api_keys (id, name, key, created_at, expires_at, revoked, revoked_at)\n" + + "VALUES (\n" + + ` '${apiKeyId}',\n` + + ` '${name}',\n` + + ` '${apiKeyHash}',\n` + + ` '${createdAt}',\n` + + ` '${expiresAt}',\n` + + " false,\n" + + " NULL\n" + + ");"; + + return { + apiKeyId, + apiKey, + apiKeyHash, + name, + createdAt, + expiresAt, + insertSql, + authorizationHeader: `Authorization: Bearer ${apiKey}`, + }; +} diff --git a/src/utils/parseExpr.ts b/src/utils/parseExpr.ts new file mode 100644 index 0000000..f69efee --- /dev/null +++ b/src/utils/parseExpr.ts @@ -0,0 +1,255 @@ +import { Parser } from "expr-eval"; +import { EventError } from "../errors/event"; +import { fetchTagAmount } from "./fetchTagAmount"; + +/** + * Expression Parser for Pricing DSL + * + * Parses and evaluates pricing expressions sent from the SDK. + * Expressions follow the format: add(mul(tag(PREMIUM_CALL),3),tag(EXTRA_FEE),250) + * + * Supported operations: + * - add(...args): Sum of all arguments + * - sub(a, b): a - b + * - mul(...args): Product of all arguments + * - div(a, b): a / b (floors result) + * - tag(NAME): Resolves to the tag's value from database + * + * Numbers are treated as cents (integers). + */ + +// Regex to match tag(NAME) patterns - tag names must be UPPER_SNAKE_CASE +const TAG_PATTERN = /tag\(([A-Z_][A-Z0-9_]*)\)/g; + +// Allowed function names in expressions +const ALLOWED_FUNCTIONS = new Set(["add", "sub", "mul", "div", "tag"]); + +/** + * Creates a configured expr-eval parser with custom functions. + */ +function createParser(): Parser { + const parser = new Parser(); + + // Variadic add: sum of all arguments + parser.functions.add = (...args: number[]): number => { + if (args.length === 0) { + throw new Error("add() requires at least one argument"); + } + return args.reduce((sum, val) => sum + val, 0); + }; + + // Binary subtraction + parser.functions.sub = (a: number, b: number): number => { + if (typeof a !== "number" || typeof b !== "number") { + throw new Error("sub() requires exactly two numeric arguments"); + } + return a - b; + }; + + // Variadic multiply: product of all arguments + parser.functions.mul = (...args: number[]): number => { + if (args.length === 0) { + throw new Error("mul() requires at least one argument"); + } + return args.reduce((product, val) => product * val, 1); + }; + + // Binary division with floor + parser.functions.div = (a: number, b: number): number => { + if (typeof a !== "number" || typeof b !== "number") { + throw new Error("div() requires exactly two numeric arguments"); + } + if (b === 0) { + throw new Error("Division by zero"); + } + return Math.floor(a / b); + }; + + return parser; +} + +/** + * Extracts all tag names from an expression string. + * + * @param exprString - The expression string to parse + * @returns Array of unique tag names found in the expression + * + * @example + * extractTagNames("add(tag(PREMIUM),tag(FEE),100)") + * // Returns: ["PREMIUM", "FEE"] + */ +export function extractTagNames(exprString: string): string[] { + const tags = new Set(); + let match: RegExpExecArray | null; + + // Reset regex state + TAG_PATTERN.lastIndex = 0; + + while ((match = TAG_PATTERN.exec(exprString)) !== null) { + if (match[1]) { + tags.add(match[1]); + } + } + + return Array.from(tags); +} + +/** + * Validates expression syntax without evaluating. + * Checks for: + * - Valid parentheses matching + * - Only allowed function names + * - Valid tag name format + * + * @param exprString - The expression string to validate + * @throws EventError if validation fails + */ +export function validateExprSyntax(exprString: string): void { + if (!exprString || exprString.trim() === "") { + throw EventError.validationFailed("Expression cannot be empty"); + } + + // Check parentheses balance + let depth = 0; + for (const char of exprString) { + if (char === "(") depth++; + if (char === ")") depth--; + if (depth < 0) { + throw EventError.validationFailed( + "Invalid expression syntax: unmatched closing parenthesis" + ); + } + } + if (depth !== 0) { + throw EventError.validationFailed( + "Invalid expression syntax: unmatched opening parenthesis" + ); + } + + // Extract and validate function names + const functionPattern = /([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g; + let match: RegExpExecArray | null; + + while ((match = functionPattern.exec(exprString)) !== null) { + const funcName = match[1]?.toLowerCase(); + if (!funcName || !ALLOWED_FUNCTIONS.has(funcName)) { + throw EventError.validationFailed( + `Unknown function in expression: ${match[1]}` + ); + } + } + + // Validate tag name format (must be UPPER_SNAKE_CASE) + const tagNamePattern = /tag\(([^)]*)\)/gi; + while ((match = tagNamePattern.exec(exprString)) !== null) { + const tagName = match[1]; + if (!tagName || !/^[A-Z_][A-Z0-9_]*$/.test(tagName)) { + throw EventError.validationFailed( + `Invalid tag name format: ${tagName}. Tag names must be UPPER_SNAKE_CASE` + ); + } + } +} + +/** + * Resolves all tag references in an expression by fetching their values + * from the database and replacing them in the expression string. + * + * @param exprString - The expression string with tag(NAME) references + * @returns The expression string with tags replaced by their numeric values + * @throws EventError if any tag is not found + */ +async function resolveTagsInExpression(exprString: string): Promise { + const tagNames = extractTagNames(exprString); + + if (tagNames.length === 0) { + return exprString; + } + + // Fetch all tag values (fetchTagAmount handles caching) + const tagValues = new Map(); + + for (const tagName of tagNames) { + const value = await fetchTagAmount(tagName, `Tag not found: ${tagName}`); + tagValues.set(tagName, value); + } + + // Replace all tag(NAME) with their values + let resolvedExpr = exprString; + for (const [tagName, value] of tagValues) { + // Use a regex with global flag to replace all occurrences + const tagPattern = new RegExp(`tag\\(${tagName}\\)`, "g"); + resolvedExpr = resolvedExpr.replace(tagPattern, value.toString()); + } + + return resolvedExpr; +} + +/** + * Parses and evaluates a pricing expression string. + * + * This is the main entry point for expression evaluation. + * It handles the full pipeline: + * 1. Validates expression syntax + * 2. Resolves all tag references from the database + * 3. Evaluates the expression using expr-eval + * 4. Returns the floored integer result (cents) + * + * @param exprString - The expression string to evaluate + * @returns The evaluated result as an integer (cents) + * @throws EventError for syntax errors, unknown tags, or evaluation errors + * + * @example + * // Simple amount + * await parseAndEvaluateExpr("250") // Returns: 250 + * + * @example + * // With tag (assumes PREMIUM_CALL = 100 in DB) + * await parseAndEvaluateExpr("add(mul(tag(PREMIUM_CALL),3),250)") + * // Returns: 550 (100*3 + 250) + */ +export async function parseAndEvaluateExpr( + exprString: string +): Promise { + // Step 1: Validate syntax + validateExprSyntax(exprString); + + // Step 2: Resolve all tags to their values + const resolvedExpr = await resolveTagsInExpression(exprString); + + // Step 3: Parse and evaluate + const parser = createParser(); + + try { + const expression = parser.parse(resolvedExpr); + const result = expression.evaluate(); + + // Step 4: Validate and return result + if (typeof result !== "number" || !Number.isFinite(result)) { + throw EventError.validationFailed( + `Expression evaluation produced invalid result: ${result}` + ); + } + + // Floor to ensure integer cents + return Math.floor(result); + } catch (error) { + // Re-throw EventError as-is + if (error instanceof EventError) { + throw error; + } + + // Wrap other errors + const message = + error instanceof Error ? error.message : "Unknown evaluation error"; + + // Check for specific error types + if (message.includes("Division by zero")) { + throw EventError.validationFailed("Division by zero in expression"); + } + + throw EventError.validationFailed( + `Failed to evaluate expression: ${message}` + ); + } +} diff --git a/src/zod/event.ts b/src/zod/event.ts index 5a11b22..ca2979f 100644 --- a/src/zod/event.ts +++ b/src/zod/event.ts @@ -1,41 +1,9 @@ import { z } from "zod"; import { USER_ID_CONFIG } from "../config/identifiers"; -import { getPostgresDB } from "../storage/db/postgres/db"; -import { tagsTable } from "../storage/db/postgres/schema"; -import { eq } from "drizzle-orm"; -import { EventError } from "../errors/event"; -import { tagCache } from "../utils/tagCache"; - -const fetchTagAmount = async ( - tag: string, - notFoundMessage: string -): Promise => { - const cachedAmount = tagCache.get(tag); - if (cachedAmount !== undefined) { - return cachedAmount; - } - - const db = getPostgresDB(); - try { - const [tagRow] = await db - .select() - .from(tagsTable) - .where(eq(tagsTable.tag, tag)) - .limit(1); - - if (!tagRow) { - throw EventError.validationFailed(notFoundMessage); - } - - tagCache.set(tag, tagRow.amount); - return tagRow.amount; - } catch (e) { - if (e instanceof EventError) { - throw e; - } - throw EventError.unknown(e as Error); - } -}; +import { fetchTagAmount } from "../utils/fetchTagAmount"; +import { parseAndEvaluateExpr } from "../utils/parseExpr"; +export { fetchTagAmount } from "../utils/fetchTagAmount"; +export { parseAndEvaluateExpr } from "../utils/parseExpr"; const BaseEvent = z.object({ type: z.number(), // overwritten later by discriminators @@ -66,6 +34,10 @@ const SDKCallEvent = BaseEvent.extend({ case: z.literal("tag"), value: z.string(), }), + z.object({ + case: z.literal("expr"), + value: z.string(), + }), ]), }) .transform(async (v) => { @@ -77,6 +49,11 @@ const SDKCallEvent = BaseEvent.extend({ return { sdkCallType: v.sdkCallType, debitAmount }; } + if (v.debit.case === "expr") { + const debitAmount = await parseAndEvaluateExpr(v.debit.value); + return { sdkCallType: v.sdkCallType, debitAmount }; + } + return { sdkCallType: v.sdkCallType, debitAmount: Math.floor(v.debit.value * 100), @@ -107,6 +84,10 @@ const AITokenUsageEvent = BaseEvent.extend({ case: z.literal("inputTag"), value: z.string(), }), + z.object({ + case: z.literal("inputExpr"), + value: z.string(), + }), ]), outputDebit: z.union([ z.object({ @@ -117,6 +98,10 @@ const AITokenUsageEvent = BaseEvent.extend({ case: z.literal("outputTag"), value: z.string(), }), + z.object({ + case: z.literal("outputExpr"), + value: z.string(), + }), ]), }) .transform(async (v) => { @@ -127,6 +112,8 @@ const AITokenUsageEvent = BaseEvent.extend({ v.inputDebit.value, `Input tag not found: ${v.inputDebit.value}` ); + } else if (v.inputDebit.case === "inputExpr") { + inputDebitAmount = await parseAndEvaluateExpr(v.inputDebit.value); } else { inputDebitAmount = Math.floor(v.inputDebit.value * 100); } @@ -138,6 +125,8 @@ const AITokenUsageEvent = BaseEvent.extend({ v.outputDebit.value, `Output tag not found: ${v.outputDebit.value}` ); + } else if (v.outputDebit.case === "outputExpr") { + outputDebitAmount = await parseAndEvaluateExpr(v.outputDebit.value); } else { outputDebitAmount = Math.floor(v.outputDebit.value * 100); }