From bd201c37671b1dfcdf5ffa06fcd9d6f6ddec3968 Mon Sep 17 00:00:00 2001 From: Jayadeep Bejoy Date: Wed, 17 Jun 2026 01:12:08 +0530 Subject: [PATCH 01/33] feat(project): Added projects --- .env.example | 1 + src/__tests__/auth.test.ts | 3 +- src/__tests__/createAPIKey.test.ts | 4 +- src/__tests__/fixtures/apiKey.ts | 5 + src/context/auth.ts | 1 + src/interceptors/auth.ts | 4 + src/routes/gRPC/auth/createAPIKey.ts | 1 + src/routes/gRPC/data/query.ts | 16 +- src/routes/gRPC/events/registerEvent.ts | 6 +- src/routes/gRPC/events/streamEvents.ts | 6 +- src/routes/gRPC/payment/createCheckoutLink.ts | 15 +- src/routes/gRPC/payment/paymentProvider.ts | 80 ++--- src/routes/http/api/apiKeys.ts | 18 +- src/routes/http/api/expressions.ts | 14 +- src/routes/http/api/onboarding.ts | 74 ++-- src/routes/http/api/tags.ts | 12 +- src/routes/http/api/webhookDeliveries.ts | 8 +- src/routes/http/api/webhookEndpoints.ts | 36 +- src/routes/http/createdCheckout.ts | 7 +- src/routes/http/forwardWebhook.ts | 8 +- src/routes/http/registerWebhookRoutes.ts | 15 +- .../clickhouse/handlers/addAiTokenUsage.ts | 3 +- .../clickhouse/handlers/addBasicUsage.ts | 3 +- .../handlers/priceRequestAiTokenUsage.ts | 4 +- .../handlers/priceRequestBasicUsage.ts | 4 +- .../clickhouse/handlers/queryEvents.ts | 30 +- src/storage/adapter/clickhouse/utils.ts | 1 + .../postgres/handlers/addAiTokenUsage.ts | 3 +- .../postgres/handlers/addBasicUsage.ts | 7 +- .../adapter/postgres/handlers/priceRequest.ts | 2 +- .../adapter/postgres/handlers/queryEvents.ts | 33 +- src/storage/db/postgres/helpers/apiKeys.ts | 11 +- .../db/postgres/helpers/expressions.ts | 40 ++- src/storage/db/postgres/helpers/metadata.ts | 100 ++---- src/storage/db/postgres/helpers/payments.ts | 2 + src/storage/db/postgres/helpers/sessions.ts | 4 + src/storage/db/postgres/helpers/tags.ts | 37 +- src/storage/db/postgres/helpers/users.ts | 12 +- .../db/postgres/helpers/webhookEndpoints.ts | 4 + src/storage/db/postgres/schema.ts | 132 ++++++- src/utils/apiKeyCache.ts | 1 + src/utils/authenticateHttpApiKey.ts | 15 +- src/utils/authenticateMasterApiKey.ts | 40 +++ src/utils/fetchTagAmount.ts | 14 +- src/utils/parseExpr.ts | 28 +- src/zod/event.ts | 337 ++++++++++-------- src/zod/internals.ts | 1 + 47 files changed, 808 insertions(+), 394 deletions(-) create mode 100644 src/utils/authenticateMasterApiKey.ts diff --git a/.env.example b/.env.example index 9b4d310..1f9ebac 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/scrawn CLICKHOUSE_URL=http://default:clickhouse@localhost:8123/scrawn HMAC_SECRET= +MASTER_API_KEY_HASH= # HMAC-SHA256 hex hash of the master API key (used for project creation during onboarding) APP_URL=http://localhost:8060 # URL the Scrawn backend is hosted on # SENTRY_DSN= diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index 71819ac..f8bd322 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -15,11 +15,12 @@ import { getPostgresDB } from "../storage/db/postgres/db"; import { webhookEndpointsTable } from "../storage/db/postgres/schema"; import { DateTime } from "luxon"; import { clearDatabase } from "./db"; -import { insertKey } from "./fixtures/apiKey"; +import { insertKey, TEST_PROJECT_ID } from "./fixtures/apiKey"; async function insertWebhookEndpoint(apiKeyId: string): Promise { const db = getPostgresDB(); await db.insert(webhookEndpointsTable).values({ + projectId: TEST_PROJECT_ID, apiKeyId, url: "https://example.com/webhook", privateKey: "test-private-key", diff --git a/src/__tests__/createAPIKey.test.ts b/src/__tests__/createAPIKey.test.ts index e0a2521..e691a3a 100644 --- a/src/__tests__/createAPIKey.test.ts +++ b/src/__tests__/createAPIKey.test.ts @@ -14,7 +14,7 @@ import { registerEvent, } from "./fixtures/grpc"; import { verifyApiKeyCreated } from "./assertions/events"; -import { createTestApiKey } from "./fixtures/apiKey"; +import { createTestApiKey, TEST_PROJECT_ID } from "./fixtures/apiKey"; import { getPostgresDB } from "../storage/db/postgres/db"; import { hashAPIKey } from "../utils/hashAPIKey"; import { @@ -44,6 +44,7 @@ async function createDashboardApiKey(): Promise<{ key: hashAPIKey(rawKey), role: "dashboard", expiresAt: DateTime.utc().plus({ years: 1 }).toISO(), + projectId: TEST_PROJECT_ID, }) .returning({ id: apiKeysTable.id }); return { rawKey, id: key!.id }; @@ -149,6 +150,7 @@ describe("AuthService", () => { const db = getPostgresDB(); await db.insert(webhookEndpointsTable).values({ + projectId: TEST_PROJECT_ID, apiKeyId: res.apiKeyId, url: "https://example.com/webhook", privateKey: "test-private-key", diff --git a/src/__tests__/fixtures/apiKey.ts b/src/__tests__/fixtures/apiKey.ts index dbdb718..1785354 100644 --- a/src/__tests__/fixtures/apiKey.ts +++ b/src/__tests__/fixtures/apiKey.ts @@ -6,6 +6,8 @@ import { import { hashAPIKey } from "../../utils/hashAPIKey"; import { DateTime } from "luxon"; +export const TEST_PROJECT_ID = "00000000-0000-0000-0000-000000000001"; + export async function createTestApiKey(): Promise<{ rawKey: string; id: string; @@ -19,10 +21,12 @@ export async function createTestApiKey(): Promise<{ key: hashAPIKey(rawKey), role: "test", expiresAt: DateTime.utc().plus({ years: 1 }).toISO(), + projectId: TEST_PROJECT_ID, }) .returning({ id: apiKeysTable.id }); await db.insert(webhookEndpointsTable).values({ + projectId: TEST_PROJECT_ID, apiKeyId: key!.id, url: "https://example.com/webhook", privateKey: "test-private-key", @@ -47,6 +51,7 @@ export async function insertKey( expiresAt: overrides.expiresAt ?? DateTime.utc().plus({ years: 1 }).toISO(), revoked: overrides.revoked ?? false, + projectId: TEST_PROJECT_ID, }) .returning({ id: apiKeysTable.id }); return key!.id; diff --git a/src/context/auth.ts b/src/context/auth.ts index 8bfe9da..bea042c 100644 --- a/src/context/auth.ts +++ b/src/context/auth.ts @@ -6,4 +6,5 @@ export interface AuthContext { apiKeyId: string; role: ApiKeyRole; mode: "production" | "test" | null; + projectId: string; } diff --git a/src/interceptors/auth.ts b/src/interceptors/auth.ts index 076b955..6f2fd76 100644 --- a/src/interceptors/auth.ts +++ b/src/interceptors/auth.ts @@ -167,6 +167,7 @@ export function authInterceptor( apiKeyId: cached.id, role: cached.role, mode: cached.mode, + projectId: cached.projectId, }; wideEventBuilder?.setAuth(cached.id, true); @@ -220,6 +221,7 @@ export function authInterceptor( id: apiKeyRecord.id, role: apiKeyRecord.role as ApiKeyRole, mode: recordMode, + projectId: apiKeyRecord.projectId, expiresAt: apiKeyRecord.expiresAt, }); @@ -227,6 +229,7 @@ export function authInterceptor( apiKeyId: apiKeyRecord.id, role: apiKeyRecord.role as ApiKeyRole, mode: recordMode, + projectId: apiKeyRecord.projectId, }; wideEventBuilder?.setAuth(apiKeyRecord.id, false); @@ -270,6 +273,7 @@ async function lookupApiKey(apiKeyHash: string) { role: apiKeysTable.role, expiresAt: apiKeysTable.expiresAt, revoked: apiKeysTable.revoked, + projectId: apiKeysTable.projectId, }) .from(apiKeysTable) .where(eq(apiKeysTable.key, apiKeyHash)) diff --git a/src/routes/gRPC/auth/createAPIKey.ts b/src/routes/gRPC/auth/createAPIKey.ts index 462e521..535214c 100644 --- a/src/routes/gRPC/auth/createAPIKey.ts +++ b/src/routes/gRPC/auth/createAPIKey.ts @@ -70,6 +70,7 @@ export async function createAPIKey( key: apiKeyHash, role: validatedData.role, expiresAt: expiresAt.toISO(), + projectId: auth.projectId, }); if (!keyEventData) { diff --git a/src/routes/gRPC/data/query.ts b/src/routes/gRPC/data/query.ts index 9f2799f..3732cf3 100644 --- a/src/routes/gRPC/data/query.ts +++ b/src/routes/gRPC/data/query.ts @@ -2,6 +2,7 @@ import type { sendUnaryData } from "@grpc/grpc-js"; import { QueryRequest, QueryResponse, Row } from "../../../gen/data/v1/data"; import { dataQuerySchema, type DataQueryRequest } from "../../../zod/data"; import { EventError } from "../../../errors/event"; +import { AuthError } from "../../../errors/auth"; import { formatZodError } from "../../../utils/formatZodError"; import { getPostgresDB } from "../../../storage/db/postgres/db"; import { @@ -29,6 +30,7 @@ import type { SQL } from "drizzle-orm"; import type { AnyPgColumn } from "drizzle-orm/pg-core"; import type { WideEventBuilder } from "../../../context/requestContext"; import { wideEventContextKey } from "../../../context/requestContext"; +import { apiKeyContextKey } from "../../../context/auth"; import type { ContextUnaryCall } from "../../../interface/types/context.js"; interface FieldDef { @@ -206,6 +208,11 @@ export async function queryData( | undefined; try { + const auth = call[apiKeyContextKey]; + if (!auth) { + return callback?.(AuthError.invalidAPIKey("API key context not found")); + } + const req = { ...call.request } as Record; const validated = dataQuerySchema.parse(req); @@ -223,7 +230,14 @@ export async function queryData( } const db = getPostgresDB(); - const whereClause = buildWhere(validated.where, tableDef); + const userWhere = buildWhere(validated.where, tableDef); + const projectFilter = eq( + (tableDef.table as any).projectId, + auth.projectId + ) as SQL; + const whereClause = userWhere + ? and(projectFilter, userWhere) + : projectFilter; const selectCols = buildSelect(tableDef); const columns = Object.keys(tableDef.fields); diff --git a/src/routes/gRPC/events/registerEvent.ts b/src/routes/gRPC/events/registerEvent.ts index 5ca981d..5973823 100644 --- a/src/routes/gRPC/events/registerEvent.ts +++ b/src/routes/gRPC/events/registerEvent.ts @@ -5,7 +5,7 @@ import { import type { WideEventBuilder } from "../../../context/requestContext"; import { apiKeyContextKey } from "../../../context/auth"; import { wideEventContextKey } from "../../../context/requestContext"; -import { registerEventSchema } from "../../../zod/event"; +import { createRegisterEventSchema } from "../../../zod/event"; import { EventError } from "../../../errors/event"; import { AuthError } from "../../../errors/auth"; import { createEventInstance, storeEvent } from "../../../utils/eventHelpers"; @@ -33,7 +33,9 @@ export async function registerEvent( ); } - const eventSkeleton = await registerEventSchema.parseAsync({ ...req }); + const eventSkeleton = await createRegisterEventSchema( + auth.projectId + ).parseAsync({ ...req }); wideEventBuilder?.setUser(eventSkeleton.userId); wideEventBuilder?.setEventContext({ eventType: eventSkeleton.type }); diff --git a/src/routes/gRPC/events/streamEvents.ts b/src/routes/gRPC/events/streamEvents.ts index c91faeb..e97e91d 100644 --- a/src/routes/gRPC/events/streamEvents.ts +++ b/src/routes/gRPC/events/streamEvents.ts @@ -8,7 +8,7 @@ import { import { EventError } from "../../../errors/event"; import { AuthError } from "../../../errors/auth"; import { StorageError } from "../../../errors/storage"; -import { streamEventSchema } from "../../../zod/event"; +import { createStreamEventSchema } from "../../../zod/event"; import { createEventInstance, storeEvent } from "../../../utils/eventHelpers"; import { apiKeyContextKey } from "../../../context/auth"; import { wideEventContextKey } from "../../../context/requestContext"; @@ -88,7 +88,9 @@ export async function streamEvents( for await (const req of call) { try { - const eventSkeleton = await streamEventSchema.parseAsync({ ...req }); + const eventSkeleton = await createStreamEventSchema( + auth.projectId + ).parseAsync({ ...req }); wideEventBuilder?.setUser(eventSkeleton.userId); wideEventBuilder?.setEventContext({ eventType: "AI_TOKEN_USAGE" }); diff --git a/src/routes/gRPC/payment/createCheckoutLink.ts b/src/routes/gRPC/payment/createCheckoutLink.ts index 097573b..23e58e6 100644 --- a/src/routes/gRPC/payment/createCheckoutLink.ts +++ b/src/routes/gRPC/payment/createCheckoutLink.ts @@ -64,7 +64,7 @@ export async function createCheckoutLink( const mode = auth.mode; - const config = await getPaymentProviderConfig(mode); + const config = await getPaymentProviderConfig(auth.projectId, mode); const validatedData = validateRequest(req); wideEventBuilder?.setUser(validatedData.userId); @@ -80,6 +80,7 @@ export async function createCheckoutLink( wideEventBuilder?.setPaymentContext({ priceAmount: custom_price }); const checkoutResult = await createCheckoutSession( + auth.projectId, config, custom_price, validatedData.userId, @@ -92,7 +93,7 @@ export async function createCheckoutLink( db, "create checkout link", async (txn) => { - await ensureUserExists(validatedData.userId, txn); + await ensureUserExists(auth.projectId, validatedData.userId, txn); await txn .select({ id: usersTable.id }) @@ -102,6 +103,7 @@ export async function createCheckoutLink( const existingId = await checkIfExistingCheckoutLink( txn, + auth.projectId, validatedData.userId, mode ); @@ -112,6 +114,7 @@ export async function createCheckoutLink( } const sessionResult = await handleAddSession( + auth.projectId, validatedData.userId, checkoutResult.sessionId, beforeTimestamp, @@ -164,6 +167,7 @@ async function calculatePrice( } async function createCheckoutSession( + projectId: string, config: PaymentProviderConfig, customPrice: number, userId: string, @@ -177,7 +181,12 @@ async function createCheckoutSession( apiKeyId, }; - const checkoutResult = await createProviderCheckout(config, params, mode); + const checkoutResult = await createProviderCheckout( + projectId, + config, + params, + mode + ); if ( !checkoutResult.checkoutUrl || diff --git a/src/routes/gRPC/payment/paymentProvider.ts b/src/routes/gRPC/payment/paymentProvider.ts index 3f938e9..bd3ac36 100644 --- a/src/routes/gRPC/payment/paymentProvider.ts +++ b/src/routes/gRPC/payment/paymentProvider.ts @@ -3,60 +3,58 @@ import { PaymentError } from "../../../errors/payment"; import { getMetadata } from "../../../storage/db/postgres/helpers/metadata"; import { decrypt } from "../../../utils/encryptMetadata.ts"; -let liveClient: DodoPayments | null = null; -let testClient: DodoPayments | null = null; +const clients = new Map(); -function clearClients(): void { - liveClient = null; - testClient = null; +function clientKey(projectId: string, mode: string): string { + return `${projectId}:${mode}`; +} + +export function clearClients(): void { + clients.clear(); } export async function getDodoClient( + projectId: string, mode?: "test" | "production" ): Promise { if (!mode) { mode = process.env.NODE_ENV === "production" ? "production" : "test"; } - if (mode === "production") { - if (liveClient) return liveClient; - - const metadata = await getMetadata(); - const apiKey = metadata?.dodo_live_api_key; - if (!apiKey) { - throw PaymentError.missingApiKey(); - } - - liveClient = new DodoPayments({ - bearerToken: decrypt(apiKey), - environment: "live_mode", - webhookKey: metadata?.dodo_live_webhook_secret - ? decrypt(metadata.dodo_live_webhook_secret) - : undefined, - }); - return liveClient; + const key = clientKey(projectId, mode); + const cached = clients.get(key); + if (cached) return cached; + + const metadata = await getMetadata(projectId); + + if (!metadata) { + throw PaymentError.missingMetadata(); } - if (testClient) return testClient; + const encryptedApiKey = + mode === "production" + ? metadata.dodo_live_api_key + : metadata.dodo_test_api_key; + const encryptedWebhookSecret = + mode === "production" + ? metadata.dodo_live_webhook_secret + : metadata.dodo_test_webhook_secret; - const metadata = await getMetadata(); - const apiKey = metadata?.dodo_test_api_key; - if (!apiKey) { + if (!encryptedApiKey) { throw PaymentError.missingApiKey(); } - testClient = new DodoPayments({ - bearerToken: decrypt(apiKey), - environment: "test_mode", - webhookKey: metadata?.dodo_test_webhook_secret - ? decrypt(metadata.dodo_test_webhook_secret) + const client = new DodoPayments({ + bearerToken: decrypt(encryptedApiKey), + environment: mode === "production" ? "live_mode" : "test_mode", + webhookKey: encryptedWebhookSecret + ? decrypt(encryptedWebhookSecret) : undefined, }); - return testClient; -} -// Re-export for callers who need to invalidate cached clients after onboarding updates -export { clearClients }; + clients.set(key, client); + return client; +} export interface PaymentProviderConfig { productId: string; @@ -76,13 +74,14 @@ export interface CheckoutResult { } export async function getPaymentProviderConfig( + projectId: string, mode: "test" | "production" ): Promise { if (!mode) { mode = process.env.NODE_ENV === "production" ? "production" : "test"; } - const metadata = await getMetadata(); + const metadata = await getMetadata(projectId); if (!metadata) { throw PaymentError.missingMetadata(); @@ -90,9 +89,9 @@ export async function getPaymentProviderConfig( const productId = mode === "production" - ? metadata?.dodo_live_product_id - : metadata?.dodo_test_product_id; - const returnUrl = metadata?.redirect_url ?? null; + ? metadata.dodo_live_product_id + : metadata.dodo_test_product_id; + const returnUrl = metadata.redirect_url ?? null; if (!productId) { throw PaymentError.missingProductId(); @@ -102,11 +101,12 @@ export async function getPaymentProviderConfig( } export async function createProviderCheckout( + projectId: string, config: PaymentProviderConfig, params: CheckoutParams, mode: "test" | "production" ): Promise { - const client = await getDodoClient(mode); + const client = await getDodoClient(projectId, mode); const session = await client.checkoutSessions.create({ product_cart: [ diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 2a18fd5..340566f 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -62,10 +62,12 @@ export async function handleCreateApiKey( key: apiKeyHash, role: validated.role, expiresAt: expiresAt.toISO(), + projectId: auth.projectId, }); const keyPair = generateWebhookKeyPair(); const endpoint = await upsertWebhookEndpoint( + auth.projectId, keyRecord.id, validated.webhookUrl, keyPair.privateKeyPem, @@ -127,7 +129,7 @@ export async function handleListApiKeys( ); try { - await authenticateHttpApiKey(request.headers.authorization); + const auth = await authenticateHttpApiKey(request.headers.authorization); const db = getPostgresDB(); const keys = await db @@ -151,7 +153,11 @@ export async function handleListApiKeys( ) ) .where( - and(ne(apiKeysTable.role, "dashboard"), eq(apiKeysTable.revoked, false)) + and( + eq(apiKeysTable.projectId, auth.projectId), + ne(apiKeysTable.role, "dashboard"), + eq(apiKeysTable.revoked, false) + ) ) .orderBy(apiKeysTable.createdAt); @@ -189,7 +195,7 @@ export async function handleRevokeApiKey( ); try { - await authenticateHttpApiKey(request.headers.authorization); + const auth = await authenticateHttpApiKey(request.headers.authorization); const params = request.params as { id: string }; const db = getPostgresDB(); @@ -199,7 +205,11 @@ export async function handleRevokeApiKey( .update(apiKeysTable) .set({ revoked: true, revokedAt: now }) .where( - and(eq(apiKeysTable.id, params.id), eq(apiKeysTable.revoked, false)) + and( + eq(apiKeysTable.projectId, auth.projectId), + eq(apiKeysTable.id, params.id), + eq(apiKeysTable.revoked, false) + ) ); if ((result.count ?? 0) === 0) { diff --git a/src/routes/http/api/expressions.ts b/src/routes/http/api/expressions.ts index 59e63d9..1b3cfeb 100644 --- a/src/routes/http/api/expressions.ts +++ b/src/routes/http/api/expressions.ts @@ -45,9 +45,9 @@ export async function handleListExpressions( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const auth = await authenticateHttpApiKey(authHeader); - const expressions = await listExpressions(); + const expressions = await listExpressions(auth.projectId); builder.setSuccess(200).addContext({ expressionCount: expressions.length }); reply.code(200); @@ -84,15 +84,15 @@ export async function handleCreateExpression( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const auth = await authenticateHttpApiKey(authHeader); const body = await request.body; const validated = createExpressionSchema.parse(body); validateExprSyntax(validated.expr); - await resolveExprRefsInExpression(validated.expr); + await resolveExprRefsInExpression(validated.expr, auth.projectId); - await createExpression(validated.key, validated.expr); + await createExpression(auth.projectId, validated.key, validated.expr); builder.setSuccess(200); reply.code(200); @@ -147,10 +147,10 @@ export async function handleDeleteExpression( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const auth = await authenticateHttpApiKey(authHeader); const params = request.params as { key: string }; - const deleted = await deleteExpression(params.key); + const deleted = await deleteExpression(auth.projectId, params.key); if (!deleted) { builder.setError(404, { diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index f3d5721..5a03dac 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import type { FastifyRequest, FastifyReply } from "fastify"; import * as Sentry from "@sentry/bun"; import { ZodError } from "zod"; @@ -9,18 +10,26 @@ import { } from "../../../context/requestContext.ts"; import { logger } from "../../../errors/logger.ts"; import { AuthError } from "../../../errors/auth"; +import { authenticateMasterApiKey } from "../../../utils/authenticateMasterApiKey.ts"; import { authenticateHttpApiKey } from "../../../utils/authenticateHttpApiKey.ts"; +import { generateAPIKey } from "../../../utils/generateAPIKey"; +import { hashAPIKey } from "../../../utils/hashAPIKey"; +import { encrypt, decrypt } from "../../../utils/encryptMetadata.ts"; +import { getPostgresDB } from "../../../storage/db/postgres/db"; import { - upsertMetadata, - getMetadata, -} from "../../../storage/db/postgres/helpers/metadata.ts"; + projectsTable, + apiKeysTable, + metadataTable, +} from "../../../storage/db/postgres/schema"; +import { getMetadata } from "../../../storage/db/postgres/helpers/metadata"; import { clearClients } from "../../gRPC/payment/paymentProvider.ts"; -import { encrypt, decrypt } from "../../../utils/encryptMetadata.ts"; +import { DateTime } from "luxon"; +import { executeInTransaction } from "../../../storage/adapter/postgres/handlers/addEventUtils"; export async function handleOnboarding( request: FastifyRequest, reply: FastifyReply -): Promise> { +): Promise> { const builder = createWideEventBuilder( generateRequestId(), request.method, @@ -29,7 +38,7 @@ export async function handleOnboarding( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + authenticateMasterApiKey(authHeader); const body = await request.body; const validated = onboardingSchema.parse(body); @@ -44,6 +53,8 @@ export async function handleOnboarding( return {}; } + const projectId = randomUUID(); + const liveClient = new DodoPayments({ bearerToken: validated.dodoLiveApiKey, environment: "live_mode", @@ -57,7 +68,7 @@ export async function handleOnboarding( let testSecret: string; try { const liveWebhook = await liveClient.webhooks.create({ - url: `${appUrl}/webhooks/payment/createdCheckout?mode=production`, + url: `${appUrl}/webhooks/payment/createdCheckout?mode=production&projectId=${projectId}`, description: "Scrawn live payment webhook", filter_types: ["payment.succeeded", "payment.failed"], }); @@ -65,7 +76,7 @@ export async function handleOnboarding( .secret; const testWebhook = await testClient.webhooks.create({ - url: `${appUrl}/webhooks/payment/createdCheckout?mode=test`, + url: `${appUrl}/webhooks/payment/createdCheckout?mode=test&projectId=${projectId}`, description: "Scrawn test payment webhook", filter_types: ["payment.succeeded", "payment.failed"], }); @@ -84,23 +95,44 @@ export async function handleOnboarding( return {}; } - await upsertMetadata({ - dodo_live_api_key: encrypt(validated.dodoLiveApiKey), - dodo_test_api_key: encrypt(validated.dodoTestApiKey), - dodo_live_product_id: validated.dodoLiveProductId, - dodo_test_product_id: validated.dodoTestProductId, - dodo_live_webhook_secret: encrypt(liveSecret), - dodo_test_webhook_secret: encrypt(testSecret), - currency: validated.currency, - redirect_url: validated.redirectUrl, + const dashboardKey = generateAPIKey("dashboard"); + const dashboardKeyHash = hashAPIKey(dashboardKey); + const expiresAt = DateTime.utc().plus({ years: 10 }).toISO(); + + const db = getPostgresDB(); + await executeInTransaction(db, "create project", async (txn) => { + await txn.insert(projectsTable).values({ + id: projectId, + name: validated.name, + }); + + await txn.insert(metadataTable).values({ + projectId, + dodo_live_api_key: encrypt(validated.dodoLiveApiKey), + dodo_test_api_key: encrypt(validated.dodoTestApiKey), + dodo_live_product_id: validated.dodoLiveProductId, + dodo_test_product_id: validated.dodoTestProductId, + dodo_live_webhook_secret: encrypt(liveSecret), + dodo_test_webhook_secret: encrypt(testSecret), + currency: validated.currency, + redirect_url: validated.redirectUrl, + }); + + await txn.insert(apiKeysTable).values({ + projectId, + name: "Default dashboard key", + key: dashboardKeyHash, + role: "dashboard", + expiresAt, + }); }); clearClients(); - builder.setSuccess(200); + builder.setSuccess(201); reply.code(201); - return {}; + return { projectId, apiKey: dashboardKey }; } catch (error) { Sentry.captureException(error, { extra: { context: "onboarding route handler" }, @@ -157,9 +189,9 @@ export async function handleGetConfig( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const auth = await authenticateHttpApiKey(authHeader); - const metadata = await getMetadata(); + const metadata = await getMetadata(auth.projectId); if (!metadata) { builder.setSuccess(200); diff --git a/src/routes/http/api/tags.ts b/src/routes/http/api/tags.ts index 6be7c28..444f875 100644 --- a/src/routes/http/api/tags.ts +++ b/src/routes/http/api/tags.ts @@ -47,9 +47,9 @@ export async function handleListTags( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const auth = await authenticateHttpApiKey(authHeader); - const tags = await listTags(); + const tags = await listTags(auth.projectId); builder.setSuccess(200).addContext({ tagCount: tags.length }); reply.code(200); @@ -86,12 +86,12 @@ export async function handleCreateTag( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const auth = await authenticateHttpApiKey(authHeader); const body = await request.body; const validated = createTagSchema.parse(body); - await createTag(validated.key, validated.amount); + await createTag(auth.projectId, validated.key, validated.amount); builder.setSuccess(200); reply.code(200); @@ -137,10 +137,10 @@ export async function handleDeleteTag( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const auth = await authenticateHttpApiKey(authHeader); const params = tagParamsSchema.parse(request.params); - const deleted = await deleteTag(params.key); + const deleted = await deleteTag(auth.projectId, params.key); if (!deleted) { builder.setError(404, { diff --git a/src/routes/http/api/webhookDeliveries.ts b/src/routes/http/api/webhookDeliveries.ts index a72fbfb..40c9901 100644 --- a/src/routes/http/api/webhookDeliveries.ts +++ b/src/routes/http/api/webhookDeliveries.ts @@ -16,6 +16,7 @@ import { apiKeysTable, } from "../../../storage/db/postgres/schema"; import { and, eq, desc, inArray, sql } from "drizzle-orm"; +import type { SQL } from "drizzle-orm"; const listDeliveriesQuerySchema = z.object({ apiKeyId: z.string().uuid("Invalid API key ID").optional(), @@ -37,12 +38,15 @@ export async function handleListDeliveries( ); try { - await authenticateHttpApiKey(request.headers.authorization); + const auth = await authenticateHttpApiKey(request.headers.authorization); const query = listDeliveriesQuerySchema.parse(request.query); const db = getPostgresDB(); - let conditions = undefined; + let conditions: SQL | undefined = eq( + webhookDeliveriesTable.projectId, + auth.projectId + ); if (query.apiKeyId) { const endpoints = await db .select({ id: webhookEndpointsTable.id }) diff --git a/src/routes/http/api/webhookEndpoints.ts b/src/routes/http/api/webhookEndpoints.ts index a02547b..961cebb 100644 --- a/src/routes/http/api/webhookEndpoints.ts +++ b/src/routes/http/api/webhookEndpoints.ts @@ -106,6 +106,15 @@ export async function handleCreateWebhookEndpoint( return { error: "Target API key not found" }; } + if (targetKey.projectId !== auth.projectId) { + builder.setError(403, { + type: "PermissionDenied", + message: "Target API key does not belong to this project", + }); + reply.code(403); + return { error: "Target API key does not belong to this project" }; + } + if (targetKey.role === "dashboard") { builder.setError(400, { type: "ValidationError", @@ -130,6 +139,7 @@ export async function handleCreateWebhookEndpoint( const keyPair = generateWebhookKeyPair(); const endpoint = await upsertWebhookEndpoint( + auth.projectId, targetApiKeyId, validated.url, keyPair.privateKeyPem, @@ -184,7 +194,10 @@ export async function handleGetWebhookEndpoint( const auth = await authenticateHttpApiKey(request.headers.authorization); builder.setApiKeyContext({ name: `webhook:${auth.apiKeyId}` }); - const endpoint = await getWebhookEndpointByApiKeyId(auth.apiKeyId); + const endpoint = await getWebhookEndpointByApiKeyId( + auth.projectId, + auth.apiKeyId + ); const endpoints: WebhookEndpointResponse[] = endpoint ? [toEndpointResponse(endpoint)] @@ -299,6 +312,15 @@ export async function handleSendTestWebhook( return { error: "API key not found" }; } + if (targetKey.projectId !== auth.projectId) { + builder.setError(403, { + type: "PermissionDenied", + message: "API key does not belong to this project", + }); + reply.code(403); + return { error: "API key does not belong to this project" }; + } + if (targetKey.role !== "test") { builder.setError(400, { type: "ValidationError", @@ -308,7 +330,10 @@ export async function handleSendTestWebhook( return { error: "Can only send test webhooks to test API keys" }; } - const endpoint = await getWebhookEndpointByApiKeyId(validated.apiKeyId); + const endpoint = await getWebhookEndpointByApiKeyId( + auth.projectId, + validated.apiKeyId + ); if (!endpoint) { builder.setError(404, { @@ -321,7 +346,7 @@ export async function handleSendTestWebhook( const now = DateTime.utc(); - await forwardWebhook(validated.apiKeyId, { + await forwardWebhook(auth.projectId, validated.apiKeyId, { eventType: "payment.succeeded", resource: "payment", action: "succeeded", @@ -374,7 +399,10 @@ export async function handleGetPublicKey( const auth = await authenticateHttpApiKey(request.headers.authorization); builder.setApiKeyContext({ name: `webhook:${auth.apiKeyId}` }); - const endpoint = await getWebhookEndpointByApiKeyId(auth.apiKeyId); + const endpoint = await getWebhookEndpointByApiKeyId( + auth.projectId, + auth.apiKeyId + ); if (!endpoint) { builder.setError(404, { diff --git a/src/routes/http/createdCheckout.ts b/src/routes/http/createdCheckout.ts index 878b648..d4855a3 100644 --- a/src/routes/http/createdCheckout.ts +++ b/src/routes/http/createdCheckout.ts @@ -72,10 +72,12 @@ export async function handleDodoWebhook( timestamp: string | undefined, webhookId: string | undefined, mode: "production" | "test", + projectId: string, builder: WideEventBuilder ): Promise { try { const client = await getDodoClient( + projectId, mode === "production" ? "production" : "test" ); const headers = buildWebhookHeaders(signature, timestamp, webhookId); @@ -158,7 +160,7 @@ export async function handleDodoWebhook( } builder.setSuccess(200); - forwardWebhook(session.apiKeyId, { + forwardWebhook(session.projectId, session.apiKeyId, { eventType: "payment.failed", resource: "payment", action: "failed", @@ -192,6 +194,7 @@ export async function handleDodoWebhook( if (!claimed) return; await updateUserBilledTimestamp(userId, billed_upto, txn); await handleAddPayment( + session.projectId, userId, creditAmount, apiKeyId, @@ -212,7 +215,7 @@ export async function handleDodoWebhook( builder.setPaymentContext({ creditAmount }); builder.setSuccess(200); - forwardWebhook(apiKeyId, { + forwardWebhook(session.projectId, apiKeyId, { eventType: "payment.succeeded", resource: "payment", action: "succeeded", diff --git a/src/routes/http/forwardWebhook.ts b/src/routes/http/forwardWebhook.ts index d27b4b2..ade2c7c 100644 --- a/src/routes/http/forwardWebhook.ts +++ b/src/routes/http/forwardWebhook.ts @@ -38,10 +38,11 @@ export interface WebhookForwardEvent { } export async function forwardWebhook( + projectId: string, apiKeyId: string, event: WebhookForwardEvent ): Promise { - const endpoint = await getWebhookEndpointByApiKeyId(apiKeyId); + const endpoint = await getWebhookEndpointByApiKeyId(projectId, apiKeyId); if (!endpoint) { return; @@ -71,7 +72,7 @@ export async function forwardWebhook( Sentry.captureException(error, { extra: { context: "webhook signing failed", error: errorMsg }, }); - await recordDelivery(endpoint.id, webhookId, event, "failed", { + await recordDelivery(projectId, endpoint.id, webhookId, event, "failed", { error: errorMsg, }); return; @@ -117,6 +118,7 @@ export async function forwardWebhook( } await recordDelivery( + projectId, endpoint.id, webhookId, event, @@ -129,6 +131,7 @@ export async function forwardWebhook( } async function recordDelivery( + projectId: string, endpointId: string, eventId: string, event: WebhookForwardEvent, @@ -141,6 +144,7 @@ async function recordDelivery( try { const db = getPostgresDB(); await db.insert(webhookDeliveriesTable).values({ + projectId, endpointId, eventId, eventType: event.eventType, diff --git a/src/routes/http/registerWebhookRoutes.ts b/src/routes/http/registerWebhookRoutes.ts index 0e15cd3..6f6e617 100644 --- a/src/routes/http/registerWebhookRoutes.ts +++ b/src/routes/http/registerWebhookRoutes.ts @@ -36,7 +36,10 @@ export async function registerWebhookRoutes( ); try { - const mode = (request.query as Record)?.mode; + const query = request.query as Record; + const mode = query?.mode; + const projectId = query?.projectId; + if (mode !== "production" && mode !== "test") { builder.setError(400, { type: "ValidationError", @@ -47,6 +50,15 @@ export async function registerWebhookRoutes( return { error: "Invalid mode query parameter" }; } + if (!projectId) { + builder.setError(400, { + type: "ValidationError", + message: "Missing 'projectId' query parameter.", + }); + reply.code(400); + return { error: "Missing projectId query parameter" }; + } + const signatureHeader = request.headers["webhook-signature"]; const timestampHeader = request.headers["webhook-timestamp"]; const webhookIdHeader = request.headers["webhook-id"]; @@ -72,6 +84,7 @@ export async function registerWebhookRoutes( timestamp, webhookId, mode, + projectId, builder ); diff --git a/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts b/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts index 052bde7..1061e1d 100644 --- a/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts +++ b/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts @@ -135,6 +135,7 @@ function buildAiTokenInsertRows( return { id: index === 0 ? firstId : crypto.randomUUID(), + project_id: auth.projectId, event_id: aggEvent.eventId, idempotency_key: aggEvent.idempotencyKey, user_id: aggEvent.userId, @@ -166,7 +167,7 @@ export async function handleAddAiTokenUsage( const firstEvent = events[0]; if (firstEvent) { - await ensureUserExists(firstEvent.userId); + await ensureUserExists(auth.projectId, firstEvent.userId); } const aggregatedEvents = aggregateAiTokenEvents(events); diff --git a/src/storage/adapter/clickhouse/handlers/addBasicUsage.ts b/src/storage/adapter/clickhouse/handlers/addBasicUsage.ts index 55a474d..6c2221c 100644 --- a/src/storage/adapter/clickhouse/handlers/addBasicUsage.ts +++ b/src/storage/adapter/clickhouse/handlers/addBasicUsage.ts @@ -27,7 +27,7 @@ export async function handleAddBasicUsage( } const reportedTimestamp = toClickHouseDateTime(event_data.reported_timestamp); - await ensureUserExists(event_data.userId); + await ensureUserExists(auth.projectId, event_data.userId); const id = crypto.randomUUID(); @@ -37,6 +37,7 @@ export async function handleAddBasicUsage( values: [ { id, + project_id: auth.projectId, event_id: event_data.eventId, idempotency_key: event_data.idempotencyKey, user_id: event_data.userId, diff --git a/src/storage/adapter/clickhouse/handlers/priceRequestAiTokenUsage.ts b/src/storage/adapter/clickhouse/handlers/priceRequestAiTokenUsage.ts index 13310f9..7cd4f13 100644 --- a/src/storage/adapter/clickhouse/handlers/priceRequestAiTokenUsage.ts +++ b/src/storage/adapter/clickhouse/handlers/priceRequestAiTokenUsage.ts @@ -5,8 +5,8 @@ import { runClickHousePriceQuery } from "../utils"; const VALUE_EXPR = "JSONExtractInt(metrics, 'debit_amount', 'input') + JSONExtractInt(metrics, 'debit_amount', 'input_cache') + JSONExtractInt(metrics, 'debit_amount', 'output')"; -const BASE_QUERY = `SELECT sum(${VALUE_EXPR}) as total FROM ai_token_usage_events WHERE user_id = {userId:String} AND mode = {mode:String} AND reported_timestamp < {before:DateTime64(3, 'UTC')}`; -const WINDOW_QUERY = `SELECT sum(${VALUE_EXPR}) as total FROM ai_token_usage_events WHERE user_id = {userId:String} AND mode = {mode:String} AND reported_timestamp > {lastBilled:DateTime64(3, 'UTC')} AND reported_timestamp < {before:DateTime64(3, 'UTC')}`; +const BASE_QUERY = `SELECT sum(${VALUE_EXPR}) as total FROM ai_token_usage_events WHERE project_id = {projectId:String} AND user_id = {userId:String} AND mode = {mode:String} AND reported_timestamp < {before:DateTime64(3, 'UTC')}`; +const WINDOW_QUERY = `SELECT sum(${VALUE_EXPR}) as total FROM ai_token_usage_events WHERE project_id = {projectId:String} AND user_id = {userId:String} AND mode = {mode:String} AND reported_timestamp > {lastBilled:DateTime64(3, 'UTC')} AND reported_timestamp < {before:DateTime64(3, 'UTC')}`; export async function handlePriceRequestAiTokenUsage( userId: UserId, diff --git a/src/storage/adapter/clickhouse/handlers/priceRequestBasicUsage.ts b/src/storage/adapter/clickhouse/handlers/priceRequestBasicUsage.ts index 86022de..6b7a8c5 100644 --- a/src/storage/adapter/clickhouse/handlers/priceRequestBasicUsage.ts +++ b/src/storage/adapter/clickhouse/handlers/priceRequestBasicUsage.ts @@ -4,9 +4,9 @@ import type { AuthContext } from "../../../../context/auth"; import { runClickHousePriceQuery } from "../utils"; const BASE_QUERY = - "SELECT sum(debit_amount) as total FROM basic_usage_events WHERE user_id = {userId:String} AND mode = {mode:String} AND reported_timestamp < {before:DateTime64(3, 'UTC')}"; + "SELECT sum(debit_amount) as total FROM basic_usage_events WHERE project_id = {projectId:String} AND user_id = {userId:String} AND mode = {mode:String} AND reported_timestamp < {before:DateTime64(3, 'UTC')}"; const WINDOW_QUERY = - "SELECT sum(debit_amount) as total FROM basic_usage_events WHERE user_id = {userId:String} AND mode = {mode:String} AND reported_timestamp > {lastBilled:DateTime64(3, 'UTC')} AND reported_timestamp < {before:DateTime64(3, 'UTC')}"; + "SELECT sum(debit_amount) as total FROM basic_usage_events WHERE project_id = {projectId:String} AND user_id = {userId:String} AND mode = {mode:String} AND reported_timestamp > {lastBilled:DateTime64(3, 'UTC')} AND reported_timestamp < {before:DateTime64(3, 'UTC')}"; export async function handlePriceRequestBasicUsage( userId: UserId, diff --git a/src/storage/adapter/clickhouse/handlers/queryEvents.ts b/src/storage/adapter/clickhouse/handlers/queryEvents.ts index 2f17e0e..abf3fa9 100644 --- a/src/storage/adapter/clickhouse/handlers/queryEvents.ts +++ b/src/storage/adapter/clickhouse/handlers/queryEvents.ts @@ -211,9 +211,9 @@ export async function handleQueryEvents( try { if (request.aggregation) { - return await handleAggregationQuery(request, tables); + return await handleAggregationQuery(request, tables, auth); } - return await handleListQuery(request, tables); + return await handleListQuery(request, tables, auth); } catch (e) { if ( e && @@ -232,11 +232,12 @@ export async function handleQueryEvents( async function handleListQuery( request: QueryRequest, - tables: EventTableName[] + tables: EventTableName[], + auth: AuthContext ): Promise { const client = getClickHouseDB(); const paramIndex = { value: 0 }; - const params: Record = {}; + const params: Record = { projectId: auth.projectId }; const queries = tables.map((t) => { const whereClause = buildWhereFromGroup( @@ -246,7 +247,8 @@ async function handleListQuery( paramIndex ); let q = `SELECT ${buildSelectColumns(t)} FROM ${t}`; - if (whereClause) q += ` WHERE ${whereClause}`; + q += ` WHERE project_id = {projectId:String}`; + if (whereClause) q += ` AND ${whereClause}`; return q; }); @@ -276,20 +278,21 @@ async function handleListQuery( data as unknown as Record[] ).map(normalizeRow); - const total = await getTotalCount(request, tables); + const total = await getTotalCount(request, tables, auth); return { rows, total }; } async function handleAggregationQuery( request: QueryRequest, - tables: EventTableName[] + tables: EventTableName[], + auth: AuthContext ): Promise { const client = getClickHouseDB(); const agg = request.aggregation!; const isSum = agg.type === "SUM"; const paramIndex = { value: 0 }; - const params: Record = {}; + const params: Record = { projectId: auth.projectId }; const subQueries = tables.map((t) => { const cols: string[] = []; @@ -325,7 +328,8 @@ async function handleAggregationQuery( paramIndex ); let q = `SELECT ${cols.join(", ")} FROM ${t}`; - if (whereClause) q += ` WHERE ${whereClause}`; + q += ` WHERE project_id = {projectId:String}`; + if (whereClause) q += ` AND ${whereClause}`; return q; }); @@ -364,11 +368,12 @@ async function handleAggregationQuery( async function getTotalCount( request: QueryRequest, - tables: EventTableName[] + tables: EventTableName[], + auth: AuthContext ): Promise { const client = getClickHouseDB(); const paramIndex = { value: 0 }; - const params: Record = {}; + const params: Record = { projectId: auth.projectId }; const subQueries = tables.map((t) => { const whereClause = buildWhereFromGroup( @@ -378,7 +383,8 @@ async function getTotalCount( paramIndex ); let q = `SELECT count() as cnt FROM ${t}`; - if (whereClause) q += ` WHERE ${whereClause}`; + q += ` WHERE project_id = {projectId:String}`; + if (whereClause) q += ` AND ${whereClause}`; return q; }); diff --git a/src/storage/adapter/clickhouse/utils.ts b/src/storage/adapter/clickhouse/utils.ts index 2858ba9..19e6d8e 100644 --- a/src/storage/adapter/clickhouse/utils.ts +++ b/src/storage/adapter/clickhouse/utils.ts @@ -70,6 +70,7 @@ export async function runClickHousePriceQuery( query = baseQuery; } + params.projectId = auth.projectId; params.mode = auth.mode; const rs = await chClient.query({ diff --git a/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts b/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts index 09a8bb1..1117fa2 100644 --- a/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts +++ b/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts @@ -117,6 +117,7 @@ function buildAiTokenInsertValues( auth: AuthContext ) { return aggregatedEvents.map((aggEvent) => ({ + projectId: auth.projectId, eventId: aggEvent.eventId, idempotencyKey: aggEvent.idempotencyKey, reportedTimestamp: aggEvent.reported_timestamp, @@ -166,7 +167,7 @@ export async function handleAddAiTokenUsage( `storing ${events.length} AI_TOKEN_USAGE event(s)`, async (txn) => { if (firstEvent) { - await ensureUserExists(firstEvent.userId, txn); + await ensureUserExists(auth.projectId, firstEvent.userId, txn); } try { diff --git a/src/storage/adapter/postgres/handlers/addBasicUsage.ts b/src/storage/adapter/postgres/handlers/addBasicUsage.ts index 127b4c0..0de79d3 100644 --- a/src/storage/adapter/postgres/handlers/addBasicUsage.ts +++ b/src/storage/adapter/postgres/handlers/addBasicUsage.ts @@ -28,7 +28,11 @@ export async function handleAddBasicUsage( connectionObject, "storing BASIC_USAGE event", async (txn) => { - const ensurePromise = ensureUserExists(event_data.userId, txn); + const ensurePromise = ensureUserExists( + auth.projectId, + event_data.userId, + txn + ); const reportedTimestamp = await validateAndPrepareTimestamp( event_data.reported_timestamp @@ -38,6 +42,7 @@ export async function handleAddBasicUsage( const [result] = await txn .insert(basicUsageEventsTable) .values({ + projectId: auth.projectId, eventId: event_data.eventId, idempotencyKey: event_data.idempotencyKey, reportedTimestamp, diff --git a/src/storage/adapter/postgres/handlers/priceRequest.ts b/src/storage/adapter/postgres/handlers/priceRequest.ts index 2cde117..ac13916 100644 --- a/src/storage/adapter/postgres/handlers/priceRequest.ts +++ b/src/storage/adapter/postgres/handlers/priceRequest.ts @@ -37,7 +37,7 @@ export async function handlePriceRequest( let result; try { - const baseCondition = sql`${priceTable.reportedTimestamp} > ${usersTable.last_billed_timestamp} AND ${priceTable.userId} = ${userId} AND ${priceTable.mode} = ${auth.mode}`; + const baseCondition = sql`${priceTable.reportedTimestamp} > ${usersTable.last_billed_timestamp} AND ${priceTable.userId} = ${userId} AND ${priceTable.mode} = ${auth.mode} AND ${priceTable.projectId} = ${auth.projectId}`; const whereClause = beforeTimestamp ? and( baseCondition, diff --git a/src/storage/adapter/postgres/handlers/queryEvents.ts b/src/storage/adapter/postgres/handlers/queryEvents.ts index 70bbfc8..3bcf970 100644 --- a/src/storage/adapter/postgres/handlers/queryEvents.ts +++ b/src/storage/adapter/postgres/handlers/queryEvents.ts @@ -255,9 +255,9 @@ export async function handleQueryEvents( try { if (request.aggregation) { - return await handleAggregationQuery(request, tables); + return await handleAggregationQuery(request, tables, auth); } - return await handleListQuery(request, tables); + return await handleListQuery(request, tables, auth); } catch (e) { if ( e && @@ -276,16 +276,21 @@ export async function handleQueryEvents( async function handleListQuery( request: QueryRequest, - tables: EventTableName[] + tables: EventTableName[], + auth: AuthContext ): Promise { const db = getPostgresDB(); const selectExpr = tables.map((t) => buildSelectColumns(t)); const whereExpr = tables.map((t) => buildWhereClause(request.where, t)); + const projectFilter = sql`project_id = ${auth.projectId}`; const subqueries = tables.map((t, i) => { const base = sql`SELECT ${selectExpr[i]} FROM ${sql.raw(t)}`; - return whereExpr[i] ? sql`${base} WHERE ${whereExpr[i]}` : base; + const fullWhere = whereExpr[i] + ? sql`${whereExpr[i]} AND ${projectFilter}` + : projectFilter; + return sql`${base} WHERE ${fullWhere}`; }); const unionQuery = sql.join(subqueries, sql` UNION ALL `); @@ -301,18 +306,20 @@ async function handleListQuery( const data = result as unknown as Record[]; const rows: QueryResultRow[] = data.map(normalizeRow); - const total = await getTotalCount(request, tables); + const total = await getTotalCount(request, tables, auth); return { rows, total }; } async function handleAggregationQuery( request: QueryRequest, - tables: EventTableName[] + tables: EventTableName[], + auth: AuthContext ): Promise { const db = getPostgresDB(); const agg = request.aggregation!; const isSum = agg.type === "SUM"; + const projectFilter = sql`project_id = ${auth.projectId}`; const subqueries = tables.map((t) => { const cols: SQL[] = []; @@ -349,7 +356,10 @@ async function handleAggregationQuery( const whereClause = buildWhereClause(request.where, t); const base = sql`SELECT ${sql.join(cols, sql`, `)} FROM ${sql.raw(t)}`; - return whereClause ? sql`${base} WHERE ${whereClause}` : base; + const fullWhere = whereClause + ? sql`${whereClause} AND ${projectFilter}` + : projectFilter; + return sql`${base} WHERE ${fullWhere}`; }); const unionQuery = sql.join(subqueries, sql` UNION ALL `); @@ -395,14 +405,19 @@ async function handleAggregationQuery( async function getTotalCount( request: QueryRequest, - tables: EventTableName[] + tables: EventTableName[], + auth: AuthContext ): Promise { const db = getPostgresDB(); + const projectFilter = sql`project_id = ${auth.projectId}`; const subqueries = tables.map((t) => { const whereClause = buildWhereClause(request.where, t); const base = sql`SELECT count(*)::int as cnt FROM ${sql.raw(t)}`; - return whereClause ? sql`${base} WHERE ${whereClause}` : base; + const fullWhere = whereClause + ? sql`${whereClause} AND ${projectFilter}` + : projectFilter; + return sql`${base} WHERE ${fullWhere}`; }); const countQuery = sql` diff --git a/src/storage/db/postgres/helpers/apiKeys.ts b/src/storage/db/postgres/helpers/apiKeys.ts index 5b36c3b..950ae8b 100644 --- a/src/storage/db/postgres/helpers/apiKeys.ts +++ b/src/storage/db/postgres/helpers/apiKeys.ts @@ -9,6 +9,7 @@ type CreateApiKeyInput = { key: string; role: string; expiresAt: string; + projectId: string; }; export async function createApiKey( @@ -37,6 +38,7 @@ export async function createApiKey( key: input.key, role: input.role as "dashboard" | "production" | "test", expiresAt: input.expiresAt, + projectId: input.projectId, }) .returning({ id: apiKeysTable.id }); @@ -74,16 +76,20 @@ type ApiKeyRecord = { role: string; expiresAt: string; revoked: boolean; + projectId: string; }; export async function getApiKeyRoleById( id: string -): Promise<{ role: "dashboard" | "production" | "test" } | null> { +): Promise<{ + role: "dashboard" | "production" | "test"; + projectId: string; +} | null> { const db = getPostgresDB(); try { const [record] = await db - .select({ role: apiKeysTable.role }) + .select({ role: apiKeysTable.role, projectId: apiKeysTable.projectId }) .from(apiKeysTable) .where(eq(apiKeysTable.id, id)) .limit(1); @@ -109,6 +115,7 @@ export async function findApiKeyByHash( role: apiKeysTable.role, expiresAt: apiKeysTable.expiresAt, revoked: apiKeysTable.revoked, + projectId: apiKeysTable.projectId, }) .from(apiKeysTable) .where(eq(apiKeysTable.key, apiKeyHash)) diff --git a/src/storage/db/postgres/helpers/expressions.ts b/src/storage/db/postgres/helpers/expressions.ts index 546b319..41639d6 100644 --- a/src/storage/db/postgres/helpers/expressions.ts +++ b/src/storage/db/postgres/helpers/expressions.ts @@ -4,14 +4,19 @@ import { eq, and, isNull } from "drizzle-orm"; import { StorageError } from "../../../../errors/storage"; import { DateTime } from "luxon"; -export async function listExpressions(): Promise { +export async function listExpressions(projectId: string): Promise { const db = getPostgresDB(); try { const rows = await db .select({ key: expressionsTable.key }) .from(expressionsTable) - .where(isNull(expressionsTable.deletedAt)); + .where( + and( + eq(expressionsTable.projectId, projectId), + isNull(expressionsTable.deletedAt) + ) + ); return rows.map((row) => row.key); } catch (e) { throw StorageError.queryFailed( @@ -21,7 +26,10 @@ export async function listExpressions(): Promise { } } -export async function findExpressionByKey(key: string): Promise { +export async function findExpressionByKey( + projectId: string, + key: string +): Promise { const db = getPostgresDB(); try { @@ -29,7 +37,11 @@ export async function findExpressionByKey(key: string): Promise { .select({ expr: expressionsTable.expr }) .from(expressionsTable) .where( - and(eq(expressionsTable.key, key), isNull(expressionsTable.deletedAt)) + and( + eq(expressionsTable.projectId, projectId), + eq(expressionsTable.key, key), + isNull(expressionsTable.deletedAt) + ) ) .limit(1); @@ -43,6 +55,7 @@ export async function findExpressionByKey(key: string): Promise { } export async function createExpression( + projectId: string, key: string, expr: string ): Promise { @@ -53,7 +66,11 @@ export async function createExpression( .select({ id: expressionsTable.id }) .from(expressionsTable) .where( - and(eq(expressionsTable.key, key), isNull(expressionsTable.deletedAt)) + and( + eq(expressionsTable.projectId, projectId), + eq(expressionsTable.key, key), + isNull(expressionsTable.deletedAt) + ) ) .limit(1); @@ -65,7 +82,7 @@ export async function createExpression( return; } - await db.insert(expressionsTable).values({ key, expr }); + await db.insert(expressionsTable).values({ projectId, key, expr }); } catch (e) { throw StorageError.insertFailed( `Failed to upsert expression '${key}'`, @@ -74,7 +91,10 @@ export async function createExpression( } } -export async function deleteExpression(key: string): Promise { +export async function deleteExpression( + projectId: string, + key: string +): Promise { const db = getPostgresDB(); try { @@ -83,7 +103,11 @@ export async function deleteExpression(key: string): Promise { .update(expressionsTable) .set({ deletedAt: now }) .where( - and(eq(expressionsTable.key, key), isNull(expressionsTable.deletedAt)) + and( + eq(expressionsTable.projectId, projectId), + eq(expressionsTable.key, key), + isNull(expressionsTable.deletedAt) + ) ); return (result.count ?? 0) > 0; diff --git a/src/storage/db/postgres/helpers/metadata.ts b/src/storage/db/postgres/helpers/metadata.ts index 4d83430..3ba469a 100644 --- a/src/storage/db/postgres/helpers/metadata.ts +++ b/src/storage/db/postgres/helpers/metadata.ts @@ -5,73 +5,51 @@ import { eq } from "drizzle-orm"; import { executeInTransaction } from "../../../adapter/postgres/handlers/addEventUtils"; export type UpsertMetadataInput = { - dodo_live_api_key?: string; - dodo_test_api_key?: string; - dodo_live_product_id?: string; - dodo_test_product_id?: string; - dodo_live_webhook_secret?: string; - dodo_test_webhook_secret?: string; - currency?: string; - redirect_url?: string; + dodo_live_api_key: string; + dodo_test_api_key: string; + dodo_live_product_id: string; + dodo_test_product_id: string; + dodo_live_webhook_secret: string; + dodo_test_webhook_secret: string; + currency: string; + redirect_url: string; }; -export async function upsertMetadata( - input: UpsertMetadataInput +export async function createMetadata( + projectId: string, + input: UpsertMetadataInput, + txn?: any ): Promise { - const db = getPostgresDB(); - - await executeInTransaction(db, "upsert metadata", async (txn) => { - try { - const [existingMetadata] = await txn - .select({ id: metadataTable.id }) - .from(metadataTable) - .limit(1) - .for("update"); - - const setValues: Partial = {}; - if (input.dodo_live_api_key !== undefined) - setValues.dodo_live_api_key = input.dodo_live_api_key; - if (input.dodo_test_api_key !== undefined) - setValues.dodo_test_api_key = input.dodo_test_api_key; - if (input.dodo_live_product_id !== undefined) - setValues.dodo_live_product_id = input.dodo_live_product_id; - if (input.dodo_test_product_id !== undefined) - setValues.dodo_test_product_id = input.dodo_test_product_id; - if (input.dodo_live_webhook_secret !== undefined) - setValues.dodo_live_webhook_secret = input.dodo_live_webhook_secret; - if (input.dodo_test_webhook_secret !== undefined) - setValues.dodo_test_webhook_secret = input.dodo_test_webhook_secret; - if (input.currency !== undefined) setValues.currency = input.currency; - if (input.redirect_url !== undefined) - setValues.redirect_url = input.redirect_url; - - if (existingMetadata) { - if (Object.keys(setValues).length > 0) { - await txn - .update(metadataTable) - .set(setValues) - .where(eq(metadataTable.id, existingMetadata.id)); - } - return; - } + const db = txn ?? getPostgresDB(); - const insertValues: typeof metadataTable.$inferInsert = { - ...setValues, - } as typeof metadataTable.$inferInsert; - await txn.insert(metadataTable).values(insertValues); - } catch (e) { - throw StorageError.insertFailed( - "Failed to upsert metadata record", - e instanceof Error ? e : new Error(String(e)) - ); - } - }); + try { + await db.insert(metadataTable).values({ + projectId, + dodo_live_api_key: input.dodo_live_api_key, + dodo_test_api_key: input.dodo_test_api_key, + dodo_live_product_id: input.dodo_live_product_id, + dodo_test_product_id: input.dodo_test_product_id, + dodo_live_webhook_secret: input.dodo_live_webhook_secret, + dodo_test_webhook_secret: input.dodo_test_webhook_secret, + currency: input.currency, + redirect_url: input.redirect_url, + }); + } catch (e) { + throw StorageError.insertFailed( + "Failed to create metadata record", + e instanceof Error ? e : new Error(String(e)) + ); + } } -export async function getMetadata(): Promise< - typeof metadataTable.$inferSelect | undefined -> { +export async function getMetadata( + projectId: string +): Promise { const db = getPostgresDB(); - const [metadata] = await db.select().from(metadataTable).limit(1); + const [metadata] = await db + .select() + .from(metadataTable) + .where(eq(metadataTable.projectId, projectId)) + .limit(1); return metadata; } diff --git a/src/storage/db/postgres/helpers/payments.ts b/src/storage/db/postgres/helpers/payments.ts index 9af0688..507e8f9 100644 --- a/src/storage/db/postgres/helpers/payments.ts +++ b/src/storage/db/postgres/helpers/payments.ts @@ -5,6 +5,7 @@ import { DateTime } from "luxon"; import type { PgTransaction } from "drizzle-orm/pg-core"; export async function handleAddPayment( + projectId: string, userId: string, creditAmount: number, apiKeyId: string, @@ -30,6 +31,7 @@ export async function handleAddPayment( const [result] = await db .insert(paymentEventsTable) .values({ + projectId, reportedTimestamp: DateTime.utc().toISO()!, userId, apiKeyId, diff --git a/src/storage/db/postgres/helpers/sessions.ts b/src/storage/db/postgres/helpers/sessions.ts index 4e3cf69..8598390 100644 --- a/src/storage/db/postgres/helpers/sessions.ts +++ b/src/storage/db/postgres/helpers/sessions.ts @@ -32,6 +32,7 @@ export async function updateSessionStatus( export async function checkIfExistingCheckoutLink( txn: PgTransaction, + projectId: string, userId: UserId, mode: "test" | "production" ): Promise { @@ -45,6 +46,7 @@ export async function checkIfExistingCheckoutLink( .from(sessionsTable) .where( and( + eq(sessionsTable.projectId, projectId), eq(sessionsTable.userId, userId), eq(sessionsTable.processed, "pending"), eq(sessionsTable.mode, mode), @@ -68,6 +70,7 @@ export async function checkIfExistingCheckoutLink( } export async function handleAddSession( + projectId: string, userId: UserId, sessionId: string, billedUpto: DateTime, @@ -91,6 +94,7 @@ export async function handleAddSession( const insertResult = await connectionObject .insert(sessionsTable) .values({ + projectId, userId: userId, sessionId: sessionId, billed_upto: billedUptoStr, diff --git a/src/storage/db/postgres/helpers/tags.ts b/src/storage/db/postgres/helpers/tags.ts index 9056f0f..b0fa739 100644 --- a/src/storage/db/postgres/helpers/tags.ts +++ b/src/storage/db/postgres/helpers/tags.ts @@ -5,14 +5,18 @@ import { StorageError } from "../../../../errors/storage"; import { DateTime } from "luxon"; import { tagCache } from "../../../../utils/tagCache"; -export async function listTags(): Promise<{ key: string; amount: number }[]> { +export async function listTags( + projectId: string +): Promise<{ key: string; amount: number }[]> { const db = getPostgresDB(); try { const rows = await db .select({ key: tagsTable.key, amount: tagsTable.amount }) .from(tagsTable) - .where(isNull(tagsTable.deletedAt)); + .where( + and(eq(tagsTable.projectId, projectId), isNull(tagsTable.deletedAt)) + ); return rows; } catch (e) { throw StorageError.queryFailed( @@ -22,14 +26,24 @@ export async function listTags(): Promise<{ key: string; amount: number }[]> { } } -export async function createTag(key: string, amount: number): Promise { +export async function createTag( + projectId: string, + key: string, + amount: number +): Promise { const db = getPostgresDB(); try { const existing = await db .select({ id: tagsTable.id }) .from(tagsTable) - .where(and(eq(tagsTable.key, key), isNull(tagsTable.deletedAt))) + .where( + and( + eq(tagsTable.projectId, projectId), + eq(tagsTable.key, key), + isNull(tagsTable.deletedAt) + ) + ) .limit(1); if (existing[0]) { @@ -41,7 +55,7 @@ export async function createTag(key: string, amount: number): Promise { return; } - await db.insert(tagsTable).values({ key, amount }); + await db.insert(tagsTable).values({ projectId, key, amount }); tagCache.delete(key); } catch (e) { throw StorageError.insertFailed( @@ -51,7 +65,10 @@ export async function createTag(key: string, amount: number): Promise { } } -export async function deleteTag(key: string): Promise { +export async function deleteTag( + projectId: string, + key: string +): Promise { const db = getPostgresDB(); try { @@ -59,7 +76,13 @@ export async function deleteTag(key: string): Promise { const result = await db .update(tagsTable) .set({ deletedAt: now }) - .where(and(eq(tagsTable.key, key), isNull(tagsTable.deletedAt))); + .where( + and( + eq(tagsTable.projectId, projectId), + eq(tagsTable.key, key), + isNull(tagsTable.deletedAt) + ) + ); if ((result.count ?? 0) > 0) { tagCache.delete(key); diff --git a/src/storage/db/postgres/helpers/users.ts b/src/storage/db/postgres/helpers/users.ts index eab1db6..b79dd61 100644 --- a/src/storage/db/postgres/helpers/users.ts +++ b/src/storage/db/postgres/helpers/users.ts @@ -1,6 +1,6 @@ import { getPostgresDB } from "../db"; import { usersTable } from "../schema"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { StorageError } from "../../../../errors/storage"; import type { PgTransaction } from "drizzle-orm/pg-core"; @@ -24,17 +24,21 @@ export async function updateUserBilledTimestamp( } } -export async function userExists(userId: string): Promise { +export async function userExists( + projectId: string, + userId: string +): Promise { const db = getPostgresDB(); const result = await db .select({ id: usersTable.id }) .from(usersTable) - .where(eq(usersTable.id, userId)) + .where(and(eq(usersTable.projectId, projectId), eq(usersTable.id, userId))) .limit(1); return result.length > 0; } export async function ensureUserExists( + projectId: string, userId: string, txn?: PgTransaction ): Promise { @@ -43,7 +47,7 @@ export async function ensureUserExists( try { await db .insert(usersTable) - .values({ id: userId }) + .values({ id: userId, projectId }) .onConflictDoNothing({ target: usersTable.id }); } catch (e) { if ( diff --git a/src/storage/db/postgres/helpers/webhookEndpoints.ts b/src/storage/db/postgres/helpers/webhookEndpoints.ts index 3d82ef6..c4ebfd3 100644 --- a/src/storage/db/postgres/helpers/webhookEndpoints.ts +++ b/src/storage/db/postgres/helpers/webhookEndpoints.ts @@ -7,6 +7,7 @@ import { DateTime } from "luxon"; export type WebhookEndpoint = typeof webhookEndpointsTable.$inferSelect; export async function getWebhookEndpointByApiKeyId( + projectId: string, apiKeyId: string ): Promise { const db = getPostgresDB(); @@ -17,6 +18,7 @@ export async function getWebhookEndpointByApiKeyId( .from(webhookEndpointsTable) .where( and( + eq(webhookEndpointsTable.projectId, projectId), eq(webhookEndpointsTable.apiKeyId, apiKeyId), isNull(webhookEndpointsTable.deletedAt) ) @@ -33,6 +35,7 @@ export async function getWebhookEndpointByApiKeyId( } export async function upsertWebhookEndpoint( + projectId: string, apiKeyId: string, url: string, privateKey: string, @@ -46,6 +49,7 @@ export async function upsertWebhookEndpoint( const [result] = await db .insert(webhookEndpointsTable) .values({ + projectId, apiKeyId, url, privateKey, diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index c266d0a..1f93982 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -14,8 +14,22 @@ import { USER_ID_CONFIG } from "../../../config/identifiers"; import { DateTime } from "luxon"; import { type Metrics } from "../../../zod/metrics"; +export const projectsTable = pgTable("projects", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + createdAt: timestamp("created_at", { + withTimezone: true, + mode: "string", + }) + .defaultNow() + .notNull(), +}); + export const usersTable = pgTable("users", { id: USER_ID_CONFIG.dbType("id").primaryKey(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), last_billed_timestamp: timestamp("last_billed_timestamp", { withTimezone: true, mode: "string", @@ -28,7 +42,11 @@ export const usersTable = pgTable("users", { .default("production"), }); -export const usersRelation = relations(usersTable, ({ many }) => ({ +export const usersRelation = relations(usersTable, ({ one, many }) => ({ + project: one(projectsTable, { + fields: [usersTable.projectId], + references: [projectsTable.id], + }), sessions: many(sessionsTable), basicUsageEvents: many(basicUsageEventsTable), paymentEvents: many(paymentEventsTable), @@ -39,6 +57,9 @@ export const sessionsTable = pgTable( "sessions", { proxy_link_id: uuid("proxy_link_id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), sessionId: text("session_id").notNull().unique(), processed: text("processed", { enum: ["pending", "failed", "succeeded"] }) .default("pending") @@ -70,6 +91,10 @@ export const sessionsTable = pgTable( ); export const sessionRelations = relations(sessionsTable, ({ one, many }) => ({ + project: one(projectsTable, { + fields: [sessionsTable.projectId], + references: [projectsTable.id], + }), user: one(usersTable, { fields: [sessionsTable.userId], references: [usersTable.id], @@ -85,6 +110,9 @@ export const apiKeysTable = pgTable( "api_keys", { id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), name: text("name").notNull(), key: text("key").notNull().unique(), role: text("role", { enum: ["dashboard", "production", "test"] }) @@ -108,12 +136,16 @@ export const apiKeysTable = pgTable( }, (table) => ({ uniqueActiveName: uniqueIndex("unique_active_name") - .on(table.name) + .on(table.projectId, table.name) .where(sql`${table.revoked} = false`), }) ); -export const apiKeysRelation = relations(apiKeysTable, ({ many }) => ({ +export const apiKeysRelation = relations(apiKeysTable, ({ one, many }) => ({ + project: one(projectsTable, { + fields: [apiKeysTable.projectId], + references: [projectsTable.id], + }), sessions: many(sessionsTable), basicUsageEvents: many(basicUsageEventsTable), paymentEvents: many(paymentEventsTable), @@ -122,6 +154,9 @@ export const apiKeysRelation = relations(apiKeysTable, ({ many }) => ({ export const basicUsageEventsTable = pgTable("basic_usage_events", { id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), eventId: uuid("event_id").notNull(), idempotencyKey: text("idempotency_key").notNull().unique(), reportedTimestamp: timestamp("reported_timestamp", { @@ -149,6 +184,10 @@ export const basicUsageEventsTable = pgTable("basic_usage_events", { export const basicUsageEventsRelation = relations( basicUsageEventsTable, ({ one }) => ({ + project: one(projectsTable, { + fields: [basicUsageEventsTable.projectId], + references: [projectsTable.id], + }), user: one(usersTable, { fields: [basicUsageEventsTable.userId], references: [usersTable.id], @@ -162,6 +201,9 @@ export const basicUsageEventsRelation = relations( export const paymentEventsTable = pgTable("payment_events", { id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), reportedTimestamp: timestamp("reported_timestamp", { withTimezone: true, mode: "string", @@ -188,6 +230,10 @@ export const paymentEventsTable = pgTable("payment_events", { export const paymentEventsRelation = relations( paymentEventsTable, ({ one }) => ({ + project: one(projectsTable, { + fields: [paymentEventsTable.projectId], + references: [projectsTable.id], + }), user: one(usersTable, { fields: [paymentEventsTable.userId], references: [usersTable.id], @@ -205,6 +251,9 @@ export const paymentEventsRelation = relations( export const aiTokenUsageEventsTable = pgTable("ai_token_usage_events", { id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), eventId: uuid("event_id").notNull(), idempotencyKey: text("idempotency_key").notNull().unique(), reportedTimestamp: timestamp("reported_timestamp", { @@ -233,6 +282,10 @@ export const aiTokenUsageEventsTable = pgTable("ai_token_usage_events", { export const aiTokenUsageEventsRelation = relations( aiTokenUsageEventsTable, ({ one }) => ({ + project: one(projectsTable, { + fields: [aiTokenUsageEventsTable.projectId], + references: [projectsTable.id], + }), user: one(usersTable, { fields: [aiTokenUsageEventsTable.userId], references: [usersTable.id], @@ -246,6 +299,9 @@ export const aiTokenUsageEventsRelation = relations( export const tagsTable = pgTable("tags", { id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), key: text("key").notNull(), amount: integer("amount").notNull(), deletedAt: timestamp("deleted_at", { @@ -254,24 +310,36 @@ export const tagsTable = pgTable("tags", { }), }); -export const metadataTable = pgTable("metadata", { - id: uuid("id").primaryKey().defaultRandom(), - last_run_at: timestamp("last_run_at", { - withTimezone: true, - mode: "string", - }), - dodo_live_api_key: text("dodo_live_api_key").notNull(), - dodo_test_api_key: text("dodo_test_api_key").notNull(), - dodo_live_product_id: text("dodo_live_product_id").notNull(), - dodo_test_product_id: text("dodo_test_product_id").notNull(), - dodo_live_webhook_secret: text("dodo_live_webhook_secret").notNull(), - dodo_test_webhook_secret: text("dodo_test_webhook_secret").notNull(), - currency: text("currency").notNull().default("usd"), - redirect_url: text("redirect_url").notNull(), -}); +export const metadataTable = pgTable( + "metadata", + { + id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), + last_run_at: timestamp("last_run_at", { + withTimezone: true, + mode: "string", + }), + dodo_live_api_key: text("dodo_live_api_key").notNull(), + dodo_test_api_key: text("dodo_test_api_key").notNull(), + dodo_live_product_id: text("dodo_live_product_id").notNull(), + dodo_test_product_id: text("dodo_test_product_id").notNull(), + dodo_live_webhook_secret: text("dodo_live_webhook_secret").notNull(), + dodo_test_webhook_secret: text("dodo_test_webhook_secret").notNull(), + currency: text("currency").notNull().default("usd"), + redirect_url: text("redirect_url").notNull(), + }, + (table) => ({ + uniqueProjectId: uniqueIndex("unique_project_id").on(table.projectId), + }) +); export const expressionsTable = pgTable("expressions", { id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), key: text("key").notNull(), expr: text("expr").notNull(), deletedAt: timestamp("deleted_at", { @@ -284,6 +352,9 @@ export const webhookEndpointsTable = pgTable( "webhook_endpoints", { id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), apiKeyId: uuid("api_key_id") .references(() => apiKeysTable.id) .notNull(), @@ -315,6 +386,10 @@ export const webhookEndpointsTable = pgTable( export const webhookEndpointsRelation = relations( webhookEndpointsTable, ({ one }) => ({ + project: one(projectsTable, { + fields: [webhookEndpointsTable.projectId], + references: [projectsTable.id], + }), apiKey: one(apiKeysTable, { fields: [webhookEndpointsTable.apiKeyId], references: [apiKeysTable.id], @@ -324,6 +399,9 @@ export const webhookEndpointsRelation = relations( export const webhookDeliveriesTable = pgTable("webhook_deliveries", { id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), endpointId: uuid("endpoint_id") .references(() => webhookEndpointsTable.id) .notNull(), @@ -346,9 +424,27 @@ export const webhookDeliveriesTable = pgTable("webhook_deliveries", { export const webhookDeliveriesRelation = relations( webhookDeliveriesTable, ({ one }) => ({ + project: one(projectsTable, { + fields: [webhookDeliveriesTable.projectId], + references: [projectsTable.id], + }), endpoint: one(webhookEndpointsTable, { fields: [webhookDeliveriesTable.endpointId], references: [webhookEndpointsTable.id], }), }) ); + +export const projectsRelation = relations(projectsTable, ({ many }) => ({ + users: many(usersTable), + sessions: many(sessionsTable), + apiKeys: many(apiKeysTable), + basicUsageEvents: many(basicUsageEventsTable), + paymentEvents: many(paymentEventsTable), + aiTokenUsageEvents: many(aiTokenUsageEventsTable), + tags: many(tagsTable), + metadata: many(metadataTable), + expressions: many(expressionsTable), + webhookEndpoints: many(webhookEndpointsTable), + webhookDeliveries: many(webhookDeliveriesTable), +})); diff --git a/src/utils/apiKeyCache.ts b/src/utils/apiKeyCache.ts index 86f7cf9..ee0c449 100644 --- a/src/utils/apiKeyCache.ts +++ b/src/utils/apiKeyCache.ts @@ -6,6 +6,7 @@ interface CachedAPIKey { id: string; role: ApiKeyRole; mode: "production" | "test" | null; + projectId: string; expiresAt: string; } diff --git a/src/utils/authenticateHttpApiKey.ts b/src/utils/authenticateHttpApiKey.ts index a467705..142853a 100644 --- a/src/utils/authenticateHttpApiKey.ts +++ b/src/utils/authenticateHttpApiKey.ts @@ -44,7 +44,12 @@ export async function authenticateHttpApiKey( `Key prefix ${role} doesn't match stored role ${cached.role}` ); } - return { apiKeyId: cached.id, role: cached.role, mode: cached.mode }; + return { + apiKeyId: cached.id, + role: cached.role, + mode: cached.mode, + projectId: cached.projectId, + }; } const apiKeyRecord = await findApiKeyByHash(apiKeyHash); @@ -76,8 +81,14 @@ export async function authenticateHttpApiKey( id: apiKeyRecord.id, role: recordRole, mode, + projectId: apiKeyRecord.projectId, expiresAt: apiKeyRecord.expiresAt, }); - return { apiKeyId: apiKeyRecord.id, role: recordRole, mode }; + return { + apiKeyId: apiKeyRecord.id, + role: recordRole, + mode, + projectId: apiKeyRecord.projectId, + }; } diff --git a/src/utils/authenticateMasterApiKey.ts b/src/utils/authenticateMasterApiKey.ts new file mode 100644 index 0000000..a3ac643 --- /dev/null +++ b/src/utils/authenticateMasterApiKey.ts @@ -0,0 +1,40 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import { AuthError } from "../errors/auth"; + +function getMasterKeyHash(): string { + const hash = process.env.MASTER_API_KEY_HASH; + if (!hash) { + throw new Error("MASTER_API_KEY_HASH environment variable is not set"); + } + return hash; +} + +export function authenticateMasterApiKey(authHeader: string | undefined): void { + if (!authHeader) { + throw AuthError.missingHeader(); + } + + if (!authHeader.startsWith("Bearer ")) { + throw AuthError.invalidHeaderFormat(); + } + + const apiKey = authHeader.slice("Bearer ".length).trim(); + + const hmacSecret = process.env.HMAC_SECRET; + if (!hmacSecret) { + throw new Error("HMAC_SECRET environment variable is not set"); + } + + const incomingHash = createHmac("sha256", hmacSecret) + .update(apiKey) + .digest("hex"); + + const storedHash = getMasterKeyHash(); + + if ( + incomingHash.length !== storedHash.length || + !timingSafeEqual(Buffer.from(incomingHash), Buffer.from(storedHash)) + ) { + throw AuthError.permissionDenied("Invalid master API key"); + } +} diff --git a/src/utils/fetchTagAmount.ts b/src/utils/fetchTagAmount.ts index 3b72d32..88ef60e 100644 --- a/src/utils/fetchTagAmount.ts +++ b/src/utils/fetchTagAmount.ts @@ -5,10 +5,12 @@ import { tagsTable } from "../storage/db/postgres/schema"; import { tagCache } from "./tagCache"; export async function fetchTagAmount( + projectId: string, tag: string, notFoundMessage: string ): Promise { - const cachedAmount = tagCache.get(tag); + const cacheKey = `${projectId}:${tag}`; + const cachedAmount = tagCache.get(cacheKey); if (cachedAmount !== undefined) { return cachedAmount; } @@ -17,13 +19,19 @@ export async function fetchTagAmount( const [tagRow] = await db .select() .from(tagsTable) - .where(and(eq(tagsTable.key, tag), isNull(tagsTable.deletedAt))) + .where( + and( + eq(tagsTable.projectId, projectId), + eq(tagsTable.key, tag), + isNull(tagsTable.deletedAt) + ) + ) .limit(1); if (!tagRow) { throw EventError.validationFailed(notFoundMessage); } - tagCache.set(tag, tagRow.amount); + tagCache.set(cacheKey, tagRow.amount); return tagRow.amount; } diff --git a/src/utils/parseExpr.ts b/src/utils/parseExpr.ts index c50584b..b5ceb06 100644 --- a/src/utils/parseExpr.ts +++ b/src/utils/parseExpr.ts @@ -184,6 +184,7 @@ export function validateExprSyntax(exprString: string): void { */ export async function resolveExprRefsInExpression( exprString: string, + projectId: string, resolving: Set = new Set() ): Promise { const refs = extractExprRefs(exprString); @@ -201,14 +202,18 @@ export async function resolveExprRefsInExpression( ); } - const storedExpr = await findExpressionByKey(refName); + const storedExpr = await findExpressionByKey(projectId, refName); if (!storedExpr) { throw EventError.validationFailed(`Expression not found: ${refName}`); } resolving.add(refName); - const expanded = await resolveExprRefsInExpression(storedExpr, resolving); + const expanded = await resolveExprRefsInExpression( + storedExpr, + projectId, + resolving + ); const refPattern = new RegExp(`expr\\(${refName}\\)`, "g"); resolved = resolved.replace(refPattern, `(${expanded})`); @@ -242,7 +247,10 @@ function extractExprRefs(exprString: string): string[] { * @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 { +async function resolveTagsInExpression( + exprString: string, + projectId: string +): Promise { const tagNames = extractTagNames(exprString); if (tagNames.length === 0) { @@ -253,7 +261,11 @@ async function resolveTagsInExpression(exprString: string): Promise { const tagValues = new Map(); for (const tagName of tagNames) { - const value = await fetchTagAmount(tagName, `Tag not found: ${tagName}`); + const value = await fetchTagAmount( + projectId, + tagName, + `Tag not found: ${tagName}` + ); tagValues.set(tagName, value); } @@ -323,16 +335,20 @@ function resolveTokenPlaceholders( */ export async function parseAndEvaluateExpr( exprString: string, + projectId: string, tokenContext?: EvalTokenContext ): Promise { // Step 1: Validate syntax validateExprSyntax(exprString); // Step 2: Resolve all expr(NAME) references (recursive, from DB) - const expandedExpr = await resolveExprRefsInExpression(exprString); + const expandedExpr = await resolveExprRefsInExpression(exprString, projectId); // Step 3: Resolve all tags to their values - const tagResolvedExpr = await resolveTagsInExpression(expandedExpr); + const tagResolvedExpr = await resolveTagsInExpression( + expandedExpr, + projectId + ); // Step 4: Resolve token placeholders if context provided const finalExpr = tokenContext diff --git a/src/zod/event.ts b/src/zod/event.ts index c6d5e6a..6040fd7 100644 --- a/src/zod/event.ts +++ b/src/zod/event.ts @@ -28,166 +28,195 @@ const BaseEvent = z.object({ idempotencyKey: z.string().min(1), }); -const BasicUsageDataSchema: z.ZodType = z - .object({ - basicUsageType: z.union([ - z - .literal(BasicUsageType.BASIC_USAGE_TYPE_UNSPECIFIED) - .transform(() => "RAW" as const), - z.literal(BasicUsageType.RAW).transform(() => "RAW" as const), - z - .literal(BasicUsageType.MIDDLEWARE_CALL) - .transform(() => "MIDDLEWARE_CALL" as const), - ]), - amount: z.number().optional(), - tag: z.string().optional(), - expr: z.string().optional(), - metadata: z.string().optional(), - }) - .transform(async (v): Promise => { - let debitAmount: number; - if (v.tag) { - debitAmount = await fetchTagAmount(v.tag, `Tag not found: ${v.tag}`); - } else if (v.expr) { - debitAmount = await parseAndEvaluateExpr(v.expr); - } else { - debitAmount = v.amount ?? 0; - } - return { - basicUsageType: v.basicUsageType, - debitAmount, - metadata: v.metadata ? parseMetadata(v.metadata) : undefined, - }; - }); - -const AITokenUsageDataSchema: z.ZodType = z - .object({ - model: z.string().min(1), - provider: z.string().optional().default("unknown"), - inputTokens: z.number().int().min(0), - inputCacheTokens: z.number().int().min(0), - outputTokens: z.number().int().min(0), - inputAmount: z.number().optional(), - inputTag: z.string().optional(), - inputExpr: z.string().optional(), - inputCacheAmount: z.number().optional(), - inputCacheTag: z.string().optional(), - inputCacheExpr: z.string().optional(), - outputCacheTokens: z.number().int().min(0), - outputCacheAmount: z.number().optional(), - outputCacheTag: z.string().optional(), - outputCacheExpr: z.string().optional(), - outputAmount: z.number().optional(), - outputTag: z.string().optional(), - outputExpr: z.string().optional(), - metadata: z.string().optional(), - }) - .transform(async (v): Promise => { - const tokenContext = { - inputTokens: v.inputTokens, - inputCacheTokens: v.inputCacheTokens, - outputTokens: v.outputTokens, - outputCacheTokens: v.outputCacheTokens, - }; - - let inputDebitAmount: number; - if (v.inputTag) { - inputDebitAmount = await fetchTagAmount( - v.inputTag, - `Input tag not found: ${v.inputTag}` - ); - } else if (v.inputExpr) { - inputDebitAmount = await parseAndEvaluateExpr(v.inputExpr, tokenContext); - } else { - inputDebitAmount = v.inputAmount ?? 0; - } +function createBasicUsageDataSchema( + projectId: string +): z.ZodType { + return z + .object({ + basicUsageType: z.union([ + z + .literal(BasicUsageType.BASIC_USAGE_TYPE_UNSPECIFIED) + .transform(() => "RAW" as const), + z.literal(BasicUsageType.RAW).transform(() => "RAW" as const), + z + .literal(BasicUsageType.MIDDLEWARE_CALL) + .transform(() => "MIDDLEWARE_CALL" as const), + ]), + amount: z.number().optional(), + tag: z.string().optional(), + expr: z.string().optional(), + metadata: z.string().optional(), + }) + .transform(async (v): Promise => { + let debitAmount: number; + if (v.tag) { + debitAmount = await fetchTagAmount( + projectId, + v.tag, + `Tag not found: ${v.tag}` + ); + } else if (v.expr) { + debitAmount = await parseAndEvaluateExpr(v.expr, projectId); + } else { + debitAmount = v.amount ?? 0; + } + return { + basicUsageType: v.basicUsageType, + debitAmount, + metadata: v.metadata ? parseMetadata(v.metadata) : undefined, + }; + }); +} - let inputCacheDebitAmount: number; - if (v.inputCacheTag) { - inputCacheDebitAmount = await fetchTagAmount( - v.inputCacheTag, - `Input cache tag not found: ${v.inputCacheTag}` - ); - } else if (v.inputCacheExpr) { - inputCacheDebitAmount = await parseAndEvaluateExpr( - v.inputCacheExpr, - tokenContext - ); - } else { - inputCacheDebitAmount = v.inputCacheAmount ?? 0; - } +function createAITokenUsageDataSchema( + projectId: string +): z.ZodType { + return z + .object({ + model: z.string(), + provider: z.string(), + inputTokens: z.number(), + inputCacheTokens: z.number(), + outputTokens: z.number(), + outputCacheTokens: z.number(), + inputTag: z.string().optional(), + inputExpr: z.string().optional(), + inputAmount: z.number().optional(), + inputCacheTag: z.string().optional(), + inputCacheExpr: z.string().optional(), + inputCacheAmount: z.number().optional(), + outputTag: z.string().optional(), + outputExpr: z.string().optional(), + outputAmount: z.number().optional(), + outputCacheTag: z.string().optional(), + outputCacheExpr: z.string().optional(), + outputCacheAmount: z.number().optional(), + metadata: z.string().optional(), + }) + .transform(async (v): Promise => { + const tokenContext = { + inputTokens: v.inputTokens, + inputCacheTokens: v.inputCacheTokens, + outputTokens: v.outputTokens, + outputCacheTokens: v.outputCacheTokens, + }; - let outputCacheDebitAmount: number; - if (v.outputCacheTag) { - outputCacheDebitAmount = await fetchTagAmount( - v.outputCacheTag, - `Output cache tag not found: ${v.outputCacheTag}` - ); - } else if (v.outputCacheExpr) { - outputCacheDebitAmount = await parseAndEvaluateExpr( - v.outputCacheExpr, - tokenContext - ); - } else { - outputCacheDebitAmount = v.outputCacheAmount ?? 0; - } + let inputDebitAmount: number; + if (v.inputTag) { + inputDebitAmount = await fetchTagAmount( + projectId, + v.inputTag, + `Input tag not found: ${v.inputTag}` + ); + } else if (v.inputExpr) { + inputDebitAmount = await parseAndEvaluateExpr( + v.inputExpr, + projectId, + tokenContext + ); + } else { + inputDebitAmount = v.inputAmount ?? 0; + } - let outputDebitAmount: number; - if (v.outputTag) { - outputDebitAmount = await fetchTagAmount( - v.outputTag, - `Output tag not found: ${v.outputTag}` - ); - } else if (v.outputExpr) { - outputDebitAmount = await parseAndEvaluateExpr( - v.outputExpr, - tokenContext - ); - } else { - outputDebitAmount = v.outputAmount ?? 0; - } + let inputCacheDebitAmount: number; + if (v.inputCacheTag) { + inputCacheDebitAmount = await fetchTagAmount( + projectId, + v.inputCacheTag, + `Input cache tag not found: ${v.inputCacheTag}` + ); + } else if (v.inputCacheExpr) { + inputCacheDebitAmount = await parseAndEvaluateExpr( + v.inputCacheExpr, + projectId, + tokenContext + ); + } else { + inputCacheDebitAmount = v.inputCacheAmount ?? 0; + } - return { - model: v.model, - provider: v.provider, - inputTokens: v.inputTokens, - inputCacheTokens: v.inputCacheTokens, - outputTokens: v.outputTokens, - outputCacheTokens: v.outputCacheTokens, - inputDebitAmount, - inputCacheDebitAmount, - outputCacheDebitAmount, - outputDebitAmount, - metadata: v.metadata ? parseMetadata(v.metadata) : undefined, - }; - }); + let outputCacheDebitAmount: number; + if (v.outputCacheTag) { + outputCacheDebitAmount = await fetchTagAmount( + projectId, + v.outputCacheTag, + `Output cache tag not found: ${v.outputCacheTag}` + ); + } else if (v.outputCacheExpr) { + outputCacheDebitAmount = await parseAndEvaluateExpr( + v.outputCacheExpr, + projectId, + tokenContext + ); + } else { + outputCacheDebitAmount = v.outputCacheAmount ?? 0; + } -const RegisterEventBasicUsage = BaseEvent.extend({ - type: z - .literal(EventType.BASIC_USAGE) - .transform(() => "BASIC_USAGE" as const), - basicUsage: BasicUsageDataSchema, -}); + let outputDebitAmount: number; + if (v.outputTag) { + outputDebitAmount = await fetchTagAmount( + projectId, + v.outputTag, + `Output tag not found: ${v.outputTag}` + ); + } else if (v.outputExpr) { + outputDebitAmount = await parseAndEvaluateExpr( + v.outputExpr, + projectId, + tokenContext + ); + } else { + outputDebitAmount = v.outputAmount ?? 0; + } -const StreamEventBasicUsage = BaseEvent.extend({ - type: z - .literal(EventType.BASIC_USAGE) - .transform(() => "BASIC_USAGE" as const), - basicUsage: BasicUsageDataSchema, -}); + return { + model: v.model, + provider: v.provider, + inputTokens: v.inputTokens, + inputCacheTokens: v.inputCacheTokens, + outputTokens: v.outputTokens, + outputCacheTokens: v.outputCacheTokens, + inputDebitAmount, + inputCacheDebitAmount, + outputCacheDebitAmount, + outputDebitAmount, + metadata: v.metadata ? parseMetadata(v.metadata) : undefined, + }; + }); +} -const StreamEventAITokenUsage = BaseEvent.extend({ - type: z - .literal(EventType.AI_TOKEN_USAGE) - .transform(() => "AI_TOKEN_USAGE" as const), - aiTokenUsage: AITokenUsageDataSchema, -}); +export function createRegisterEventSchema(projectId: string) { + const BasicUsageDataSchema = createBasicUsageDataSchema(projectId); + return BaseEvent.extend({ + type: z + .literal(EventType.BASIC_USAGE) + .transform(() => "BASIC_USAGE" as const), + basicUsage: BasicUsageDataSchema, + }); +} -export const registerEventSchema = RegisterEventBasicUsage; -export type RegisterEventSchemaType = z.output; +export function createStreamEventSchema(projectId: string) { + const BasicUsageDataSchema = createBasicUsageDataSchema(projectId); + const AITokenUsageDataSchema = createAITokenUsageDataSchema(projectId); + return z.union([ + BaseEvent.extend({ + type: z + .literal(EventType.BASIC_USAGE) + .transform(() => "BASIC_USAGE" as const), + basicUsage: BasicUsageDataSchema, + }), + BaseEvent.extend({ + type: z + .literal(EventType.AI_TOKEN_USAGE) + .transform(() => "AI_TOKEN_USAGE" as const), + aiTokenUsage: AITokenUsageDataSchema, + }), + ]); +} -export const streamEventSchema = z.union([ - StreamEventBasicUsage, - StreamEventAITokenUsage, -]); -export type StreamEventSchemaType = z.output; +export type RegisterEventSchemaType = z.output< + ReturnType +>; +export type StreamEventSchemaType = z.output< + ReturnType +>; diff --git a/src/zod/internals.ts b/src/zod/internals.ts index 938d40b..525b71d 100644 --- a/src/zod/internals.ts +++ b/src/zod/internals.ts @@ -33,6 +33,7 @@ export function createFilterGroupSchema( } export const onboardingSchema = z.object({ + name: z.string().min(1, "Project name is required").max(255), dodoLiveApiKey: z.string().min(1, "Dodo live API key is required"), dodoTestApiKey: z.string().min(1, "Dodo test API key is required"), dodoLiveProductId: z.string().min(1, "Dodo live product ID is required"), From bfdfe30eb0cc6b145a04a8bfed4224268316316a Mon Sep 17 00:00:00 2001 From: Jayadeep Bejoy Date: Wed, 17 Jun 2026 01:28:15 +0530 Subject: [PATCH 02/33] fix(datetime): Slight changes --- src/interceptors/auth.ts | 2 +- src/routes/http/api/apiKeys.ts | 15 ++++++ src/routes/http/api/onboarding.ts | 88 +++++++++++++++++++++++-------- src/utils/apiKeyCache.ts | 2 +- 4 files changed, 82 insertions(+), 25 deletions(-) diff --git a/src/interceptors/auth.ts b/src/interceptors/auth.ts index 6f2fd76..b271258 100644 --- a/src/interceptors/auth.ts +++ b/src/interceptors/auth.ts @@ -202,7 +202,7 @@ export function authInterceptor( if ( DateTime.utc() > - DateTime.fromSQL(apiKeyRecord.expiresAt, { zone: "utc" }) + DateTime.fromISO(apiKeyRecord.expiresAt, { zone: "utc" }) ) { return callback?.(AuthError.expiredAPIKey()); } diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 340566f..59f110e 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -23,6 +23,7 @@ import { import { eq, and, isNull, ne, sql } from "drizzle-orm"; import type { ApiKeyRole } from "../../../utils/keyFormat"; import { invalidateWebhookEndpointCache } from "../../../interceptors/auth"; +import { apiKeyCache } from "../../../utils/apiKeyCache"; const createApiKeySchema = z.object({ name: z.string().min(1, "Name is required").max(255), @@ -221,6 +222,20 @@ export async function handleRevokeApiKey( return { error: "API key not found or already revoked" }; } + const [keyRow] = await db + .select({ key: apiKeysTable.key }) + .from(apiKeysTable) + .where( + and( + eq(apiKeysTable.projectId, auth.projectId), + eq(apiKeysTable.id, params.id) + ) + ) + .limit(1); + if (keyRow) { + apiKeyCache.delete(keyRow.key); + } + builder.setSuccess(200); reply.code(200); return { message: "API key revoked" }; diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index 5a03dac..7597093 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -66,12 +66,15 @@ export async function handleOnboarding( let liveSecret: string; let testSecret: string; + let liveWebhookId: string | undefined; + let testWebhookId: string | undefined; try { const liveWebhook = await liveClient.webhooks.create({ url: `${appUrl}/webhooks/payment/createdCheckout?mode=production&projectId=${projectId}`, description: "Scrawn live payment webhook", filter_types: ["payment.succeeded", "payment.failed"], }); + liveWebhookId = liveWebhook.id; liveSecret = (await liveClient.webhooks.retrieveSecret(liveWebhook.id)) .secret; @@ -80,9 +83,24 @@ export async function handleOnboarding( description: "Scrawn test payment webhook", filter_types: ["payment.succeeded", "payment.failed"], }); + testWebhookId = testWebhook.id; testSecret = (await testClient.webhooks.retrieveSecret(testWebhook.id)) .secret; } catch (error) { + if (liveWebhookId) { + liveClient.webhooks.delete(liveWebhookId).catch((e) => + Sentry.captureException(e, { + extra: { context: "rollback: failed to delete live webhook" }, + }) + ); + } + if (testWebhookId) { + testClient.webhooks.delete(testWebhookId).catch((e) => + Sentry.captureException(e, { + extra: { context: "rollback: failed to delete test webhook" }, + }) + ); + } const errMsg = error instanceof Error ? error.message : String(error); Sentry.captureException(error, { extra: { context: "dodo webhook registration during onboarding" }, @@ -100,32 +118,56 @@ export async function handleOnboarding( const expiresAt = DateTime.utc().plus({ years: 10 }).toISO(); const db = getPostgresDB(); - await executeInTransaction(db, "create project", async (txn) => { - await txn.insert(projectsTable).values({ - id: projectId, - name: validated.name, - }); + try { + await executeInTransaction(db, "create project", async (txn) => { + await txn.insert(projectsTable).values({ + id: projectId, + name: validated.name, + }); - await txn.insert(metadataTable).values({ - projectId, - dodo_live_api_key: encrypt(validated.dodoLiveApiKey), - dodo_test_api_key: encrypt(validated.dodoTestApiKey), - dodo_live_product_id: validated.dodoLiveProductId, - dodo_test_product_id: validated.dodoTestProductId, - dodo_live_webhook_secret: encrypt(liveSecret), - dodo_test_webhook_secret: encrypt(testSecret), - currency: validated.currency, - redirect_url: validated.redirectUrl, - }); + await txn.insert(metadataTable).values({ + projectId, + dodo_live_api_key: encrypt(validated.dodoLiveApiKey), + dodo_test_api_key: encrypt(validated.dodoTestApiKey), + dodo_live_product_id: validated.dodoLiveProductId, + dodo_test_product_id: validated.dodoTestProductId, + dodo_live_webhook_secret: encrypt(liveSecret), + dodo_test_webhook_secret: encrypt(testSecret), + currency: validated.currency, + redirect_url: validated.redirectUrl, + }); - await txn.insert(apiKeysTable).values({ - projectId, - name: "Default dashboard key", - key: dashboardKeyHash, - role: "dashboard", - expiresAt, + await txn.insert(apiKeysTable).values({ + projectId, + name: "Default dashboard key", + key: dashboardKeyHash, + role: "dashboard", + expiresAt, + }); }); - }); + } catch (txnError) { + if (liveWebhookId) { + liveClient.webhooks.delete(liveWebhookId).catch((e) => + Sentry.captureException(e, { + extra: { + context: + "rollback: failed to delete live webhook after DB failure", + }, + }) + ); + } + if (testWebhookId) { + testClient.webhooks.delete(testWebhookId).catch((e) => + Sentry.captureException(e, { + extra: { + context: + "rollback: failed to delete test webhook after DB failure", + }, + }) + ); + } + throw txnError; + } clearClients(); diff --git a/src/utils/apiKeyCache.ts b/src/utils/apiKeyCache.ts index ee0c449..d8bdfa3 100644 --- a/src/utils/apiKeyCache.ts +++ b/src/utils/apiKeyCache.ts @@ -14,7 +14,7 @@ const store = Cache.getStore("api-keys", { max: 1000, ttlMs: 5 * 60 * 1000, validate: (value) => - DateTime.utc() <= DateTime.fromSQL(value.expiresAt, { zone: "utc" }), + DateTime.utc() <= DateTime.fromISO(value.expiresAt, { zone: "utc" }), }); export const apiKeyCache = { From 4cbc1ed7d5d020ba7c88e79cc55f84d0749182bf Mon Sep 17 00:00:00 2001 From: Jayadeep Bejoy Date: Wed, 17 Jun 2026 01:36:03 +0530 Subject: [PATCH 03/33] fix(webhooks): potential leak) --- src/routes/http/api/webhookDeliveries.ts | 25 +++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/routes/http/api/webhookDeliveries.ts b/src/routes/http/api/webhookDeliveries.ts index 40c9901..8830b31 100644 --- a/src/routes/http/api/webhookDeliveries.ts +++ b/src/routes/http/api/webhookDeliveries.ts @@ -51,16 +51,23 @@ export async function handleListDeliveries( const endpoints = await db .select({ id: webhookEndpointsTable.id }) .from(webhookEndpointsTable) - .where(eq(webhookEndpointsTable.apiKeyId, query.apiKeyId)); - const ids = endpoints.map((e) => e.id); - if (ids.length > 0) { - conditions = inArray(webhookDeliveriesTable.endpointId, ids); - } else { - conditions = eq( - webhookDeliveriesTable.id, - "00000000-0000-0000-0000-000000000000" + .where( + and( + eq(webhookEndpointsTable.projectId, auth.projectId), + eq(webhookEndpointsTable.apiKeyId, query.apiKeyId) + ) ); - } + const ids = endpoints.map((e) => e.id); + conditions = + ids.length > 0 + ? and(conditions, inArray(webhookDeliveriesTable.endpointId, ids)) + : and( + conditions, + eq( + webhookDeliveriesTable.id, + "00000000-0000-0000-0000-000000000000" + ) + ); } if (query.eventType) { From 4b9f81e511c3c9c2dc40b21c27641c39befe8c15 Mon Sep 17 00:00:00 2001 From: Jayadeep Bejoy Date: Wed, 17 Jun 2026 02:47:30 +0530 Subject: [PATCH 04/33] fix(user): Cache invalidation --- src/storage/db/postgres/helpers/tags.ts | 6 ++-- src/storage/db/postgres/helpers/users.ts | 15 ++++++--- src/storage/db/postgres/schema.ts | 39 +++++++++++++++--------- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/storage/db/postgres/helpers/tags.ts b/src/storage/db/postgres/helpers/tags.ts index b0fa739..e20d072 100644 --- a/src/storage/db/postgres/helpers/tags.ts +++ b/src/storage/db/postgres/helpers/tags.ts @@ -51,12 +51,12 @@ export async function createTag( .update(tagsTable) .set({ amount }) .where(eq(tagsTable.id, existing[0].id)); - tagCache.delete(key); + tagCache.delete(`${projectId}:${key}`); return; } await db.insert(tagsTable).values({ projectId, key, amount }); - tagCache.delete(key); + tagCache.delete(`${projectId}:${key}`); } catch (e) { throw StorageError.insertFailed( `Failed to upsert tag '${key}'`, @@ -85,7 +85,7 @@ export async function deleteTag( ); if ((result.count ?? 0) > 0) { - tagCache.delete(key); + tagCache.delete(`${projectId}:${key}`); return true; } return false; diff --git a/src/storage/db/postgres/helpers/users.ts b/src/storage/db/postgres/helpers/users.ts index b79dd61..13fa33d 100644 --- a/src/storage/db/postgres/helpers/users.ts +++ b/src/storage/db/postgres/helpers/users.ts @@ -45,10 +45,17 @@ export async function ensureUserExists( const db = txn ?? getPostgresDB(); try { - await db - .insert(usersTable) - .values({ id: userId, projectId }) - .onConflictDoNothing({ target: usersTable.id }); + const [existing] = await db + .select({ id: usersTable.id }) + .from(usersTable) + .where( + and(eq(usersTable.projectId, projectId), eq(usersTable.id, userId)) + ) + .limit(1); + + if (existing) return; + + await db.insert(usersTable).values({ id: userId, projectId }); } catch (e) { if ( e instanceof Error && diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index 1f93982..f08dc0d 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -25,22 +25,31 @@ export const projectsTable = pgTable("projects", { .notNull(), }); -export const usersTable = pgTable("users", { - id: USER_ID_CONFIG.dbType("id").primaryKey(), - projectId: uuid("project_id") - .references(() => projectsTable.id) - .notNull(), - last_billed_timestamp: timestamp("last_billed_timestamp", { - withTimezone: true, - mode: "string", +export const usersTable = pgTable( + "users", + { + id: USER_ID_CONFIG.dbType("id").primaryKey(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), + last_billed_timestamp: timestamp("last_billed_timestamp", { + withTimezone: true, + mode: "string", + }) + .default(DateTime.utc(1).toString()) + .notNull(), + payment_provider_user_id: text("payment_provider_user_id"), + mode: text("mode", { enum: ["test", "production"] }) + .notNull() + .default("production"), + }, + (table) => ({ + uniqueUserPerProject: uniqueIndex("uq_users_project_id").on( + table.projectId, + table.id + ), }) - .default(DateTime.utc(1).toString()) - .notNull(), - payment_provider_user_id: text("payment_provider_user_id"), - mode: text("mode", { enum: ["test", "production"] }) - .notNull() - .default("production"), -}); +); export const usersRelation = relations(usersTable, ({ one, many }) => ({ project: one(projectsTable, { From 2b54ffbbbffbbc7994cff5a4784dd0c1b18b6c9c Mon Sep 17 00:00:00 2001 From: Jayadeep Bejoy Date: Wed, 17 Jun 2026 21:32:49 +0530 Subject: [PATCH 05/33] fix: composite PK for usersTable, token validation, ensureUserExists race, scoped clearClients --- src/routes/gRPC/payment/paymentProvider.ts | 5 + src/routes/http/api/onboarding.ts | 4 +- src/storage/db/postgres/helpers/users.ts | 26 +-- src/storage/db/postgres/schema.ts | 200 ++++++++++++--------- src/zod/event.ts | 8 +- 5 files changed, 130 insertions(+), 113 deletions(-) diff --git a/src/routes/gRPC/payment/paymentProvider.ts b/src/routes/gRPC/payment/paymentProvider.ts index bd3ac36..f151748 100644 --- a/src/routes/gRPC/payment/paymentProvider.ts +++ b/src/routes/gRPC/payment/paymentProvider.ts @@ -13,6 +13,11 @@ export function clearClients(): void { clients.clear(); } +export function removeClient(projectId: string): void { + clients.delete(clientKey(projectId, "test")); + clients.delete(clientKey(projectId, "production")); +} + export async function getDodoClient( projectId: string, mode?: "test" | "production" diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index 7597093..0dbdf2e 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -22,7 +22,7 @@ import { metadataTable, } from "../../../storage/db/postgres/schema"; import { getMetadata } from "../../../storage/db/postgres/helpers/metadata"; -import { clearClients } from "../../gRPC/payment/paymentProvider.ts"; +import { removeClient } from "../../gRPC/payment/paymentProvider.ts"; import { DateTime } from "luxon"; import { executeInTransaction } from "../../../storage/adapter/postgres/handlers/addEventUtils"; @@ -169,7 +169,7 @@ export async function handleOnboarding( throw txnError; } - clearClients(); + removeClient(projectId); builder.setSuccess(201); diff --git a/src/storage/db/postgres/helpers/users.ts b/src/storage/db/postgres/helpers/users.ts index 13fa33d..287789e 100644 --- a/src/storage/db/postgres/helpers/users.ts +++ b/src/storage/db/postgres/helpers/users.ts @@ -45,24 +45,14 @@ export async function ensureUserExists( const db = txn ?? getPostgresDB(); try { - const [existing] = await db - .select({ id: usersTable.id }) - .from(usersTable) - .where( - and(eq(usersTable.projectId, projectId), eq(usersTable.id, userId)) - ) - .limit(1); - - if (existing) return; - - await db.insert(usersTable).values({ id: userId, projectId }); + await db + .insert(usersTable) + .values({ id: userId, projectId }) + .onConflictDoNothing(); } catch (e) { - if ( - e instanceof Error && - (e.message.includes("duplicate") || e.message.includes("unique")) - ) { - return; - } - throw e; + throw StorageError.queryFailed( + "Failed to ensure user exists", + e instanceof Error ? e : new Error(String(e)) + ); } } diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index f08dc0d..e04a31d 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -9,6 +9,8 @@ import { boolean, jsonb, uniqueIndex, + primaryKey, + foreignKey, } from "drizzle-orm/pg-core"; import { USER_ID_CONFIG } from "../../../config/identifiers"; import { DateTime } from "luxon"; @@ -28,7 +30,7 @@ export const projectsTable = pgTable("projects", { export const usersTable = pgTable( "users", { - id: USER_ID_CONFIG.dbType("id").primaryKey(), + id: USER_ID_CONFIG.dbType("id").notNull(), projectId: uuid("project_id") .references(() => projectsTable.id) .notNull(), @@ -44,10 +46,7 @@ export const usersTable = pgTable( .default("production"), }, (table) => ({ - uniqueUserPerProject: uniqueIndex("uq_users_project_id").on( - table.projectId, - table.id - ), + pk: primaryKey({ columns: [table.projectId, table.id] }), }) ); @@ -73,9 +72,7 @@ export const sessionsTable = pgTable( processed: text("processed", { enum: ["pending", "failed", "succeeded"] }) .default("pending") .notNull(), - userId: USER_ID_CONFIG.dbType("user_id") - .references(() => usersTable.id) - .notNull(), + userId: USER_ID_CONFIG.dbType("user_id").notNull(), apiKeyId: uuid("api_key_id") .references(() => apiKeysTable.id) .notNull(), @@ -96,6 +93,10 @@ export const sessionsTable = pgTable( }, (table) => ({ uniqueSessionId: uniqueIndex("unique_session_id").on(table.sessionId), + userFk: foreignKey({ + columns: [table.projectId, table.userId], + foreignColumns: [usersTable.projectId, usersTable.id], + }), }) ); @@ -161,34 +162,41 @@ export const apiKeysRelation = relations(apiKeysTable, ({ one, many }) => ({ aiTokenUsageEvents: many(aiTokenUsageEventsTable), })); -export const basicUsageEventsTable = pgTable("basic_usage_events", { - id: uuid("id").primaryKey().defaultRandom(), - projectId: uuid("project_id") - .references(() => projectsTable.id) - .notNull(), - eventId: uuid("event_id").notNull(), - idempotencyKey: text("idempotency_key").notNull().unique(), - reportedTimestamp: timestamp("reported_timestamp", { - withTimezone: true, - mode: "string", - }).notNull(), - ingestedTimestamp: timestamp("ingested_timestamp", { - withTimezone: true, - mode: "string", +export const basicUsageEventsTable = pgTable( + "basic_usage_events", + { + id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), + eventId: uuid("event_id").notNull(), + idempotencyKey: text("idempotency_key").notNull().unique(), + reportedTimestamp: timestamp("reported_timestamp", { + withTimezone: true, + mode: "string", + }).notNull(), + ingestedTimestamp: timestamp("ingested_timestamp", { + withTimezone: true, + mode: "string", + }) + .defaultNow() + .notNull(), + userId: USER_ID_CONFIG.dbType("user_id").notNull(), + apiKeyId: uuid("api_key_id") + .references(() => apiKeysTable.id) + .notNull(), + mode: text("mode", { enum: ["test", "production"] }).notNull(), + type: text("type", { enum: ["RAW", "MIDDLEWARE_CALL"] }).notNull(), + debitAmount: bigint("debit_amount", { mode: "number" }).notNull(), + metadata: jsonb("metadata").$type>(), + }, + (table) => ({ + userFk: foreignKey({ + columns: [table.projectId, table.userId], + foreignColumns: [usersTable.projectId, usersTable.id], + }), }) - .defaultNow() - .notNull(), - userId: USER_ID_CONFIG.dbType("user_id") - .references(() => usersTable.id) - .notNull(), - apiKeyId: uuid("api_key_id") - .references(() => apiKeysTable.id) - .notNull(), - mode: text("mode", { enum: ["test", "production"] }).notNull(), - type: text("type", { enum: ["RAW", "MIDDLEWARE_CALL"] }).notNull(), - debitAmount: bigint("debit_amount", { mode: "number" }).notNull(), - metadata: jsonb("metadata").$type>(), -}); +); export const basicUsageEventsRelation = relations( basicUsageEventsTable, @@ -208,33 +216,40 @@ export const basicUsageEventsRelation = relations( }) ); -export const paymentEventsTable = pgTable("payment_events", { - id: uuid("id").primaryKey().defaultRandom(), - projectId: uuid("project_id") - .references(() => projectsTable.id) - .notNull(), - reportedTimestamp: timestamp("reported_timestamp", { - withTimezone: true, - mode: "string", - }).notNull(), - ingestedTimestamp: timestamp("ingested_timestamp", { - withTimezone: true, - mode: "string", +export const paymentEventsTable = pgTable( + "payment_events", + { + id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), + reportedTimestamp: timestamp("reported_timestamp", { + withTimezone: true, + mode: "string", + }).notNull(), + ingestedTimestamp: timestamp("ingested_timestamp", { + withTimezone: true, + mode: "string", + }) + .defaultNow() + .notNull(), + userId: USER_ID_CONFIG.dbType("user_id").notNull(), + apiKeyId: uuid("api_key_id") + .references(() => apiKeysTable.id) + .notNull(), + mode: text("mode", { enum: ["test", "production"] }).notNull(), + creditAmount: bigint("credit_amount", { mode: "number" }).notNull(), + proxyId: uuid("proxy_id") + .references(() => sessionsTable.proxy_link_id) + .notNull(), + }, + (table) => ({ + userFk: foreignKey({ + columns: [table.projectId, table.userId], + foreignColumns: [usersTable.projectId, usersTable.id], + }), }) - .defaultNow() - .notNull(), - userId: USER_ID_CONFIG.dbType("user_id") - .references(() => usersTable.id) - .notNull(), - apiKeyId: uuid("api_key_id") - .references(() => apiKeysTable.id) - .notNull(), - mode: text("mode", { enum: ["test", "production"] }).notNull(), - creditAmount: bigint("credit_amount", { mode: "number" }).notNull(), - proxyId: uuid("proxy_id") - .references(() => sessionsTable.proxy_link_id) - .notNull(), -}); +); export const paymentEventsRelation = relations( paymentEventsTable, @@ -258,35 +273,42 @@ export const paymentEventsRelation = relations( }) ); -export const aiTokenUsageEventsTable = pgTable("ai_token_usage_events", { - id: uuid("id").primaryKey().defaultRandom(), - projectId: uuid("project_id") - .references(() => projectsTable.id) - .notNull(), - eventId: uuid("event_id").notNull(), - idempotencyKey: text("idempotency_key").notNull().unique(), - reportedTimestamp: timestamp("reported_timestamp", { - withTimezone: true, - mode: "string", - }).notNull(), - ingestedTimestamp: timestamp("ingested_timestamp", { - withTimezone: true, - mode: "string", +export const aiTokenUsageEventsTable = pgTable( + "ai_token_usage_events", + { + id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), + eventId: uuid("event_id").notNull(), + idempotencyKey: text("idempotency_key").notNull().unique(), + reportedTimestamp: timestamp("reported_timestamp", { + withTimezone: true, + mode: "string", + }).notNull(), + ingestedTimestamp: timestamp("ingested_timestamp", { + withTimezone: true, + mode: "string", + }) + .defaultNow() + .notNull(), + userId: USER_ID_CONFIG.dbType("user_id").notNull(), + apiKeyId: uuid("api_key_id") + .references(() => apiKeysTable.id) + .notNull(), + mode: text("mode", { enum: ["test", "production"] }).notNull(), + model: text("model").notNull(), + provider: text("provider").notNull(), + metrics: jsonb("metrics").$type().notNull(), + metadata: jsonb("metadata").$type>(), + }, + (table) => ({ + userFk: foreignKey({ + columns: [table.projectId, table.userId], + foreignColumns: [usersTable.projectId, usersTable.id], + }), }) - .defaultNow() - .notNull(), - userId: USER_ID_CONFIG.dbType("user_id") - .references(() => usersTable.id) - .notNull(), - apiKeyId: uuid("api_key_id") - .references(() => apiKeysTable.id) - .notNull(), - mode: text("mode", { enum: ["test", "production"] }).notNull(), - model: text("model").notNull(), - provider: text("provider").notNull(), - metrics: jsonb("metrics").$type().notNull(), - metadata: jsonb("metadata").$type>(), -}); +); export const aiTokenUsageEventsRelation = relations( aiTokenUsageEventsTable, diff --git a/src/zod/event.ts b/src/zod/event.ts index 6040fd7..e980a26 100644 --- a/src/zod/event.ts +++ b/src/zod/event.ts @@ -75,10 +75,10 @@ function createAITokenUsageDataSchema( .object({ model: z.string(), provider: z.string(), - inputTokens: z.number(), - inputCacheTokens: z.number(), - outputTokens: z.number(), - outputCacheTokens: z.number(), + inputTokens: z.number().int().nonnegative(), + inputCacheTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + outputCacheTokens: z.number().int().nonnegative(), inputTag: z.string().optional(), inputExpr: z.string().optional(), inputAmount: z.number().optional(), From 287a146b279f882eec0061877915b69c861f9c26 Mon Sep 17 00:00:00 2001 From: Jayadeep Bejoy Date: Wed, 17 Jun 2026 21:50:37 +0530 Subject: [PATCH 06/33] fix: add model.min(1), provider default, token count .min(0) in createAITokenUsageDataSchema --- src/zod/event.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/zod/event.ts b/src/zod/event.ts index e980a26..bb58230 100644 --- a/src/zod/event.ts +++ b/src/zod/event.ts @@ -73,12 +73,12 @@ function createAITokenUsageDataSchema( ): z.ZodType { return z .object({ - model: z.string(), - provider: z.string(), - inputTokens: z.number().int().nonnegative(), - inputCacheTokens: z.number().int().nonnegative(), - outputTokens: z.number().int().nonnegative(), - outputCacheTokens: z.number().int().nonnegative(), + model: z.string().min(1), + provider: z.string().optional().default("unknown"), + inputTokens: z.number().int().min(0), + inputCacheTokens: z.number().int().min(0), + outputTokens: z.number().int().min(0), + outputCacheTokens: z.number().int().min(0), inputTag: z.string().optional(), inputExpr: z.string().optional(), inputAmount: z.number().optional(), From 382189968af302f47dc544573a1aaff01986fde1 Mon Sep 17 00:00:00 2001 From: Jayadeep Bejoy Date: Wed, 17 Jun 2026 23:53:16 +0530 Subject: [PATCH 07/33] fix: scope row lock, deleteWebhookEndpoint, and remove metadata from query registry --- src/routes/gRPC/data/query.ts | 11 +---------- src/routes/gRPC/payment/createCheckoutLink.ts | 9 +++++++-- src/routes/http/api/webhookEndpoints.ts | 2 +- src/storage/db/postgres/helpers/webhookEndpoints.ts | 2 ++ 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/routes/gRPC/data/query.ts b/src/routes/gRPC/data/query.ts index 3732cf3..c53d8d0 100644 --- a/src/routes/gRPC/data/query.ts +++ b/src/routes/gRPC/data/query.ts @@ -10,7 +10,6 @@ import { sessionsTable, tagsTable, expressionsTable, - metadataTable, } from "../../../storage/db/postgres/schema"; import { eq, @@ -44,8 +43,7 @@ interface TableDef { | typeof usersTable | typeof sessionsTable | typeof tagsTable - | typeof expressionsTable - | typeof metadataTable; + | typeof expressionsTable; fields: Record; } @@ -97,13 +95,6 @@ const TABLE_REGISTRY: Record = { expr: { col: expressionsTable.expr, cast: "text" }, }, }, - metadata: { - tableName: "metadata", - table: metadataTable, - fields: { - id: { col: metadataTable.id, cast: "uuid" }, - }, - }, }; function castValue( diff --git a/src/routes/gRPC/payment/createCheckoutLink.ts b/src/routes/gRPC/payment/createCheckoutLink.ts index 23e58e6..6973f22 100644 --- a/src/routes/gRPC/payment/createCheckoutLink.ts +++ b/src/routes/gRPC/payment/createCheckoutLink.ts @@ -32,7 +32,7 @@ import { getPostgresDB } from "../../../storage/db/postgres/db"; import { checkIfExistingCheckoutLink } from "../../../storage/db/postgres/helpers/sessions"; import { ensureUserExists } from "../../../storage/db/postgres/helpers/users"; import { usersTable } from "../../../storage/db/postgres/schema"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; export async function createCheckoutLink( call: ContextUnaryCall, @@ -98,7 +98,12 @@ export async function createCheckoutLink( await txn .select({ id: usersTable.id }) .from(usersTable) - .where(eq(usersTable.id, validatedData.userId)) + .where( + and( + eq(usersTable.projectId, auth.projectId), + eq(usersTable.id, validatedData.userId) + ) + ) .for("update"); const existingId = await checkIfExistingCheckoutLink( diff --git a/src/routes/http/api/webhookEndpoints.ts b/src/routes/http/api/webhookEndpoints.ts index 961cebb..a3ef8f3 100644 --- a/src/routes/http/api/webhookEndpoints.ts +++ b/src/routes/http/api/webhookEndpoints.ts @@ -240,7 +240,7 @@ export async function handleDeleteWebhookEndpoint( const auth = await authenticateHttpApiKey(request.headers.authorization); builder.setApiKeyContext({ name: `webhook:${auth.apiKeyId}` }); - const deleted = await deleteWebhookEndpoint(auth.apiKeyId); + const deleted = await deleteWebhookEndpoint(auth.projectId, auth.apiKeyId); if (!deleted) { builder.setError(404, { diff --git a/src/storage/db/postgres/helpers/webhookEndpoints.ts b/src/storage/db/postgres/helpers/webhookEndpoints.ts index c4ebfd3..a9c5a72 100644 --- a/src/storage/db/postgres/helpers/webhookEndpoints.ts +++ b/src/storage/db/postgres/helpers/webhookEndpoints.ts @@ -89,6 +89,7 @@ export async function upsertWebhookEndpoint( } export async function deleteWebhookEndpoint( + projectId: string, apiKeyId: string ): Promise { const db = getPostgresDB(); @@ -101,6 +102,7 @@ export async function deleteWebhookEndpoint( .set({ deletedAt: now }) .where( and( + eq(webhookEndpointsTable.projectId, projectId), eq(webhookEndpointsTable.apiKeyId, apiKeyId), isNull(webhookEndpointsTable.deletedAt) ) From c0755c705742aa0809c3ef4895584ef4e7bde2c8 Mon Sep 17 00:00:00 2001 From: Jayadeep Bejoy Date: Thu, 18 Jun 2026 21:48:44 +0530 Subject: [PATCH 08/33] =?UTF-8?q?fix:=20resolve=20all=20greptile=20finding?= =?UTF-8?q?s=20=E2=80=94=20billing,=20auth,=20tests,=20schema,=20validatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- src/__tests__/db/index.ts | 3 +- src/__tests__/fixtures/apiKey.ts | 18 ++++ src/routes/gRPC/data/query.ts | 8 +- src/routes/http/api/apiKeys.ts | 90 ++++++++++++------- src/routes/http/api/onboarding.ts | 19 ++++ src/routes/http/api/webhookDeliveries.ts | 21 ++--- src/routes/http/createdCheckout.ts | 16 +++- src/routes/http/registerWebhookRoutes.ts | 13 +++ .../adapter/postgres/handlers/priceRequest.ts | 18 ++-- src/storage/db/postgres/helpers/metadata.ts | 39 -------- src/storage/db/postgres/helpers/users.ts | 5 +- .../db/postgres/helpers/webhookEndpoints.ts | 6 +- src/storage/db/postgres/schema.ts | 19 ++-- src/utils/parseExpr.ts | 8 +- 15 files changed, 175 insertions(+), 110 deletions(-) diff --git a/.env.example b/.env.example index 1f9ebac..39b7c27 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/scrawn CLICKHOUSE_URL=http://default:clickhouse@localhost:8123/scrawn HMAC_SECRET= -MASTER_API_KEY_HASH= # HMAC-SHA256 hex hash of the master API key (used for project creation during onboarding) +MASTER_API_KEY_HASH= # hex(HMAC-SHA256(HMAC_SECRET, )) — computed with HMAC_SECRET as the signing key (used for project creation during onboarding) APP_URL=http://localhost:8060 # URL the Scrawn backend is hosted on # SENTRY_DSN= diff --git a/src/__tests__/db/index.ts b/src/__tests__/db/index.ts index ada334a..c8178e8 100644 --- a/src/__tests__/db/index.ts +++ b/src/__tests__/db/index.ts @@ -31,7 +31,8 @@ export async function clearDatabase() { users, tags, metadata, - expressions + expressions, + projects RESTART IDENTITY CASCADE `); diff --git a/src/__tests__/fixtures/apiKey.ts b/src/__tests__/fixtures/apiKey.ts index 1785354..5b41df9 100644 --- a/src/__tests__/fixtures/apiKey.ts +++ b/src/__tests__/fixtures/apiKey.ts @@ -1,18 +1,35 @@ import { getPostgresDB } from "../../storage/db/postgres/db"; import { + projectsTable, apiKeysTable, webhookEndpointsTable, } from "../../storage/db/postgres/schema"; +import { eq } from "drizzle-orm"; import { hashAPIKey } from "../../utils/hashAPIKey"; import { DateTime } from "luxon"; export const TEST_PROJECT_ID = "00000000-0000-0000-0000-000000000001"; +async function ensureTestProject(): Promise { + const db = getPostgresDB(); + const [existing] = await db + .select({ id: projectsTable.id }) + .from(projectsTable) + .where(eq(projectsTable.id, TEST_PROJECT_ID)) + .limit(1); + if (existing) return; + await db.insert(projectsTable).values({ + id: TEST_PROJECT_ID, + name: "test-project", + }); +} + export async function createTestApiKey(): Promise<{ rawKey: string; id: string; }> { const db = getPostgresDB(); + await ensureTestProject(); const rawKey = `scrn_test_${crypto.randomUUID().replace(/-/g, "").slice(0, 32)}`; const [key] = await db .insert(apiKeysTable) @@ -42,6 +59,7 @@ export async function insertKey( overrides: Partial<{ revoked: boolean; expiresAt: string }> = {} ): Promise { const db = getPostgresDB(); + await ensureTestProject(); const [key] = await db .insert(apiKeysTable) .values({ diff --git a/src/routes/gRPC/data/query.ts b/src/routes/gRPC/data/query.ts index c53d8d0..86415ad 100644 --- a/src/routes/gRPC/data/query.ts +++ b/src/routes/gRPC/data/query.ts @@ -223,7 +223,13 @@ export async function queryData( const db = getPostgresDB(); const userWhere = buildWhere(validated.where, tableDef); const projectFilter = eq( - (tableDef.table as any).projectId, + ( + tableDef.table as + | typeof usersTable + | typeof sessionsTable + | typeof tagsTable + | typeof expressionsTable + ).projectId, auth.projectId ) as SQL; const whereClause = userWhere diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 59f110e..8939910 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -16,6 +16,7 @@ import { createApiKey } from "../../../storage/db/postgres/helpers/apiKeys"; import { upsertWebhookEndpoint } from "../../../storage/db/postgres/helpers/webhookEndpoints"; import { generateWebhookKeyPair } from "../../../utils/generateWebhookKeyPair"; import { getPostgresDB } from "../../../storage/db/postgres/db"; +import { executeInTransaction } from "../../../storage/adapter/postgres/handlers/addEventUtils"; import { apiKeysTable, webhookEndpointsTable, @@ -48,31 +49,61 @@ export async function handleCreateApiKey( try { const auth = await authenticateHttpApiKey(request.headers.authorization); + if (auth.role !== "dashboard") { + throw AuthError.permissionDenied( + "Only dashboard keys can manage API keys" + ); + } builder.setApiKeyContext({ name: `create-key:${auth.apiKeyId}` }); const body = await request.body; const validated = createApiKeySchema.parse(body); + if ( + validated.role === "production" && + !validated.webhookUrl.startsWith("https://") + ) { + builder.setError(400, { + type: "ValidationError", + message: "Production webhook URLs must use HTTPS", + }); + reply.code(400); + return { error: "Production webhook URLs must use HTTPS" }; + } + const apiKey = generateAPIKey(validated.role as ApiKeyRole); const apiKeyHash = hashAPIKey(apiKey); const now = DateTime.utc(); const expiresAt = now.plus({ seconds: validated.expiresIn }); - const keyRecord = await createApiKey({ - name: validated.name, - key: apiKeyHash, - role: validated.role, - expiresAt: expiresAt.toISO(), - projectId: auth.projectId, - }); + const db = getPostgresDB(); + const { keyRecord, endpoint } = await executeInTransaction( + db, + "create API key", + async (txn) => { + const rec = await createApiKey( + { + name: validated.name, + key: apiKeyHash, + role: validated.role, + expiresAt: expiresAt.toISO(), + projectId: auth.projectId, + }, + txn + ); + + const keyPair = generateWebhookKeyPair(); + const ep = await upsertWebhookEndpoint( + auth.projectId, + rec.id, + validated.webhookUrl, + keyPair.privateKeyPem, + keyPair.publicKeyPrefixed, + txn + ); - const keyPair = generateWebhookKeyPair(); - const endpoint = await upsertWebhookEndpoint( - auth.projectId, - keyRecord.id, - validated.webhookUrl, - keyPair.privateKeyPem, - keyPair.publicKeyPrefixed + return { keyRecord: rec, endpoint: ep }; + } ); invalidateWebhookEndpointCache(keyRecord.id); @@ -131,6 +162,11 @@ export async function handleListApiKeys( try { const auth = await authenticateHttpApiKey(request.headers.authorization); + if (auth.role !== "dashboard") { + throw AuthError.permissionDenied( + "Only dashboard keys can manage API keys" + ); + } const db = getPostgresDB(); const keys = await db @@ -197,12 +233,17 @@ export async function handleRevokeApiKey( try { const auth = await authenticateHttpApiKey(request.headers.authorization); + if (auth.role !== "dashboard") { + throw AuthError.permissionDenied( + "Only dashboard keys can manage API keys" + ); + } const params = request.params as { id: string }; const db = getPostgresDB(); const now = DateTime.utc().toISO(); - const result = await db + const [revokedRow] = await db .update(apiKeysTable) .set({ revoked: true, revokedAt: now }) .where( @@ -211,9 +252,10 @@ export async function handleRevokeApiKey( eq(apiKeysTable.id, params.id), eq(apiKeysTable.revoked, false) ) - ); + ) + .returning({ key: apiKeysTable.key }); - if ((result.count ?? 0) === 0) { + if (!revokedRow) { builder.setError(404, { type: "NotFoundError", message: "API key not found or already revoked", @@ -222,19 +264,7 @@ export async function handleRevokeApiKey( return { error: "API key not found or already revoked" }; } - const [keyRow] = await db - .select({ key: apiKeysTable.key }) - .from(apiKeysTable) - .where( - and( - eq(apiKeysTable.projectId, auth.projectId), - eq(apiKeysTable.id, params.id) - ) - ) - .limit(1); - if (keyRow) { - apiKeyCache.delete(keyRow.key); - } + apiKeyCache.delete(revokedRow.key); builder.setSuccess(200); reply.code(200); diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index 0dbdf2e..429322a 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -24,6 +24,7 @@ import { import { getMetadata } from "../../../storage/db/postgres/helpers/metadata"; import { removeClient } from "../../gRPC/payment/paymentProvider.ts"; import { DateTime } from "luxon"; +import { eq } from "drizzle-orm"; import { executeInTransaction } from "../../../storage/adapter/postgres/handlers/addEventUtils"; export async function handleOnboarding( @@ -55,6 +56,21 @@ export async function handleOnboarding( const projectId = randomUUID(); + const existing = await getPostgresDB() + .select({ id: projectsTable.id }) + .from(projectsTable) + .where(eq(projectsTable.name, validated.name)) + .limit(1); + + if (existing.length > 0) { + builder.setError(409, { + type: "ConflictError", + message: `Project with name '${validated.name}' already exists`, + }); + reply.code(409); + return {}; + } + const liveClient = new DodoPayments({ bearerToken: validated.dodoLiveApiKey, environment: "live_mode", @@ -232,6 +248,9 @@ export async function handleGetConfig( try { const authHeader = request.headers.authorization; const auth = await authenticateHttpApiKey(authHeader); + if (auth.role !== "dashboard") { + throw AuthError.permissionDenied("Only dashboard keys can read config"); + } const metadata = await getMetadata(auth.projectId); diff --git a/src/routes/http/api/webhookDeliveries.ts b/src/routes/http/api/webhookDeliveries.ts index 8830b31..0bca7fb 100644 --- a/src/routes/http/api/webhookDeliveries.ts +++ b/src/routes/http/api/webhookDeliveries.ts @@ -71,24 +71,21 @@ export async function handleListDeliveries( } if (query.eventType) { - conditions = conditions - ? and(conditions, eq(webhookDeliveriesTable.eventType, query.eventType)) - : eq(webhookDeliveriesTable.eventType, query.eventType); + conditions = and( + conditions, + eq(webhookDeliveriesTable.eventType, query.eventType) + ); } if (query.status) { - conditions = conditions - ? and( - conditions, - sql`${webhookDeliveriesTable.status} = ${query.status}` - ) - : sql`${webhookDeliveriesTable.status} = ${query.status}`; + conditions = and( + conditions, + sql`${webhookDeliveriesTable.status} = ${query.status}` + ); } if (query.role) { - conditions = conditions - ? and(conditions, sql`${apiKeysTable.role} = ${query.role}`) - : sql`${apiKeysTable.role} = ${query.role}`; + conditions = and(conditions, sql`${apiKeysTable.role} = ${query.role}`); } const rows = await db diff --git a/src/routes/http/createdCheckout.ts b/src/routes/http/createdCheckout.ts index d4855a3..84700d3 100644 --- a/src/routes/http/createdCheckout.ts +++ b/src/routes/http/createdCheckout.ts @@ -135,6 +135,15 @@ export async function handleDodoWebhook( ); } + if (session.projectId !== projectId) { + return errorResponse( + 404, + "NotFoundError", + `Session not found for checkout_session_id: ${checkout_session_id}`, + builder + ); + } + if (session.processed !== "pending") { Sentry.captureMessage( `Webhook received for session ${checkout_session_id} with non-pending status: ${session.processed}`, @@ -192,7 +201,12 @@ export async function handleDodoWebhook( txn ); if (!claimed) return; - await updateUserBilledTimestamp(userId, billed_upto, txn); + await updateUserBilledTimestamp( + session.projectId, + userId, + billed_upto, + txn + ); await handleAddPayment( session.projectId, userId, diff --git a/src/routes/http/registerWebhookRoutes.ts b/src/routes/http/registerWebhookRoutes.ts index 6f6e617..84502e8 100644 --- a/src/routes/http/registerWebhookRoutes.ts +++ b/src/routes/http/registerWebhookRoutes.ts @@ -59,6 +59,19 @@ export async function registerWebhookRoutes( return { error: "Missing projectId query parameter" }; } + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + projectId + ) + ) { + builder.setError(400, { + type: "ValidationError", + message: "Invalid 'projectId' query parameter.", + }); + reply.code(400); + return { error: "Invalid projectId query parameter" }; + } + const signatureHeader = request.headers["webhook-signature"]; const timestampHeader = request.headers["webhook-timestamp"]; const webhookIdHeader = request.headers["webhook-id"]; diff --git a/src/storage/adapter/postgres/handlers/priceRequest.ts b/src/storage/adapter/postgres/handlers/priceRequest.ts index ac13916..5a2bfe2 100644 --- a/src/storage/adapter/postgres/handlers/priceRequest.ts +++ b/src/storage/adapter/postgres/handlers/priceRequest.ts @@ -50,7 +50,13 @@ export async function handlePriceRequest( price: sum(priceColumn), }) .from(priceTable) - .innerJoin(usersTable, eq(priceTable.userId, usersTable.id)) + .innerJoin( + usersTable, + and( + eq(priceTable.userId, usersTable.id), + eq(priceTable.projectId, usersTable.projectId) + ) + ) .where(whereClause) .groupBy(priceTable.userId); } catch (e) { @@ -82,15 +88,7 @@ export async function handlePriceRequest( return 0; } - let parsedPrice: number; - try { - parsedPrice = parseInt(priceValue); - } catch (e) { - throw StorageError.priceCalculationFailed( - userId, - new Error(`Failed to parse price value: ${priceValue}`) - ); - } + const parsedPrice = parseInt(priceValue, 10); if (isNaN(parsedPrice)) { throw StorageError.priceCalculationFailed( diff --git a/src/storage/db/postgres/helpers/metadata.ts b/src/storage/db/postgres/helpers/metadata.ts index 3ba469a..f696125 100644 --- a/src/storage/db/postgres/helpers/metadata.ts +++ b/src/storage/db/postgres/helpers/metadata.ts @@ -2,45 +2,6 @@ import { getPostgresDB } from "../db"; import { metadataTable } from "../schema"; import { StorageError } from "../../../../errors/storage"; import { eq } from "drizzle-orm"; -import { executeInTransaction } from "../../../adapter/postgres/handlers/addEventUtils"; - -export type UpsertMetadataInput = { - dodo_live_api_key: string; - dodo_test_api_key: string; - dodo_live_product_id: string; - dodo_test_product_id: string; - dodo_live_webhook_secret: string; - dodo_test_webhook_secret: string; - currency: string; - redirect_url: string; -}; - -export async function createMetadata( - projectId: string, - input: UpsertMetadataInput, - txn?: any -): Promise { - const db = txn ?? getPostgresDB(); - - try { - await db.insert(metadataTable).values({ - projectId, - dodo_live_api_key: input.dodo_live_api_key, - dodo_test_api_key: input.dodo_test_api_key, - dodo_live_product_id: input.dodo_live_product_id, - dodo_test_product_id: input.dodo_test_product_id, - dodo_live_webhook_secret: input.dodo_live_webhook_secret, - dodo_test_webhook_secret: input.dodo_test_webhook_secret, - currency: input.currency, - redirect_url: input.redirect_url, - }); - } catch (e) { - throw StorageError.insertFailed( - "Failed to create metadata record", - e instanceof Error ? e : new Error(String(e)) - ); - } -} export async function getMetadata( projectId: string diff --git a/src/storage/db/postgres/helpers/users.ts b/src/storage/db/postgres/helpers/users.ts index 287789e..624a5a5 100644 --- a/src/storage/db/postgres/helpers/users.ts +++ b/src/storage/db/postgres/helpers/users.ts @@ -5,6 +5,7 @@ import { StorageError } from "../../../../errors/storage"; import type { PgTransaction } from "drizzle-orm/pg-core"; export async function updateUserBilledTimestamp( + projectId: string, userId: string, billedUpto: string, txn?: PgTransaction @@ -15,7 +16,9 @@ export async function updateUserBilledTimestamp( await db .update(usersTable) .set({ last_billed_timestamp: billedUpto }) - .where(eq(usersTable.id, userId)); + .where( + and(eq(usersTable.projectId, projectId), eq(usersTable.id, userId)) + ); } catch (e) { throw StorageError.queryFailed( "Failed to update user billed timestamp", diff --git a/src/storage/db/postgres/helpers/webhookEndpoints.ts b/src/storage/db/postgres/helpers/webhookEndpoints.ts index a9c5a72..341ae20 100644 --- a/src/storage/db/postgres/helpers/webhookEndpoints.ts +++ b/src/storage/db/postgres/helpers/webhookEndpoints.ts @@ -1,6 +1,7 @@ import { getPostgresDB } from "../db"; import { webhookEndpointsTable } from "../schema"; import { eq, and, isNull } from "drizzle-orm"; +import type { PgTransaction } from "drizzle-orm/pg-core"; import { StorageError } from "../../../../errors/storage"; import { DateTime } from "luxon"; @@ -39,9 +40,10 @@ export async function upsertWebhookEndpoint( apiKeyId: string, url: string, privateKey: string, - publicKey: string + publicKey: string, + txn?: PgTransaction ): Promise { - const db = getPostgresDB(); + const db = txn ?? getPostgresDB(); try { const now = DateTime.utc().toISO(); diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index e04a31d..3eea693 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -106,8 +106,8 @@ export const sessionRelations = relations(sessionsTable, ({ one, many }) => ({ references: [projectsTable.id], }), user: one(usersTable, { - fields: [sessionsTable.userId], - references: [usersTable.id], + fields: [sessionsTable.projectId, sessionsTable.userId], + references: [usersTable.projectId, usersTable.id], }), apiKey: one(apiKeysTable, { fields: [sessionsTable.apiKeyId], @@ -206,8 +206,8 @@ export const basicUsageEventsRelation = relations( references: [projectsTable.id], }), user: one(usersTable, { - fields: [basicUsageEventsTable.userId], - references: [usersTable.id], + fields: [basicUsageEventsTable.projectId, basicUsageEventsTable.userId], + references: [usersTable.projectId, usersTable.id], }), apiKey: one(apiKeysTable, { fields: [basicUsageEventsTable.apiKeyId], @@ -259,8 +259,8 @@ export const paymentEventsRelation = relations( references: [projectsTable.id], }), user: one(usersTable, { - fields: [paymentEventsTable.userId], - references: [usersTable.id], + fields: [paymentEventsTable.projectId, paymentEventsTable.userId], + references: [usersTable.projectId, usersTable.id], }), apiKey: one(apiKeysTable, { fields: [paymentEventsTable.apiKeyId], @@ -318,8 +318,11 @@ export const aiTokenUsageEventsRelation = relations( references: [projectsTable.id], }), user: one(usersTable, { - fields: [aiTokenUsageEventsTable.userId], - references: [usersTable.id], + fields: [ + aiTokenUsageEventsTable.projectId, + aiTokenUsageEventsTable.userId, + ], + references: [usersTable.projectId, usersTable.id], }), apiKey: one(apiKeysTable, { fields: [aiTokenUsageEventsTable.apiKeyId], diff --git a/src/utils/parseExpr.ts b/src/utils/parseExpr.ts index b5ceb06..8a5559a 100644 --- a/src/utils/parseExpr.ts +++ b/src/utils/parseExpr.ts @@ -37,10 +37,10 @@ const ALLOWED_FUNCTIONS = new Set([ "div", "tag", "expr", - "inputTokens", - "outputTokens", - "inputCacheTokens", - "outputCacheTokens", + "inputtokens", + "outputtokens", + "inputcachetokens", + "outputcachetokens", ]); /** From 7b6179f22d365ec2fb26a300cd35376e61dfa14b Mon Sep 17 00:00:00 2001 From: Jayadeep Bejoy Date: Fri, 19 Jun 2026 16:06:43 +0530 Subject: [PATCH 09/33] fix: prevent dashboard self-revoke, remove unsafe cast in query.ts --- src/routes/gRPC/data/query.ts | 23 ++++++++--------------- src/routes/http/api/apiKeys.ts | 3 ++- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/routes/gRPC/data/query.ts b/src/routes/gRPC/data/query.ts index 86415ad..f52c852 100644 --- a/src/routes/gRPC/data/query.ts +++ b/src/routes/gRPC/data/query.ts @@ -37,13 +37,15 @@ interface FieldDef { cast: "text" | "integer" | "uuid" | "timestamptz" | "boolean"; } +type ScopedTable = + | typeof usersTable + | typeof sessionsTable + | typeof tagsTable + | typeof expressionsTable; + interface TableDef { tableName: string; - table: - | typeof usersTable - | typeof sessionsTable - | typeof tagsTable - | typeof expressionsTable; + table: ScopedTable; fields: Record; } @@ -222,16 +224,7 @@ export async function queryData( const db = getPostgresDB(); const userWhere = buildWhere(validated.where, tableDef); - const projectFilter = eq( - ( - tableDef.table as - | typeof usersTable - | typeof sessionsTable - | typeof tagsTable - | typeof expressionsTable - ).projectId, - auth.projectId - ) as SQL; + const projectFilter = eq(tableDef.table.projectId, auth.projectId) as SQL; const whereClause = userWhere ? and(projectFilter, userWhere) : projectFilter; diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 8939910..2dc084d 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -250,7 +250,8 @@ export async function handleRevokeApiKey( and( eq(apiKeysTable.projectId, auth.projectId), eq(apiKeysTable.id, params.id), - eq(apiKeysTable.revoked, false) + eq(apiKeysTable.revoked, false), + ne(apiKeysTable.role, "dashboard") ) ) .returning({ key: apiKeysTable.key }); From 91b36962cbef8bc29bda1ecb0aa6617ad3b8de0c Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 22 Jun 2026 03:50:46 +0530 Subject: [PATCH 10/33] fix(schema.ts): added project_id to clickhouse db schema --- src/storage/adapter/clickhouse/schema.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/storage/adapter/clickhouse/schema.ts b/src/storage/adapter/clickhouse/schema.ts index ae12dcd..6eb31fd 100644 --- a/src/storage/adapter/clickhouse/schema.ts +++ b/src/storage/adapter/clickhouse/schema.ts @@ -5,6 +5,7 @@ import { innerProduct } from "drizzle-orm"; const BASIC_USAGE_EVENTS_TABLE = ` CREATE TABLE IF NOT EXISTS basic_usage_events ( id UUID DEFAULT generateUUIDv4(), + project_id String, event_id String, idempotency_key String, user_id String, @@ -22,6 +23,7 @@ ORDER BY (idempotency_key, user_id) const AI_TOKEN_USAGE_EVENTS_TABLE = ` CREATE TABLE IF NOT EXISTS ai_token_usage_events ( id UUID DEFAULT generateUUIDv4(), + project_id String, event_id String, idempotency_key String, user_id String, From b6410a758a316f71f2922093c53c3710cced1717 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Wed, 24 Jun 2026 02:52:44 +0530 Subject: [PATCH 11/33] fix(generateInitialAPIKey): now API key gets inserted into the db --- src/utils/generateInitialAPIKey.ts | 52 ++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/utils/generateInitialAPIKey.ts b/src/utils/generateInitialAPIKey.ts index ecff084..7adc257 100644 --- a/src/utils/generateInitialAPIKey.ts +++ b/src/utils/generateInitialAPIKey.ts @@ -1,6 +1,8 @@ import { createHmac, randomUUID } from "crypto"; import { generateAPIKey } from "./generateAPIKey"; import { DateTime } from "luxon"; +import { getPostgresDB } from "../storage/db/postgres/db"; +import { projectsTable, apiKeysTable } from "../storage/db/postgres/schema"; const HMAC_SECRET = process.env.HMAC_SECRET; @@ -17,18 +19,19 @@ function hashAPIKey(apiKey: string): string { } export type InitialApiKeyData = { + projectId: string; apiKeyId: string; apiKey: string; apiKeyHash: string; name: string; - role: string; + role: "dashboard" | "production" | "test"; createdAt: string; expiresAt: string; - insertSql: string; authorizationHeader: string; }; export function generateInitialApiKeyData(): InitialApiKeyData { + const projectId = randomUUID(); const apiKeyId = randomUUID(); const apiKey = generateAPIKey("dashboard"); const apiKeyHash = hashAPIKey(apiKey); @@ -37,20 +40,8 @@ export function generateInitialApiKeyData(): InitialApiKeyData { const createdAt = DateTime.utc().toISO(); const expiresAt = DateTime.utc().plus({ days: 365 }).toISO(); - const insertSql = - "INSERT INTO api_keys (id, name, key, role, created_at, expires_at, revoked, revoked_at)\n" + - "VALUES (\n" + - ` '${apiKeyId}',\n` + - ` '${name}',\n` + - ` '${apiKeyHash}',\n` + - ` '${role}',\n` + - ` '${createdAt}',\n` + - ` '${expiresAt}',\n` + - " false,\n" + - " NULL\n" + - ");"; - return { + projectId, apiKeyId, apiKey, apiKeyHash, @@ -58,9 +49,36 @@ export function generateInitialApiKeyData(): InitialApiKeyData { role, createdAt, expiresAt, - insertSql, authorizationHeader: `Authorization: Bearer ${apiKey}`, }; } -console.log(generateInitialApiKeyData()); +async function insertInitialData(data: InitialApiKeyData) { + const db = getPostgresDB(process.env.DATABASE_URL); + + await db.insert(projectsTable).values({ + id: data.projectId, + name: "Default Project", + createdAt: data.createdAt, + }); + + await db.insert(apiKeysTable).values({ + id: data.apiKeyId, + projectId: data.projectId, + name: data.name, + key: data.apiKeyHash, + role: data.role, + createdAt: data.createdAt, + expiresAt: data.expiresAt, + revoked: false, + revokedAt: null, + }); +} + +const data = generateInitialApiKeyData(); + +await insertInitialData(data); + +console.log("Initial API key generation was successful.."); +console.log(data); +process.exit(0); From d998a50862faef90511dd75d86f2690b710cca0b Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Wed, 24 Jun 2026 06:14:33 +0530 Subject: [PATCH 12/33] fix(generateInitialAPIKey): idempotency gaurd added --- src/utils/generateInitialAPIKey.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/utils/generateInitialAPIKey.ts b/src/utils/generateInitialAPIKey.ts index 7adc257..7815baf 100644 --- a/src/utils/generateInitialAPIKey.ts +++ b/src/utils/generateInitialAPIKey.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { createHmac, randomUUID } from "crypto"; import { generateAPIKey } from "./generateAPIKey"; import { DateTime } from "luxon"; @@ -56,6 +57,19 @@ export function generateInitialApiKeyData(): InitialApiKeyData { async function insertInitialData(data: InitialApiKeyData) { const db = getPostgresDB(process.env.DATABASE_URL); + const existing = await db + .select({ id: projectsTable.id }) + .from(projectsTable) + .where(sql`name = 'Default Project'`) + .limit(1); + + if (existing.length > 0) { + console.log( + `Default Project already exists (id=${existing[0]!.id}). Skipping seed.` + ); + return; + } + await db.insert(projectsTable).values({ id: data.projectId, name: "Default Project", From 97cb89efbd7d1f7a1b8ae63925a30cc50e6ebddd Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Thu, 25 Jun 2026 02:13:39 +0530 Subject: [PATCH 13/33] fix(project): schema fixes --- src/routes/http/api/onboarding.ts | 28 +++++++++++++++++---- src/storage/adapter/clickhouse/schema.ts | 4 +-- src/storage/adapter/clickhouse/utils.ts | 13 +++++++--- src/storage/db/postgres/helpers/metadata.ts | 8 ++++++ src/storage/db/postgres/schema.ts | 21 ++++++++++++++++ 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index 429322a..b347638 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -21,7 +21,10 @@ import { apiKeysTable, metadataTable, } from "../../../storage/db/postgres/schema"; -import { getMetadata } from "../../../storage/db/postgres/helpers/metadata"; +import { + getMetadata, + getAnyMetadata, +} from "../../../storage/db/postgres/helpers/metadata"; import { removeClient } from "../../gRPC/payment/paymentProvider.ts"; import { DateTime } from "luxon"; import { eq } from "drizzle-orm"; @@ -247,12 +250,27 @@ export async function handleGetConfig( try { const authHeader = request.headers.authorization; - const auth = await authenticateHttpApiKey(authHeader); - if (auth.role !== "dashboard") { - throw AuthError.permissionDenied("Only dashboard keys can read config"); + + let projectId: string | undefined; + let isMasterKey = false; + try { + authenticateMasterApiKey(authHeader); + isMasterKey = true; + } catch (masterErr) { + if (!(masterErr instanceof AuthError)) { + throw masterErr; + } + + const auth = await authenticateHttpApiKey(authHeader); + if (auth.role !== "dashboard") { + throw AuthError.permissionDenied("Only dashboard keys can read config"); + } + projectId = auth.projectId; } - const metadata = await getMetadata(auth.projectId); + const metadata = isMasterKey + ? await getAnyMetadata() + : await getMetadata(projectId!); if (!metadata) { builder.setSuccess(200); diff --git a/src/storage/adapter/clickhouse/schema.ts b/src/storage/adapter/clickhouse/schema.ts index 6eb31fd..653c662 100644 --- a/src/storage/adapter/clickhouse/schema.ts +++ b/src/storage/adapter/clickhouse/schema.ts @@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS basic_usage_events ( debit_amount Int64, metadata JSON ) ENGINE = ReplacingMergeTree() -ORDER BY (idempotency_key, user_id) +ORDER BY (project_id, idempotency_key, user_id) `; const AI_TOKEN_USAGE_EVENTS_TABLE = ` @@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS ai_token_usage_events ( metrics String, metadata JSON ) ENGINE = ReplacingMergeTree() -ORDER BY (idempotency_key, user_id) +ORDER BY (project_id, idempotency_key, user_id) `; export async function runClickHouseMigrations(): Promise { diff --git a/src/storage/adapter/clickhouse/utils.ts b/src/storage/adapter/clickhouse/utils.ts index 19e6d8e..4873556 100644 --- a/src/storage/adapter/clickhouse/utils.ts +++ b/src/storage/adapter/clickhouse/utils.ts @@ -2,7 +2,7 @@ import type { DateTime } from "luxon"; import { DateTime as LuxonDateTime } from "luxon"; import { getPostgresDB } from "../../db/postgres/db"; import { usersTable } from "../../db/postgres/schema"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { StorageError } from "../../../errors/storage"; import { getClickHouseDB } from "../../db/clickhouse"; import type { UserId } from "../../../config/identifiers"; @@ -12,13 +12,18 @@ export function toClickHouseDateTime(dt: DateTime): string { return dt.toUTC().toFormat("yyyy-MM-dd HH:mm:ss.SSS"); } -async function fetchLastBilled(userId: string): Promise { +async function fetchLastBilled( + userId: string, + projectId: string +): Promise { const pgDb = getPostgresDB(); try { const [user] = await pgDb .select({ lastBilled: usersTable.last_billed_timestamp }) .from(usersTable) - .where(eq(usersTable.id, userId)) + .where( + and(eq(usersTable.id, userId), eq(usersTable.projectId, projectId)) + ) .limit(1); return user?.lastBilled ?? null; } catch { @@ -49,7 +54,7 @@ export async function runClickHousePriceQuery( } const beforeTs = toClickHouseDateTime(beforeTimestamp); - const lastBilled = await fetchLastBilled(userId); + const lastBilled = await fetchLastBilled(userId, auth.projectId); try { let query: string; diff --git a/src/storage/db/postgres/helpers/metadata.ts b/src/storage/db/postgres/helpers/metadata.ts index f696125..d14389b 100644 --- a/src/storage/db/postgres/helpers/metadata.ts +++ b/src/storage/db/postgres/helpers/metadata.ts @@ -14,3 +14,11 @@ export async function getMetadata( .limit(1); return metadata; } + +export async function getAnyMetadata(): Promise< + typeof metadataTable.$inferSelect | undefined +> { + const db = getPostgresDB(); + const [metadata] = await db.select().from(metadataTable).limit(1); + return metadata; +} diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index 3eea693..510c9ff 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -344,6 +344,13 @@ export const tagsTable = pgTable("tags", { }), }); +export const tagsRelation = relations(tagsTable, ({ one }) => ({ + project: one(projectsTable, { + fields: [tagsTable.projectId], + references: [projectsTable.id], + }), +})); + export const metadataTable = pgTable( "metadata", { @@ -369,6 +376,13 @@ export const metadataTable = pgTable( }) ); +export const metadataRelation = relations(metadataTable, ({ one }) => ({ + project: one(projectsTable, { + fields: [metadataTable.projectId], + references: [projectsTable.id], + }), +})); + export const expressionsTable = pgTable("expressions", { id: uuid("id").primaryKey().defaultRandom(), projectId: uuid("project_id") @@ -382,6 +396,13 @@ export const expressionsTable = pgTable("expressions", { }), }); +export const expressionsRelation = relations(expressionsTable, ({ one }) => ({ + project: one(projectsTable, { + fields: [expressionsTable.projectId], + references: [projectsTable.id], + }), +})); + export const webhookEndpointsTable = pgTable( "webhook_endpoints", { From 4788da898373ff377bfd8e4df0ae7cf4ebd4f4bb Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Fri, 26 Jun 2026 16:26:02 +0530 Subject: [PATCH 14/33] feat(createDashboardKey): made a new route to handle creation of dashboard keys --- src/routes/http/api/apiKeys.ts | 87 +++++++++++++++++++ src/routes/http/api/onboarding.ts | 32 +++++-- src/routes/http/api/registerApiRoutes.ts | 8 ++ .../postgres/handlers/addBasicUsage.ts | 2 +- src/storage/db/postgres/helpers/metadata.ts | 8 -- src/storage/db/postgres/schema.ts | 15 +++- 6 files changed, 133 insertions(+), 19 deletions(-) diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 2dc084d..385912c 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -19,12 +19,14 @@ import { getPostgresDB } from "../../../storage/db/postgres/db"; import { executeInTransaction } from "../../../storage/adapter/postgres/handlers/addEventUtils"; import { apiKeysTable, + projectsTable, webhookEndpointsTable, } from "../../../storage/db/postgres/schema"; import { eq, and, isNull, ne, sql } from "drizzle-orm"; import type { ApiKeyRole } from "../../../utils/keyFormat"; import { invalidateWebhookEndpointCache } from "../../../interceptors/auth"; import { apiKeyCache } from "../../../utils/apiKeyCache"; +import { authenticateMasterApiKey } from "../../../utils/authenticateMasterApiKey.ts"; const createApiKeySchema = z.object({ name: z.string().min(1, "Name is required").max(255), @@ -289,3 +291,88 @@ export async function handleRevokeApiKey( logger.emit(builder.build()); } } + +export async function handleCreateDashboardKey( + request: FastifyRequest, + reply: FastifyReply +): Promise | { error: string }> { + const builder = createWideEventBuilder( + generateRequestId(), + request.method, + request.url + ); + + try { + const authHeader = request.headers.authorization; + authenticateMasterApiKey(authHeader); + + const body = await request.body; + const params = request.params as { project_id: string }; + + const appUrl = process.env.APP_URL; + if (!appUrl) { + builder.setError(500, { + type: "ConfigError", + message: "APP_URL environment variable is not set", + }); + reply.code(500); + return { error: "APP_URL environment variable is not set" }; + } + + const projectId = params.project_id; + + const existing = await getPostgresDB() + .select({ id: projectsTable.id }) + .from(projectsTable) + .where(eq(projectsTable.id, projectId)) + .limit(1); + + if (existing.length === 0) { + builder.setError(404, { + type: "NotFound", + message: `Project with name '${projectId}' doesn't exist`, + }); + reply.code(404); + return { + error: `Project with name '${projectId}' doesn't exist`, + }; + } + + const dashboardKey = generateAPIKey("dashboard"); + const dashboardKeyHash = hashAPIKey(dashboardKey); + const expiresAt = DateTime.utc().plus({ years: 10 }).toISO(); + const db = getPostgresDB(); + + await db.insert(apiKeysTable).values({ + projectId, + name: "Default dashboard key", + key: dashboardKeyHash, + role: "dashboard", + expiresAt, + }); + + builder.setSuccess(201); + reply.code(201); + return { projectId, apiKey: dashboardKey }; + } catch (error) { + if (error instanceof AuthError) { + builder.setError(401, { type: error.type, message: error.message }); + reply.code(401); + return { error: error.message }; + } + + if (error instanceof ZodError) { + const issues = error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("; "); + builder.setError(400, { type: "ValidationError", message: issues }); + reply.code(400); + return { error: issues }; + } + + const err = error instanceof Error ? error : new Error(String(error)); + builder.setError(500, { type: "InternalError", message: err.message }); + reply.code(500); + return { error: "Internal server error" }; + } +} diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index b347638..00e92db 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -10,6 +10,7 @@ import { } from "../../../context/requestContext.ts"; import { logger } from "../../../errors/logger.ts"; import { AuthError } from "../../../errors/auth"; +import { StorageError } from "../../../errors/storage"; import { authenticateMasterApiKey } from "../../../utils/authenticateMasterApiKey.ts"; import { authenticateHttpApiKey } from "../../../utils/authenticateHttpApiKey.ts"; import { generateAPIKey } from "../../../utils/generateAPIKey"; @@ -21,10 +22,7 @@ import { apiKeysTable, metadataTable, } from "../../../storage/db/postgres/schema"; -import { - getMetadata, - getAnyMetadata, -} from "../../../storage/db/postgres/helpers/metadata"; +import { getMetadata } from "../../../storage/db/postgres/helpers/metadata"; import { removeClient } from "../../gRPC/payment/paymentProvider.ts"; import { DateTime } from "luxon"; import { eq } from "drizzle-orm"; @@ -199,6 +197,18 @@ export async function handleOnboarding( extra: { context: "onboarding route handler" }, }); + if ( + error instanceof StorageError && + error.type === "CONSTRAINT_VIOLATION" + ) { + builder.setError(409, { + type: "ConflictError", + message: "A project with this name already exists", + }); + reply.code(409); + return {}; + } + if (error instanceof AuthError) { builder.setError(401, { type: error.type, @@ -268,9 +278,17 @@ export async function handleGetConfig( projectId = auth.projectId; } - const metadata = isMasterKey - ? await getAnyMetadata() - : await getMetadata(projectId!); + if (isMasterKey) { + const query = request.query as Record; + if (!query.projectId) { + throw AuthError.permissionDenied( + "projectId is required when using master key" + ); + } + projectId = query.projectId; + } + + const metadata = await getMetadata(projectId!); if (!metadata) { builder.setSuccess(200); diff --git a/src/routes/http/api/registerApiRoutes.ts b/src/routes/http/api/registerApiRoutes.ts index e17b649..557aa15 100644 --- a/src/routes/http/api/registerApiRoutes.ts +++ b/src/routes/http/api/registerApiRoutes.ts @@ -15,6 +15,7 @@ import { } from "./webhookEndpoints.ts"; import { handleCreateApiKey, + handleCreateDashboardKey, handleListApiKeys, handleRevokeApiKey, } from "./apiKeys.ts"; @@ -104,6 +105,13 @@ export async function registerApiRoutes( } ); + server.post( + "/api/v1/create-dashboard-key/:project-id", + async (request: FastifyRequest, reply: FastifyReply) => { + return handleCreateDashboardKey(request, reply); + } + ); + // Webhook endpoints server.post( "/api/v1/internals/webhook-endpoint", diff --git a/src/storage/adapter/postgres/handlers/addBasicUsage.ts b/src/storage/adapter/postgres/handlers/addBasicUsage.ts index 0de79d3..071d6c9 100644 --- a/src/storage/adapter/postgres/handlers/addBasicUsage.ts +++ b/src/storage/adapter/postgres/handlers/addBasicUsage.ts @@ -28,7 +28,7 @@ export async function handleAddBasicUsage( connectionObject, "storing BASIC_USAGE event", async (txn) => { - const ensurePromise = ensureUserExists( + const ensurePromise = await ensureUserExists( auth.projectId, event_data.userId, txn diff --git a/src/storage/db/postgres/helpers/metadata.ts b/src/storage/db/postgres/helpers/metadata.ts index d14389b..f696125 100644 --- a/src/storage/db/postgres/helpers/metadata.ts +++ b/src/storage/db/postgres/helpers/metadata.ts @@ -14,11 +14,3 @@ export async function getMetadata( .limit(1); return metadata; } - -export async function getAnyMetadata(): Promise< - typeof metadataTable.$inferSelect | undefined -> { - const db = getPostgresDB(); - const [metadata] = await db.select().from(metadataTable).limit(1); - return metadata; -} diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index 510c9ff..a6b4f21 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -8,6 +8,7 @@ import { text, boolean, jsonb, + unique, uniqueIndex, primaryKey, foreignKey, @@ -18,7 +19,7 @@ import { type Metrics } from "../../../zod/metrics"; export const projectsTable = pgTable("projects", { id: uuid("id").primaryKey().defaultRandom(), - name: text("name").notNull(), + name: text("name").notNull().unique(), createdAt: timestamp("created_at", { withTimezone: true, mode: "string", @@ -170,7 +171,7 @@ export const basicUsageEventsTable = pgTable( .references(() => projectsTable.id) .notNull(), eventId: uuid("event_id").notNull(), - idempotencyKey: text("idempotency_key").notNull().unique(), + idempotencyKey: text("idempotency_key").notNull(), reportedTimestamp: timestamp("reported_timestamp", { withTimezone: true, mode: "string", @@ -195,6 +196,10 @@ export const basicUsageEventsTable = pgTable( columns: [table.projectId, table.userId], foreignColumns: [usersTable.projectId, usersTable.id], }), + uniqueIdempotency: unique("basic_usage_events_idempotency_key_idx").on( + table.projectId, + table.idempotencyKey + ), }) ); @@ -281,7 +286,7 @@ export const aiTokenUsageEventsTable = pgTable( .references(() => projectsTable.id) .notNull(), eventId: uuid("event_id").notNull(), - idempotencyKey: text("idempotency_key").notNull().unique(), + idempotencyKey: text("idempotency_key").notNull(), reportedTimestamp: timestamp("reported_timestamp", { withTimezone: true, mode: "string", @@ -307,6 +312,10 @@ export const aiTokenUsageEventsTable = pgTable( columns: [table.projectId, table.userId], foreignColumns: [usersTable.projectId, usersTable.id], }), + uniqueIdempotency: unique("ai_token_usage_events_idempotency_key_idx").on( + table.projectId, + table.idempotencyKey + ), }) ); From 94184ac3d4fb85e544474dda8564844d6c54a57e Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Fri, 26 Jun 2026 16:48:22 +0530 Subject: [PATCH 15/33] fix(resgisterApiRoutes.ts): fixed params --- src/routes/http/api/apiKeys.ts | 2 +- src/routes/http/api/registerApiRoutes.ts | 2 +- src/utils/parseExpr.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 385912c..ce55a05 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -307,7 +307,7 @@ export async function handleCreateDashboardKey( authenticateMasterApiKey(authHeader); const body = await request.body; - const params = request.params as { project_id: string }; + const params = request.params as { projectId: string }; const appUrl = process.env.APP_URL; if (!appUrl) { diff --git a/src/routes/http/api/registerApiRoutes.ts b/src/routes/http/api/registerApiRoutes.ts index 557aa15..6626b93 100644 --- a/src/routes/http/api/registerApiRoutes.ts +++ b/src/routes/http/api/registerApiRoutes.ts @@ -106,7 +106,7 @@ export async function registerApiRoutes( ); server.post( - "/api/v1/create-dashboard-key/:project-id", + "/api/v1/create-dashboard-key/:projectId", async (request: FastifyRequest, reply: FastifyReply) => { return handleCreateDashboardKey(request, reply); } diff --git a/src/utils/parseExpr.ts b/src/utils/parseExpr.ts index 8a5559a..fe2841b 100644 --- a/src/utils/parseExpr.ts +++ b/src/utils/parseExpr.ts @@ -292,10 +292,10 @@ function resolveTokenPlaceholders( context: EvalTokenContext ): string { return exprString - .replace(/inputTokens\(\)/g, String(context.inputTokens ?? 0)) - .replace(/outputTokens\(\)/g, String(context.outputTokens ?? 0)) - .replace(/inputCacheTokens\(\)/g, String(context.inputCacheTokens ?? 0)) - .replace(/outputCacheTokens\(\)/g, String(context.outputCacheTokens ?? 0)); + .replace(/inputTokens\(\)/gi, String(context.inputTokens ?? 0)) + .replace(/outputTokens\(\)/gi, String(context.outputTokens ?? 0)) + .replace(/inputCacheTokens\(\)/gi, String(context.inputCacheTokens ?? 0)) + .replace(/outputCacheTokens\(\)/gi, String(context.outputCacheTokens ?? 0)); } /** From e9348f1b44517c690ad12ad7aae62cbe1725ca9b Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Fri, 26 Jun 2026 16:53:01 +0530 Subject: [PATCH 16/33] fix(apiKeys.ts): fixed linting errors --- src/routes/http/api/apiKeys.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index ce55a05..42abd4f 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -306,7 +306,6 @@ export async function handleCreateDashboardKey( const authHeader = request.headers.authorization; authenticateMasterApiKey(authHeader); - const body = await request.body; const params = request.params as { projectId: string }; const appUrl = process.env.APP_URL; @@ -319,22 +318,22 @@ export async function handleCreateDashboardKey( return { error: "APP_URL environment variable is not set" }; } - const projectId = params.project_id; + const project_id = params.projectId; const existing = await getPostgresDB() .select({ id: projectsTable.id }) .from(projectsTable) - .where(eq(projectsTable.id, projectId)) + .where(eq(projectsTable.id, project_id)) .limit(1); if (existing.length === 0) { builder.setError(404, { type: "NotFound", - message: `Project with name '${projectId}' doesn't exist`, + message: `Project with name '${project_id}' doesn't exist`, }); reply.code(404); return { - error: `Project with name '${projectId}' doesn't exist`, + error: `Project with name '${project_id}' doesn't exist`, }; } @@ -344,7 +343,7 @@ export async function handleCreateDashboardKey( const db = getPostgresDB(); await db.insert(apiKeysTable).values({ - projectId, + projectId: project_id, name: "Default dashboard key", key: dashboardKeyHash, role: "dashboard", @@ -353,7 +352,7 @@ export async function handleCreateDashboardKey( builder.setSuccess(201); reply.code(201); - return { projectId, apiKey: dashboardKey }; + return { project_id, apiKey: dashboardKey }; } catch (error) { if (error instanceof AuthError) { builder.setError(401, { type: error.type, message: error.message }); From 06e61d97c6e2c31caa750913ebefd4da27633199 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Sat, 27 Jun 2026 16:29:24 +0530 Subject: [PATCH 17/33] fix(apiKeys): fixed greptile issues --- src/interface/storage/Storage.ts | 2 + src/routes/http/api/apiKeys.ts | 51 ++++++++++++------- .../handlers/priceRequestAiTokenUsage.ts | 2 +- .../clickhouse/handlers/queryEvents.ts | 17 ++++++- .../adapter/postgres/handlers/queryEvents.ts | 16 +++++- 5 files changed, 65 insertions(+), 23 deletions(-) diff --git a/src/interface/storage/Storage.ts b/src/interface/storage/Storage.ts index a145180..c96f21e 100644 --- a/src/interface/storage/Storage.ts +++ b/src/interface/storage/Storage.ts @@ -20,6 +20,8 @@ export const QUERY_FIELD_NAMES = [ "outputDebitAmount", "inputCacheTokens", "inputCacheDebitAmount", + "outputCacheTokens", + "outputCacheDebitAmount", "creditAmount", "provider", "metadata", diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 42abd4f..91476da 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -308,16 +308,6 @@ export async function handleCreateDashboardKey( const params = request.params as { projectId: string }; - const appUrl = process.env.APP_URL; - if (!appUrl) { - builder.setError(500, { - type: "ConfigError", - message: "APP_URL environment variable is not set", - }); - reply.code(500); - return { error: "APP_URL environment variable is not set" }; - } - const project_id = params.projectId; const existing = await getPostgresDB() @@ -339,16 +329,41 @@ export async function handleCreateDashboardKey( const dashboardKey = generateAPIKey("dashboard"); const dashboardKeyHash = hashAPIKey(dashboardKey); - const expiresAt = DateTime.utc().plus({ years: 10 }).toISO(); + const expiresAt = DateTime.utc().plus({ years: 10 }).toISO()!; const db = getPostgresDB(); - await db.insert(apiKeysTable).values({ - projectId: project_id, - name: "Default dashboard key", - key: dashboardKeyHash, - role: "dashboard", - expiresAt, - }); + const existingKey = await db + .select({ id: apiKeysTable.id, key: apiKeysTable.key }) + .from(apiKeysTable) + .where( + and( + eq(apiKeysTable.projectId, project_id), + eq(apiKeysTable.name, "Default dashboard key"), + eq(apiKeysTable.revoked, false) + ) + ) + .limit(1); + + const existingDashboardKey = existingKey[0]; + if (existingDashboardKey) { + await db + .update(apiKeysTable) + .set({ + key: dashboardKeyHash, + expiresAt, + }) + .where(eq(apiKeysTable.id, existingDashboardKey.id)); + + apiKeyCache.delete(existingDashboardKey.key); + } else { + await db.insert(apiKeysTable).values({ + projectId: project_id, + name: "Default dashboard key", + key: dashboardKeyHash, + role: "dashboard", + expiresAt, + }); + } builder.setSuccess(201); reply.code(201); diff --git a/src/storage/adapter/clickhouse/handlers/priceRequestAiTokenUsage.ts b/src/storage/adapter/clickhouse/handlers/priceRequestAiTokenUsage.ts index 7cd4f13..d07795b 100644 --- a/src/storage/adapter/clickhouse/handlers/priceRequestAiTokenUsage.ts +++ b/src/storage/adapter/clickhouse/handlers/priceRequestAiTokenUsage.ts @@ -4,7 +4,7 @@ import type { AuthContext } from "../../../../context/auth"; import { runClickHousePriceQuery } from "../utils"; const VALUE_EXPR = - "JSONExtractInt(metrics, 'debit_amount', 'input') + JSONExtractInt(metrics, 'debit_amount', 'input_cache') + JSONExtractInt(metrics, 'debit_amount', 'output')"; + "JSONExtractInt(metrics, 'debit_amount', 'input') + JSONExtractInt(metrics, 'debit_amount', 'input_cache') + JSONExtractInt(metrics, 'debit_amount', 'output_cache') + JSONExtractInt(metrics, 'debit_amount', 'output')"; const BASE_QUERY = `SELECT sum(${VALUE_EXPR}) as total FROM ai_token_usage_events WHERE project_id = {projectId:String} AND user_id = {userId:String} AND mode = {mode:String} AND reported_timestamp < {before:DateTime64(3, 'UTC')}`; const WINDOW_QUERY = `SELECT sum(${VALUE_EXPR}) as total FROM ai_token_usage_events WHERE project_id = {projectId:String} AND user_id = {userId:String} AND mode = {mode:String} AND reported_timestamp > {lastBilled:DateTime64(3, 'UTC')} AND reported_timestamp < {before:DateTime64(3, 'UTC')}`; diff --git a/src/storage/adapter/clickhouse/handlers/queryEvents.ts b/src/storage/adapter/clickhouse/handlers/queryEvents.ts index abf3fa9..55b19ee 100644 --- a/src/storage/adapter/clickhouse/handlers/queryEvents.ts +++ b/src/storage/adapter/clickhouse/handlers/queryEvents.ts @@ -74,10 +74,10 @@ const CH_FIELDS: Partial< basicUsageType: { select: null, where: null }, debitAmount: { select: - "toString(JSONExtractInt(metrics, 'debit_amount', 'input') + JSONExtractInt(metrics, 'debit_amount', 'input_cache') + JSONExtractInt(metrics, 'debit_amount', 'output'))", + "toString(JSONExtractInt(metrics, 'debit_amount', 'input') + JSONExtractInt(metrics, 'debit_amount', 'input_cache') + JSONExtractInt(metrics, 'debit_amount', 'output_cache') + JSONExtractInt(metrics, 'debit_amount', 'output'))", where: null, aggExpr: - "JSONExtractInt(metrics, 'debit_amount', 'input') + JSONExtractInt(metrics, 'debit_amount', 'input_cache') + JSONExtractInt(metrics, 'debit_amount', 'output')", + "JSONExtractInt(metrics, 'debit_amount', 'input') + JSONExtractInt(metrics, 'debit_amount', 'input_cache') + JSONExtractInt(metrics, 'debit_amount', 'output_cache') + JSONExtractInt(metrics, 'debit_amount', 'output')", }, model: { select: "model", where: "model" }, inputTokens: { @@ -111,6 +111,17 @@ const CH_FIELDS: Partial< where: null, aggExpr: "JSONExtractInt(metrics, 'debit_amount', 'input_cache')", }, + outputCacheTokens: { + select: "toString(JSONExtractInt(metrics, 'tokens', 'output_cache'))", + where: null, + aggExpr: "JSONExtractInt(metrics, 'tokens', 'output_cache')", + }, + outputCacheDebitAmount: { + select: + "toString(JSONExtractInt(metrics, 'debit_amount', 'output_cache'))", + where: null, + aggExpr: "JSONExtractInt(metrics, 'debit_amount', 'output_cache')", + }, creditAmount: { select: null, where: null }, provider: { select: "provider", where: "provider" }, metadata: { select: "toString(metadata)", where: null }, @@ -133,6 +144,8 @@ const CH_PARAM_TYPE: Record = { outputDebitAmount: "Int64", inputCacheTokens: "Int64", inputCacheDebitAmount: "Int64", + outputCacheTokens: "Int64", + outputCacheDebitAmount: "Int64", creditAmount: "Int64", provider: "String", metadata: "String", diff --git a/src/storage/adapter/postgres/handlers/queryEvents.ts b/src/storage/adapter/postgres/handlers/queryEvents.ts index 3bcf970..308effd 100644 --- a/src/storage/adapter/postgres/handlers/queryEvents.ts +++ b/src/storage/adapter/postgres/handlers/queryEvents.ts @@ -105,11 +105,11 @@ const PG_FIELDS: PGFieldRegistry = { basicUsageType: { select: null, whereCol: null, whereCast: "" }, debitAmount: { select: - "(COALESCE((metrics->'debit_amount'->>'input')::integer,0) + COALESCE((metrics->'debit_amount'->>'input_cache')::integer,0) + COALESCE((metrics->'debit_amount'->>'output')::integer,0))::text", + "(COALESCE((metrics->'debit_amount'->>'input')::integer,0) + COALESCE((metrics->'debit_amount'->>'input_cache')::integer,0) + COALESCE((metrics->'debit_amount'->>'output_cache')::integer,0) + COALESCE((metrics->'debit_amount'->>'output')::integer,0))::text", whereCol: null, whereCast: "", aggExpr: - "(COALESCE((metrics->'debit_amount'->>'input')::bigint,0) + COALESCE((metrics->'debit_amount'->>'input_cache')::bigint,0) + COALESCE((metrics->'debit_amount'->>'output')::bigint,0))", + "(COALESCE((metrics->'debit_amount'->>'input')::bigint,0) + COALESCE((metrics->'debit_amount'->>'input_cache')::bigint,0) + COALESCE((metrics->'debit_amount'->>'output_cache')::bigint,0) + COALESCE((metrics->'debit_amount'->>'output')::bigint,0))", }, model: { select: "model", whereCol: "model", whereCast: "" }, inputTokens: { @@ -148,6 +148,18 @@ const PG_FIELDS: PGFieldRegistry = { whereCast: "", aggExpr: "(metrics->'debit_amount'->>'input_cache')::bigint", }, + outputCacheTokens: { + select: "(metrics->'tokens'->>'output_cache')::text", + whereCol: null, + whereCast: "", + aggExpr: "(metrics->'tokens'->>'output_cache')::bigint", + }, + outputCacheDebitAmount: { + select: "(metrics->'debit_amount'->>'output_cache')::text", + whereCol: null, + whereCast: "", + aggExpr: "(metrics->'debit_amount'->>'output_cache')::bigint", + }, creditAmount: { select: null, whereCol: null, whereCast: "" }, provider: { select: "provider", whereCol: "provider", whereCast: "" }, metadata: { select: "metadata::text", whereCol: null, whereCast: "" }, From 1f4553be52e6e2ce7f3387eafa493997ff629b3d Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Sat, 27 Jun 2026 16:31:40 +0530 Subject: [PATCH 18/33] fix(queryEvents): fixed greptile issues --- src/storage/adapter/clickhouse/handlers/queryEvents.ts | 2 ++ src/storage/adapter/postgres/handlers/queryEvents.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/storage/adapter/clickhouse/handlers/queryEvents.ts b/src/storage/adapter/clickhouse/handlers/queryEvents.ts index 55b19ee..a7ba35d 100644 --- a/src/storage/adapter/clickhouse/handlers/queryEvents.ts +++ b/src/storage/adapter/clickhouse/handlers/queryEvents.ts @@ -52,6 +52,8 @@ const CH_FIELDS: Partial< outputDebitAmount: { select: null, where: null }, inputCacheTokens: { select: null, where: null }, inputCacheDebitAmount: { select: null, where: null }, + outputCacheTokens: { select: null, where: null }, + outputCacheDebitAmount: { select: null, where: null }, creditAmount: { select: null, where: null }, provider: { select: null, where: null }, metadata: { select: null, where: null }, diff --git a/src/storage/adapter/postgres/handlers/queryEvents.ts b/src/storage/adapter/postgres/handlers/queryEvents.ts index 308effd..b54a23a 100644 --- a/src/storage/adapter/postgres/handlers/queryEvents.ts +++ b/src/storage/adapter/postgres/handlers/queryEvents.ts @@ -69,6 +69,8 @@ const PG_FIELDS: PGFieldRegistry = { outputDebitAmount: { select: null, whereCol: null, whereCast: "" }, inputCacheTokens: { select: null, whereCol: null, whereCast: "" }, inputCacheDebitAmount: { select: null, whereCol: null, whereCast: "" }, + outputCacheTokens: { select: null, whereCol: null, whereCast: "" }, + outputCacheDebitAmount: { select: null, whereCol: null, whereCast: "" }, creditAmount: { select: null, whereCol: null, whereCast: "" }, provider: { select: null, whereCol: null, whereCast: "" }, metadata: { select: "metadata::text", whereCol: null, whereCast: "" }, @@ -194,6 +196,8 @@ const PG_FIELDS: PGFieldRegistry = { outputDebitAmount: { select: null, whereCol: null, whereCast: "" }, inputCacheTokens: { select: null, whereCol: null, whereCast: "" }, inputCacheDebitAmount: { select: null, whereCol: null, whereCast: "" }, + outputCacheTokens: { select: null, whereCol: null, whereCast: "" }, + outputCacheDebitAmount: { select: null, whereCol: null, whereCast: "" }, creditAmount: { select: "credit_amount::text", whereCol: "credit_amount", From e3fee511f5b497586bc0652908f0aecc55a23d93 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Sat, 27 Jun 2026 17:02:52 +0530 Subject: [PATCH 19/33] fix(db): constrain config keys --- src/routes/http/api/webhookDeliveries.ts | 6 ++ .../clickhouse/handlers/addAiTokenUsage.ts | 12 ++-- .../postgres/handlers/addAiTokenUsage.ts | 8 ++- .../db/postgres/helpers/expressions.ts | 29 +++------ src/storage/db/postgres/helpers/tags.ts | 29 +++------ src/storage/db/postgres/schema.ts | 64 ++++++++++++------- 6 files changed, 74 insertions(+), 74 deletions(-) diff --git a/src/routes/http/api/webhookDeliveries.ts b/src/routes/http/api/webhookDeliveries.ts index 0bca7fb..e9a5963 100644 --- a/src/routes/http/api/webhookDeliveries.ts +++ b/src/routes/http/api/webhookDeliveries.ts @@ -40,6 +40,12 @@ export async function handleListDeliveries( try { const auth = await authenticateHttpApiKey(request.headers.authorization); + if (auth.role !== "dashboard") { + throw AuthError.permissionDenied( + "Only dashboard keys can read webhook deliveries" + ); + } + const query = listDeliveriesQuerySchema.parse(request.query); const db = getPostgresDB(); diff --git a/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts b/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts index 1061e1d..132b914 100644 --- a/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts +++ b/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts @@ -165,13 +165,15 @@ export async function handleAddAiTokenUsage( validateAiTokenEvent(event_data); } - const firstEvent = events[0]; - if (firstEvent) { - await ensureUserExists(auth.projectId, firstEvent.userId); - } - const aggregatedEvents = aggregateAiTokenEvents(events); + const distinctUserIds = Array.from( + new Set(aggregatedEvents.map((e) => e.userId)) + ); + for (const userId of distinctUserIds) { + await ensureUserExists(auth.projectId, userId); + } + const firstId = crypto.randomUUID(); const now = toClickHouseDateTime(DateTime.utc()); const values = buildAiTokenInsertRows(aggregatedEvents, auth, firstId, now); diff --git a/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts b/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts index 1117fa2..bf40b51 100644 --- a/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts +++ b/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts @@ -160,14 +160,16 @@ export async function handleAddAiTokenUsage( } const aggregatedEvents = await aggregateAiTokenEvents(events); - const firstEvent = events[0]; return await executeInTransaction( connectionObject, `storing ${events.length} AI_TOKEN_USAGE event(s)`, async (txn) => { - if (firstEvent) { - await ensureUserExists(auth.projectId, firstEvent.userId, txn); + const distinctUserIds = Array.from( + new Set(aggregatedEvents.map((e) => e.userId)) + ); + for (const userId of distinctUserIds) { + await ensureUserExists(auth.projectId, userId, txn); } try { diff --git a/src/storage/db/postgres/helpers/expressions.ts b/src/storage/db/postgres/helpers/expressions.ts index 41639d6..0d59c82 100644 --- a/src/storage/db/postgres/helpers/expressions.ts +++ b/src/storage/db/postgres/helpers/expressions.ts @@ -62,27 +62,14 @@ export async function createExpression( const db = getPostgresDB(); try { - const existing = await db - .select({ id: expressionsTable.id }) - .from(expressionsTable) - .where( - and( - eq(expressionsTable.projectId, projectId), - eq(expressionsTable.key, key), - isNull(expressionsTable.deletedAt) - ) - ) - .limit(1); - - if (existing[0]) { - await db - .update(expressionsTable) - .set({ expr }) - .where(eq(expressionsTable.id, existing[0].id)); - return; - } - - await db.insert(expressionsTable).values({ projectId, key, expr }); + await db + .insert(expressionsTable) + .values({ projectId, key, expr }) + .onConflictDoUpdate({ + target: [expressionsTable.projectId, expressionsTable.key], + targetWhere: isNull(expressionsTable.deletedAt), + set: { expr }, + }); } catch (e) { throw StorageError.insertFailed( `Failed to upsert expression '${key}'`, diff --git a/src/storage/db/postgres/helpers/tags.ts b/src/storage/db/postgres/helpers/tags.ts index e20d072..6d42143 100644 --- a/src/storage/db/postgres/helpers/tags.ts +++ b/src/storage/db/postgres/helpers/tags.ts @@ -34,28 +34,15 @@ export async function createTag( const db = getPostgresDB(); try { - const existing = await db - .select({ id: tagsTable.id }) - .from(tagsTable) - .where( - and( - eq(tagsTable.projectId, projectId), - eq(tagsTable.key, key), - isNull(tagsTable.deletedAt) - ) - ) - .limit(1); - - if (existing[0]) { - await db - .update(tagsTable) - .set({ amount }) - .where(eq(tagsTable.id, existing[0].id)); - tagCache.delete(`${projectId}:${key}`); - return; - } + await db + .insert(tagsTable) + .values({ projectId, key, amount }) + .onConflictDoUpdate({ + target: [tagsTable.projectId, tagsTable.key], + targetWhere: isNull(tagsTable.deletedAt), + set: { amount }, + }); - await db.insert(tagsTable).values({ projectId, key, amount }); tagCache.delete(`${projectId}:${key}`); } catch (e) { throw StorageError.insertFailed( diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index a6b4f21..b726540 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -340,18 +340,26 @@ export const aiTokenUsageEventsRelation = relations( }) ); -export const tagsTable = pgTable("tags", { - id: uuid("id").primaryKey().defaultRandom(), - projectId: uuid("project_id") - .references(() => projectsTable.id) - .notNull(), - key: text("key").notNull(), - amount: integer("amount").notNull(), - deletedAt: timestamp("deleted_at", { - withTimezone: true, - mode: "string", - }), -}); +export const tagsTable = pgTable( + "tags", + { + id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), + key: text("key").notNull(), + amount: integer("amount").notNull(), + deletedAt: timestamp("deleted_at", { + withTimezone: true, + mode: "string", + }), + }, + (table) => ({ + uniqueActiveTag: uniqueIndex("unique_active_tag") + .on(table.projectId, table.key) + .where(sql`${table.deletedAt} IS NULL`), + }) +); export const tagsRelation = relations(tagsTable, ({ one }) => ({ project: one(projectsTable, { @@ -392,18 +400,26 @@ export const metadataRelation = relations(metadataTable, ({ one }) => ({ }), })); -export const expressionsTable = pgTable("expressions", { - id: uuid("id").primaryKey().defaultRandom(), - projectId: uuid("project_id") - .references(() => projectsTable.id) - .notNull(), - key: text("key").notNull(), - expr: text("expr").notNull(), - deletedAt: timestamp("deleted_at", { - withTimezone: true, - mode: "string", - }), -}); +export const expressionsTable = pgTable( + "expressions", + { + id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id") + .references(() => projectsTable.id) + .notNull(), + key: text("key").notNull(), + expr: text("expr").notNull(), + deletedAt: timestamp("deleted_at", { + withTimezone: true, + mode: "string", + }), + }, + (table) => ({ + uniqueActiveExpr: uniqueIndex("unique_active_expr") + .on(table.projectId, table.key) + .where(sql`${table.deletedAt} IS NULL`), + }) +); export const expressionsRelation = relations(expressionsTable, ({ one }) => ({ project: one(projectsTable, { From 1550d8d97f6315f3eaa230e2e076bcbf7f808a21 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Sun, 28 Jun 2026 11:39:53 +0530 Subject: [PATCH 20/33] feat(projects): added project create, update, delete routes and func --- src/routes/http/api/projects.ts | 227 +++++++++++++++++++++++ src/routes/http/api/registerApiRoutes.ts | 27 +++ 2 files changed, 254 insertions(+) create mode 100644 src/routes/http/api/projects.ts diff --git a/src/routes/http/api/projects.ts b/src/routes/http/api/projects.ts new file mode 100644 index 0000000..7498c0a --- /dev/null +++ b/src/routes/http/api/projects.ts @@ -0,0 +1,227 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; +import * as Sentry from "@sentry/bun"; +import { + createWideEventBuilder, + generateRequestId, +} from "../../../context/requestContext.ts"; +import { logger } from "../../../errors/logger.ts"; +import { AuthError } from "../../../errors/auth"; +import { authenticateMasterApiKey } from "../../../utils/authenticateMasterApiKey.ts"; +import { getPostgresDB } from "../../../storage/db/postgres/db"; +import { + projectsTable, + metadataTable, + apiKeysTable, + usersTable, + sessionsTable, + basicUsageEventsTable, + aiTokenUsageEventsTable, + paymentEventsTable, +} from "../../../storage/db/postgres/schema"; +import { eq, inArray } from "drizzle-orm"; +import { encrypt, decrypt } from "../../../utils/encryptMetadata.ts"; +import { executeInTransaction } from "../../../storage/adapter/postgres/handlers/addEventUtils"; + +function maskApiKey(key: string | null | undefined): string | null { + if (!key) return null; + if (key.length <= 16) return "****"; + return key.slice(0, 4) + "****" + key.slice(-4); +} + +export async function handleListProjects( + request: FastifyRequest, + reply: FastifyReply +): Promise> { + const builder = createWideEventBuilder( + generateRequestId(), + request.method, + request.url + ); + + try { + authenticateMasterApiKey(request.headers.authorization); + + const body = (await request.body) as { projectIds: string[] }; + if ( + !body || + !Array.isArray(body.projectIds) || + body.projectIds.length === 0 + ) { + builder.setSuccess(200); + reply.code(200); + return { projects: [] }; + } + + const db = getPostgresDB(); + const projects = await db + .select({ + id: projectsTable.id, + name: projectsTable.name, + dodo_live_api_key: metadataTable.dodo_live_api_key, + dodo_test_api_key: metadataTable.dodo_test_api_key, + dodo_live_product_id: metadataTable.dodo_live_product_id, + dodo_test_product_id: metadataTable.dodo_test_product_id, + currency: metadataTable.currency, + redirect_url: metadataTable.redirect_url, + }) + .from(projectsTable) + .innerJoin(metadataTable, eq(projectsTable.id, metadataTable.projectId)) + .where(inArray(projectsTable.id, body.projectIds)); + + builder.setSuccess(200); + reply.code(200); + + return { + projects: projects.map((p) => ({ + id: p.id, + name: p.name, + dodoLiveApiKey: maskApiKey(decrypt(p.dodo_live_api_key)), + dodoTestApiKey: maskApiKey(decrypt(p.dodo_test_api_key)), + dodoLiveProductId: p.dodo_live_product_id, + dodoTestProductId: p.dodo_test_product_id, + currency: p.currency, + redirectUrl: p.redirect_url, + })), + }; + } catch (error) { + Sentry.captureException(error, { + extra: { context: "handleListProjects" }, + }); + if (error instanceof AuthError) { + builder.setError(401, { type: error.type, message: error.message }); + reply.code(401); + return {}; + } + const err = error instanceof Error ? error : new Error(String(error)); + builder.setError(500, { type: "InternalError", message: err.message }); + reply.code(500); + return {}; + } finally { + logger.emit(builder.build()); + } +} + +export async function handleUpdateProject( + request: FastifyRequest, + reply: FastifyReply +): Promise> { + const builder = createWideEventBuilder( + generateRequestId(), + request.method, + request.url + ); + + try { + authenticateMasterApiKey(request.headers.authorization); + + const { projectId } = request.params as { projectId: string }; + const body = (await request.body) as { + name?: string; + dodoLiveProductId?: string; + dodoTestProductId?: string; + currency?: string; + redirectUrl?: string; + dodoLiveApiKey?: string; + dodoTestApiKey?: string; + }; + + const db = getPostgresDB(); + await executeInTransaction(db, "update project", async (txn) => { + if (body.name) { + await txn + .update(projectsTable) + .set({ name: body.name }) + .where(eq(projectsTable.id, projectId)); + } + + const metaUpdates: any = {}; + if (body.dodoLiveProductId) + metaUpdates.dodo_live_product_id = body.dodoLiveProductId; + if (body.dodoTestProductId) + metaUpdates.dodo_test_product_id = body.dodoTestProductId; + if (body.currency) metaUpdates.currency = body.currency; + if (body.redirectUrl) metaUpdates.redirect_url = body.redirectUrl; + + if (body.dodoLiveApiKey && !body.dodoLiveApiKey.includes("****")) { + metaUpdates.dodo_live_api_key = encrypt(body.dodoLiveApiKey); + } + if (body.dodoTestApiKey && !body.dodoTestApiKey.includes("****")) { + metaUpdates.dodo_test_api_key = encrypt(body.dodoTestApiKey); + } + + if (Object.keys(metaUpdates).length > 0) { + await txn + .update(metadataTable) + .set(metaUpdates) + .where(eq(metadataTable.projectId, projectId)); + } + }); + + builder.setSuccess(200); + reply.code(200); + return { success: true }; + } catch (error) { + Sentry.captureException(error, { + extra: { context: "handleUpdateProject" }, + }); + if (error instanceof AuthError) { + builder.setError(401, { type: error.type, message: error.message }); + reply.code(401); + return {}; + } + const err = error instanceof Error ? error : new Error(String(error)); + builder.setError(500, { type: "InternalError", message: err.message }); + reply.code(500); + return {}; + } finally { + logger.emit(builder.build()); + } +} + +export async function handleDeleteProject( + request: FastifyRequest, + reply: FastifyReply +): Promise> { + const builder = createWideEventBuilder( + generateRequestId(), + request.method, + request.url + ); + + try { + authenticateMasterApiKey(request.headers.authorization); + + const { projectId } = request.params as { projectId: string }; + const db = getPostgresDB(); + + // Manual cascading deletes + await executeInTransaction(db, "delete project", async (txn) => { + await txn + .delete(apiKeysTable) + .where(eq(apiKeysTable.projectId, projectId)); + await txn + .delete(metadataTable) + .where(eq(metadataTable.projectId, projectId)); + await txn.delete(projectsTable).where(eq(projectsTable.id, projectId)); + }); + + builder.setSuccess(200); + reply.code(200); + return { success: true }; + } catch (error) { + Sentry.captureException(error, { + extra: { context: "handleDeleteProject" }, + }); + if (error instanceof AuthError) { + builder.setError(401, { type: error.type, message: error.message }); + reply.code(401); + return {}; + } + const err = error instanceof Error ? error : new Error(String(error)); + builder.setError(500, { type: "InternalError", message: err.message }); + reply.code(500); + return {}; + } finally { + logger.emit(builder.build()); + } +} diff --git a/src/routes/http/api/registerApiRoutes.ts b/src/routes/http/api/registerApiRoutes.ts index 6626b93..18185f2 100644 --- a/src/routes/http/api/registerApiRoutes.ts +++ b/src/routes/http/api/registerApiRoutes.ts @@ -1,5 +1,10 @@ import type { FastifyRequest, FastifyReply } from "fastify"; import { handleOnboarding, handleGetConfig } from "./onboarding.ts"; +import { + handleListProjects, + handleUpdateProject, + handleDeleteProject, +} from "./projects.ts"; import { handleListTags, handleCreateTag, handleDeleteTag } from "./tags.ts"; import { handleListExpressions, @@ -39,6 +44,28 @@ export async function registerApiRoutes( } ); + // Projects + server.post( + "/api/v1/internals/projects/list", + async (request: FastifyRequest, reply: FastifyReply) => { + return handleListProjects(request, reply); + } + ); + + server.put( + "/api/v1/internals/projects/:projectId", + async (request: FastifyRequest, reply: FastifyReply) => { + return handleUpdateProject(request, reply); + } + ); + + server.delete( + "/api/v1/internals/projects/:projectId", + async (request: FastifyRequest, reply: FastifyReply) => { + return handleDeleteProject(request, reply); + } + ); + // Tags server.get( "/api/v1/tags", From 4ca65cb73f0f7bc1c0b053aece67e82658ca1c45 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Sun, 28 Jun 2026 11:59:21 +0530 Subject: [PATCH 21/33] fix(projects): fixed projects checking --- src/routes/http/api/projects.ts | 119 ++++++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 6 deletions(-) diff --git a/src/routes/http/api/projects.ts b/src/routes/http/api/projects.ts index 7498c0a..5c7bcc7 100644 --- a/src/routes/http/api/projects.ts +++ b/src/routes/http/api/projects.ts @@ -1,5 +1,7 @@ import type { FastifyRequest, FastifyReply } from "fastify"; import * as Sentry from "@sentry/bun"; +import DodoPayments from "dodopayments"; +import { removeClient } from "../../gRPC/payment/paymentProvider.ts"; import { createWideEventBuilder, generateRequestId, @@ -126,12 +128,19 @@ export async function handleUpdateProject( }; const db = getPostgresDB(); + + const appUrl = process.env.SCRAWN_HTTP_URL || "http://localhost:8070"; + await executeInTransaction(db, "update project", async (txn) => { + let rowsAffected = 0; + if (body.name) { - await txn + const updated = await txn .update(projectsTable) .set({ name: body.name }) - .where(eq(projectsTable.id, projectId)); + .where(eq(projectsTable.id, projectId)) + .returning({ id: projectsTable.id }); + rowsAffected += updated.length; } const metaUpdates: any = {}; @@ -144,19 +153,74 @@ export async function handleUpdateProject( if (body.dodoLiveApiKey && !body.dodoLiveApiKey.includes("****")) { metaUpdates.dodo_live_api_key = encrypt(body.dodoLiveApiKey); + + const liveClient = new DodoPayments({ + bearerToken: body.dodoLiveApiKey, + environment: "live_mode", + }); + + try { + const liveWebhook = await liveClient.webhooks.create({ + url: `${appUrl}/webhooks/payment/createdCheckout?mode=production&projectId=${projectId}`, + description: "Scrawn live payment webhook", + filter_types: ["payment.succeeded", "payment.failed"], + }); + const liveSecret = ( + await liveClient.webhooks.retrieveSecret(liveWebhook.id) + ).secret; + metaUpdates.dodo_live_webhook_secret = encrypt(liveSecret); + } catch (error) { + Sentry.captureException(error, { + extra: { context: "failed to register live webhook on update" }, + }); + } } + if (body.dodoTestApiKey && !body.dodoTestApiKey.includes("****")) { metaUpdates.dodo_test_api_key = encrypt(body.dodoTestApiKey); + + const testClient = new DodoPayments({ + bearerToken: body.dodoTestApiKey, + environment: "test_mode", + }); + + try { + const testWebhook = await testClient.webhooks.create({ + url: `${appUrl}/webhooks/payment/createdCheckout?mode=test&projectId=${projectId}`, + description: "Scrawn test payment webhook", + filter_types: ["payment.succeeded", "payment.failed"], + }); + const testSecret = ( + await testClient.webhooks.retrieveSecret(testWebhook.id) + ).secret; + metaUpdates.dodo_test_webhook_secret = encrypt(testSecret); + } catch (error) { + Sentry.captureException(error, { + extra: { context: "failed to register test webhook on update" }, + }); + } } if (Object.keys(metaUpdates).length > 0) { - await txn + const updated = await txn .update(metadataTable) .set(metaUpdates) - .where(eq(metadataTable.projectId, projectId)); + .where(eq(metadataTable.projectId, projectId)) + .returning({ projectId: metadataTable.projectId }); + rowsAffected += updated.length; + } + + if ( + rowsAffected === 0 && + (body.name || Object.keys(metaUpdates).length > 0) + ) { + throw new Error("PROJECT_NOT_FOUND"); } }); + // Invalidate cached clients + removeClient(projectId); + builder.setSuccess(200); reply.code(200); return { success: true }; @@ -170,6 +234,17 @@ export async function handleUpdateProject( return {}; } const err = error instanceof Error ? error : new Error(String(error)); + if ( + err.name === "StorageError" && + (err as any).originalError?.message === "PROJECT_NOT_FOUND" + ) { + builder.setError(404, { + type: "NotFoundError", + message: "Project not found", + }); + reply.code(404); + return {}; + } builder.setError(500, { type: "InternalError", message: err.message }); reply.code(500); return {}; @@ -194,17 +269,38 @@ export async function handleDeleteProject( const { projectId } = request.params as { projectId: string }; const db = getPostgresDB(); - // Manual cascading deletes await executeInTransaction(db, "delete project", async (txn) => { + await txn + .delete(basicUsageEventsTable) + .where(eq(basicUsageEventsTable.projectId, projectId)); + await txn + .delete(paymentEventsTable) + .where(eq(paymentEventsTable.projectId, projectId)); + await txn + .delete(aiTokenUsageEventsTable) + .where(eq(aiTokenUsageEventsTable.projectId, projectId)); + await txn + .delete(sessionsTable) + .where(eq(sessionsTable.projectId, projectId)); + await txn.delete(usersTable).where(eq(usersTable.projectId, projectId)); await txn .delete(apiKeysTable) .where(eq(apiKeysTable.projectId, projectId)); await txn .delete(metadataTable) .where(eq(metadataTable.projectId, projectId)); - await txn.delete(projectsTable).where(eq(projectsTable.id, projectId)); + const deletedProject = await txn + .delete(projectsTable) + .where(eq(projectsTable.id, projectId)) + .returning({ id: projectsTable.id }); + + if (deletedProject.length === 0) { + throw new Error("PROJECT_NOT_FOUND"); + } }); + removeClient(projectId); + builder.setSuccess(200); reply.code(200); return { success: true }; @@ -218,6 +314,17 @@ export async function handleDeleteProject( return {}; } const err = error instanceof Error ? error : new Error(String(error)); + if ( + err.name === "StorageError" && + (err as any).originalError?.message === "PROJECT_NOT_FOUND" + ) { + builder.setError(404, { + type: "NotFoundError", + message: "Project not found", + }); + reply.code(404); + return {}; + } builder.setError(500, { type: "InternalError", message: err.message }); reply.code(500); return {}; From 8b2fa2b6a69b3e734cd72cbe9b977919e12f6b08 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 01:20:08 +0530 Subject: [PATCH 22/33] fix(project.ts): fixed multiple grep issues --- src/routes/http/api/apiKeys.ts | 63 +++++++++++++----------- src/routes/http/api/expressions.ts | 12 +++++ src/routes/http/api/projects.ts | 58 +++++++++++++++------- src/routes/http/api/tags.ts | 8 +++ src/storage/db/postgres/helpers/users.ts | 2 +- 5 files changed, 95 insertions(+), 48 deletions(-) diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 91476da..12e4ac2 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -332,38 +332,43 @@ export async function handleCreateDashboardKey( const expiresAt = DateTime.utc().plus({ years: 10 }).toISO()!; const db = getPostgresDB(); - const existingKey = await db - .select({ id: apiKeysTable.id, key: apiKeysTable.key }) - .from(apiKeysTable) - .where( - and( - eq(apiKeysTable.projectId, project_id), - eq(apiKeysTable.name, "Default dashboard key"), - eq(apiKeysTable.revoked, false) + await executeInTransaction(db, "rotate dashboard key", async (txn) => { + const existingKey = await txn + .select({ id: apiKeysTable.id, key: apiKeysTable.key }) + .from(apiKeysTable) + .where( + and( + eq(apiKeysTable.projectId, project_id), + eq(apiKeysTable.name, "Default dashboard key"), + eq(apiKeysTable.role, "dashboard"), + eq(apiKeysTable.revoked, false) + ) ) - ) - .limit(1); - - const existingDashboardKey = existingKey[0]; - if (existingDashboardKey) { - await db - .update(apiKeysTable) - .set({ + .for("update") + .limit(1); + + const existingDashboardKey = existingKey[0]; + if (existingDashboardKey) { + await txn + .update(apiKeysTable) + .set({ + key: dashboardKeyHash, + role: "dashboard", + expiresAt, + }) + .where(eq(apiKeysTable.id, existingDashboardKey.id)); + + apiKeyCache.delete(existingDashboardKey.key); + } else { + await txn.insert(apiKeysTable).values({ + projectId: project_id, + name: "Default dashboard key", key: dashboardKeyHash, + role: "dashboard", expiresAt, - }) - .where(eq(apiKeysTable.id, existingDashboardKey.id)); - - apiKeyCache.delete(existingDashboardKey.key); - } else { - await db.insert(apiKeysTable).values({ - projectId: project_id, - name: "Default dashboard key", - key: dashboardKeyHash, - role: "dashboard", - expiresAt, - }); - } + }); + } + }); builder.setSuccess(201); reply.code(201); diff --git a/src/routes/http/api/expressions.ts b/src/routes/http/api/expressions.ts index 1b3cfeb..40c002b 100644 --- a/src/routes/http/api/expressions.ts +++ b/src/routes/http/api/expressions.ts @@ -86,6 +86,12 @@ export async function handleCreateExpression( const authHeader = request.headers.authorization; const auth = await authenticateHttpApiKey(authHeader); + if (auth.role !== "dashboard") { + throw AuthError.permissionDenied( + "Only dashboard keys can manage expressions" + ); + } + const body = await request.body; const validated = createExpressionSchema.parse(body); @@ -149,6 +155,12 @@ export async function handleDeleteExpression( const authHeader = request.headers.authorization; const auth = await authenticateHttpApiKey(authHeader); + if (auth.role !== "dashboard") { + throw AuthError.permissionDenied( + "Only dashboard keys can manage expressions" + ); + } + const params = request.params as { key: string }; const deleted = await deleteExpression(auth.projectId, params.key); diff --git a/src/routes/http/api/projects.ts b/src/routes/http/api/projects.ts index 5c7bcc7..112b5fd 100644 --- a/src/routes/http/api/projects.ts +++ b/src/routes/http/api/projects.ts @@ -19,6 +19,8 @@ import { basicUsageEventsTable, aiTokenUsageEventsTable, paymentEventsTable, + webhookDeliveriesTable, + webhookEndpointsTable, } from "../../../storage/db/postgres/schema"; import { eq, inArray } from "drizzle-orm"; import { encrypt, decrypt } from "../../../utils/encryptMetadata.ts"; @@ -44,11 +46,16 @@ export async function handleListProjects( authenticateMasterApiKey(request.headers.authorization); const body = (await request.body) as { projectIds: string[] }; - if ( - !body || - !Array.isArray(body.projectIds) || - body.projectIds.length === 0 - ) { + if (!body || !Array.isArray(body.projectIds)) { + builder.setError(400, { + type: "BadRequestError", + message: "Missing projectIds array", + }); + reply.code(400); + return {}; + } + + if (body.projectIds.length === 0) { builder.setSuccess(200); reply.code(200); return { projects: [] }; @@ -129,20 +136,17 @@ export async function handleUpdateProject( const db = getPostgresDB(); - const appUrl = process.env.SCRAWN_HTTP_URL || "http://localhost:8070"; + const appUrl = process.env.APP_URL; + if (!appUrl) { + builder.setError(500, { + type: "InternalError", + message: "APP_URL environment variable is not set", + }); + reply.code(500); + return {}; + } await executeInTransaction(db, "update project", async (txn) => { - let rowsAffected = 0; - - if (body.name) { - const updated = await txn - .update(projectsTable) - .set({ name: body.name }) - .where(eq(projectsTable.id, projectId)) - .returning({ id: projectsTable.id }); - rowsAffected += updated.length; - } - const metaUpdates: any = {}; if (body.dodoLiveProductId) metaUpdates.dodo_live_product_id = body.dodoLiveProductId; @@ -173,6 +177,7 @@ export async function handleUpdateProject( Sentry.captureException(error, { extra: { context: "failed to register live webhook on update" }, }); + throw new Error("FAILED_TO_REGISTER_LIVE_WEBHOOK"); } } @@ -198,9 +203,21 @@ export async function handleUpdateProject( Sentry.captureException(error, { extra: { context: "failed to register test webhook on update" }, }); + throw new Error("FAILED_TO_REGISTER_TEST_WEBHOOK"); } } + let rowsAffected = 0; + + if (body.name) { + const updated = await txn + .update(projectsTable) + .set({ name: body.name }) + .where(eq(projectsTable.id, projectId)) + .returning({ id: projectsTable.id }); + rowsAffected += updated.length; + } + if (Object.keys(metaUpdates).length > 0) { const updated = await txn .update(metadataTable) @@ -218,7 +235,6 @@ export async function handleUpdateProject( } }); - // Invalidate cached clients removeClient(projectId); builder.setSuccess(200); @@ -270,6 +286,12 @@ export async function handleDeleteProject( const db = getPostgresDB(); await executeInTransaction(db, "delete project", async (txn) => { + await txn + .delete(webhookDeliveriesTable) + .where(eq(webhookDeliveriesTable.projectId, projectId)); + await txn + .delete(webhookEndpointsTable) + .where(eq(webhookEndpointsTable.projectId, projectId)); await txn .delete(basicUsageEventsTable) .where(eq(basicUsageEventsTable.projectId, projectId)); diff --git a/src/routes/http/api/tags.ts b/src/routes/http/api/tags.ts index 444f875..284e1dc 100644 --- a/src/routes/http/api/tags.ts +++ b/src/routes/http/api/tags.ts @@ -88,6 +88,10 @@ export async function handleCreateTag( const authHeader = request.headers.authorization; const auth = await authenticateHttpApiKey(authHeader); + if (auth.role !== "dashboard") { + throw AuthError.permissionDenied("Only dashboard keys can manage tags"); + } + const body = await request.body; const validated = createTagSchema.parse(body); @@ -139,6 +143,10 @@ export async function handleDeleteTag( const authHeader = request.headers.authorization; const auth = await authenticateHttpApiKey(authHeader); + if (auth.role !== "dashboard") { + throw AuthError.permissionDenied("Only dashboard keys can manage tags"); + } + const params = tagParamsSchema.parse(request.params); const deleted = await deleteTag(auth.projectId, params.key); diff --git a/src/storage/db/postgres/helpers/users.ts b/src/storage/db/postgres/helpers/users.ts index 624a5a5..63a2c0f 100644 --- a/src/storage/db/postgres/helpers/users.ts +++ b/src/storage/db/postgres/helpers/users.ts @@ -51,7 +51,7 @@ export async function ensureUserExists( await db .insert(usersTable) .values({ id: userId, projectId }) - .onConflictDoNothing(); + .onConflictDoNothing({ target: [usersTable.projectId, usersTable.id] }); } catch (e) { throw StorageError.queryFailed( "Failed to ensure user exists", From 84410404a953fd38fcffe01a96fac60b72428ddd Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 01:59:30 +0530 Subject: [PATCH 23/33] fix(projcets.ts): handling failure in updating dodo webhooks --- src/routes/http/api/projects.ts | 132 +++++++++++++++++++------------- 1 file changed, 78 insertions(+), 54 deletions(-) diff --git a/src/routes/http/api/projects.ts b/src/routes/http/api/projects.ts index 112b5fd..e9f6e66 100644 --- a/src/routes/http/api/projects.ts +++ b/src/routes/http/api/projects.ts @@ -120,6 +120,8 @@ export async function handleUpdateProject( request.url ); + const cleanupTasks: Array<() => Promise> = []; + try { authenticateMasterApiKey(request.headers.authorization); @@ -146,67 +148,85 @@ export async function handleUpdateProject( return {}; } - await executeInTransaction(db, "update project", async (txn) => { - const metaUpdates: any = {}; - if (body.dodoLiveProductId) - metaUpdates.dodo_live_product_id = body.dodoLiveProductId; - if (body.dodoTestProductId) - metaUpdates.dodo_test_product_id = body.dodoTestProductId; - if (body.currency) metaUpdates.currency = body.currency; - if (body.redirectUrl) metaUpdates.redirect_url = body.redirectUrl; - - if (body.dodoLiveApiKey && !body.dodoLiveApiKey.includes("****")) { - metaUpdates.dodo_live_api_key = encrypt(body.dodoLiveApiKey); - - const liveClient = new DodoPayments({ - bearerToken: body.dodoLiveApiKey, - environment: "live_mode", - }); + const metaUpdates: any = {}; + if (body.dodoLiveProductId) + metaUpdates.dodo_live_product_id = body.dodoLiveProductId; + if (body.dodoTestProductId) + metaUpdates.dodo_test_product_id = body.dodoTestProductId; + if (body.currency) metaUpdates.currency = body.currency; + if (body.redirectUrl) metaUpdates.redirect_url = body.redirectUrl; - try { - const liveWebhook = await liveClient.webhooks.create({ - url: `${appUrl}/webhooks/payment/createdCheckout?mode=production&projectId=${projectId}`, - description: "Scrawn live payment webhook", - filter_types: ["payment.succeeded", "payment.failed"], - }); - const liveSecret = ( - await liveClient.webhooks.retrieveSecret(liveWebhook.id) - ).secret; - metaUpdates.dodo_live_webhook_secret = encrypt(liveSecret); - } catch (error) { - Sentry.captureException(error, { - extra: { context: "failed to register live webhook on update" }, - }); - throw new Error("FAILED_TO_REGISTER_LIVE_WEBHOOK"); - } - } + if (body.dodoLiveApiKey && !body.dodoLiveApiKey.includes("****")) { + metaUpdates.dodo_live_api_key = encrypt(body.dodoLiveApiKey); - if (body.dodoTestApiKey && !body.dodoTestApiKey.includes("****")) { - metaUpdates.dodo_test_api_key = encrypt(body.dodoTestApiKey); + const liveClient = new DodoPayments({ + bearerToken: body.dodoLiveApiKey, + environment: "live_mode", + }); - const testClient = new DodoPayments({ - bearerToken: body.dodoTestApiKey, - environment: "test_mode", + try { + const liveWebhook = await liveClient.webhooks.create({ + url: `${appUrl}/webhooks/payment/createdCheckout?mode=production&projectId=${projectId}`, + description: "Scrawn live payment webhook", + filter_types: ["payment.succeeded", "payment.failed"], }); + cleanupTasks.push(async () => { + try { + await liveClient.webhooks.delete(liveWebhook.id); + } catch (e) { + Sentry.captureException(e, { + extra: { context: "failed to clean up live webhook on rollback" }, + }); + } + }); + const liveSecret = ( + await liveClient.webhooks.retrieveSecret(liveWebhook.id) + ).secret; + metaUpdates.dodo_live_webhook_secret = encrypt(liveSecret); + } catch (error) { + Sentry.captureException(error, { + extra: { context: "failed to register live webhook on update" }, + }); + throw new Error("FAILED_TO_REGISTER_LIVE_WEBHOOK"); + } + } + + if (body.dodoTestApiKey && !body.dodoTestApiKey.includes("****")) { + metaUpdates.dodo_test_api_key = encrypt(body.dodoTestApiKey); - try { - const testWebhook = await testClient.webhooks.create({ - url: `${appUrl}/webhooks/payment/createdCheckout?mode=test&projectId=${projectId}`, - description: "Scrawn test payment webhook", - filter_types: ["payment.succeeded", "payment.failed"], - }); - const testSecret = ( - await testClient.webhooks.retrieveSecret(testWebhook.id) - ).secret; - metaUpdates.dodo_test_webhook_secret = encrypt(testSecret); - } catch (error) { - Sentry.captureException(error, { - extra: { context: "failed to register test webhook on update" }, - }); - throw new Error("FAILED_TO_REGISTER_TEST_WEBHOOK"); - } + const testClient = new DodoPayments({ + bearerToken: body.dodoTestApiKey, + environment: "test_mode", + }); + + try { + const testWebhook = await testClient.webhooks.create({ + url: `${appUrl}/webhooks/payment/createdCheckout?mode=test&projectId=${projectId}`, + description: "Scrawn test payment webhook", + filter_types: ["payment.succeeded", "payment.failed"], + }); + cleanupTasks.push(async () => { + try { + await testClient.webhooks.delete(testWebhook.id); + } catch (e) { + Sentry.captureException(e, { + extra: { context: "failed to clean up test webhook on rollback" }, + }); + } + }); + const testSecret = ( + await testClient.webhooks.retrieveSecret(testWebhook.id) + ).secret; + metaUpdates.dodo_test_webhook_secret = encrypt(testSecret); + } catch (error) { + Sentry.captureException(error, { + extra: { context: "failed to register test webhook on update" }, + }); + throw new Error("FAILED_TO_REGISTER_TEST_WEBHOOK"); } + } + await executeInTransaction(db, "update project", async (txn) => { let rowsAffected = 0; if (body.name) { @@ -241,6 +261,10 @@ export async function handleUpdateProject( reply.code(200); return { success: true }; } catch (error) { + if (cleanupTasks.length > 0) { + await Promise.allSettled(cleanupTasks.map((task) => task())); + } + Sentry.captureException(error, { extra: { context: "handleUpdateProject" }, }); From 3a45993e6349f64174c3d83a19f59bb89bee631e Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 02:24:40 +0530 Subject: [PATCH 24/33] fix(projcets.ts): now accepting a dashboard-only target apiKeyId --- src/routes/http/api/projects.ts | 51 +++++++++++++++++++------ src/routes/http/api/webhookEndpoints.ts | 44 ++++++++++++++++++++- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/routes/http/api/projects.ts b/src/routes/http/api/projects.ts index e9f6e66..804a5c3 100644 --- a/src/routes/http/api/projects.ts +++ b/src/routes/http/api/projects.ts @@ -9,6 +9,7 @@ import { import { logger } from "../../../errors/logger.ts"; import { AuthError } from "../../../errors/auth"; import { authenticateMasterApiKey } from "../../../utils/authenticateMasterApiKey.ts"; +import { apiKeyCache } from "../../../utils/apiKeyCache.ts"; import { getPostgresDB } from "../../../storage/db/postgres/db"; import { projectsTable, @@ -138,16 +139,6 @@ export async function handleUpdateProject( const db = getPostgresDB(); - const appUrl = process.env.APP_URL; - if (!appUrl) { - builder.setError(500, { - type: "InternalError", - message: "APP_URL environment variable is not set", - }); - reply.code(500); - return {}; - } - const metaUpdates: any = {}; if (body.dodoLiveProductId) metaUpdates.dodo_live_product_id = body.dodoLiveProductId; @@ -164,6 +155,16 @@ export async function handleUpdateProject( environment: "live_mode", }); + const appUrl = process.env.APP_URL; + if (!appUrl) { + builder.setError(500, { + type: "InternalError", + message: "APP_URL environment variable is not set", + }); + reply.code(500); + return {}; + } + try { const liveWebhook = await liveClient.webhooks.create({ url: `${appUrl}/webhooks/payment/createdCheckout?mode=production&projectId=${projectId}`, @@ -199,6 +200,16 @@ export async function handleUpdateProject( environment: "test_mode", }); + const appUrl = process.env.APP_URL; + if (!appUrl) { + builder.setError(500, { + type: "InternalError", + message: "APP_URL environment variable is not set", + }); + reply.code(500); + return {}; + } + try { const testWebhook = await testClient.webhooks.create({ url: `${appUrl}/webhooks/payment/createdCheckout?mode=test&projectId=${projectId}`, @@ -285,6 +296,17 @@ export async function handleUpdateProject( reply.code(404); return {}; } + if ( + err.name === "StorageError" && + (err as any).originalError?.code === "23505" + ) { + builder.setError(409, { + type: "ConflictError", + message: "A project with this name already exists", + }); + reply.code(409); + return {}; + } builder.setError(500, { type: "InternalError", message: err.message }); reply.code(500); return {}; @@ -329,9 +351,14 @@ export async function handleDeleteProject( .delete(sessionsTable) .where(eq(sessionsTable.projectId, projectId)); await txn.delete(usersTable).where(eq(usersTable.projectId, projectId)); - await txn + const deletedKeys = await txn .delete(apiKeysTable) - .where(eq(apiKeysTable.projectId, projectId)); + .where(eq(apiKeysTable.projectId, projectId)) + .returning({ key: apiKeysTable.key }); + + for (const k of deletedKeys) { + apiKeyCache.delete(k.key); + } await txn .delete(metadataTable) .where(eq(metadataTable.projectId, projectId)); diff --git a/src/routes/http/api/webhookEndpoints.ts b/src/routes/http/api/webhookEndpoints.ts index a3ef8f3..febd4be 100644 --- a/src/routes/http/api/webhookEndpoints.ts +++ b/src/routes/http/api/webhookEndpoints.ts @@ -194,9 +194,29 @@ export async function handleGetWebhookEndpoint( const auth = await authenticateHttpApiKey(request.headers.authorization); builder.setApiKeyContext({ name: `webhook:${auth.apiKeyId}` }); + const query = request.query as { apiKeyId?: string }; + const targetApiKeyId = + query.apiKeyId && auth.role === "dashboard" + ? query.apiKeyId + : auth.apiKeyId; + + if (targetApiKeyId !== auth.apiKeyId) { + const targetKey = await getApiKeyRoleById(targetApiKeyId); + if (!targetKey || targetKey.projectId !== auth.projectId) { + builder.setError(403, { + type: "PermissionDenied", + message: "Target API key not found or belongs to another project", + }); + reply.code(403); + return { + error: "Target API key not found or belongs to another project", + }; + } + } + const endpoint = await getWebhookEndpointByApiKeyId( auth.projectId, - auth.apiKeyId + targetApiKeyId ); const endpoints: WebhookEndpointResponse[] = endpoint @@ -240,7 +260,27 @@ export async function handleDeleteWebhookEndpoint( const auth = await authenticateHttpApiKey(request.headers.authorization); builder.setApiKeyContext({ name: `webhook:${auth.apiKeyId}` }); - const deleted = await deleteWebhookEndpoint(auth.projectId, auth.apiKeyId); + const query = request.query as { apiKeyId?: string }; + const targetApiKeyId = + query.apiKeyId && auth.role === "dashboard" + ? query.apiKeyId + : auth.apiKeyId; + + if (targetApiKeyId !== auth.apiKeyId) { + const targetKey = await getApiKeyRoleById(targetApiKeyId); + if (!targetKey || targetKey.projectId !== auth.projectId) { + builder.setError(403, { + type: "PermissionDenied", + message: "Target API key not found or belongs to another project", + }); + reply.code(403); + return { + error: "Target API key not found or belongs to another project", + }; + } + } + + const deleted = await deleteWebhookEndpoint(auth.projectId, targetApiKeyId); if (!deleted) { builder.setError(404, { From f076f9fa8bbef33bdf8ba201ed048d6d87d40a35 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 02:43:13 +0530 Subject: [PATCH 25/33] fix(projcets.ts): fixed greptile issues --- src/storage/adapter/clickhouse/handlers/queryEvents.ts | 6 +++--- src/storage/adapter/postgres/handlers/queryEvents.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/storage/adapter/clickhouse/handlers/queryEvents.ts b/src/storage/adapter/clickhouse/handlers/queryEvents.ts index a7ba35d..d1c3db0 100644 --- a/src/storage/adapter/clickhouse/handlers/queryEvents.ts +++ b/src/storage/adapter/clickhouse/handlers/queryEvents.ts @@ -263,7 +263,7 @@ async function handleListQuery( ); let q = `SELECT ${buildSelectColumns(t)} FROM ${t}`; q += ` WHERE project_id = {projectId:String}`; - if (whereClause) q += ` AND ${whereClause}`; + if (whereClause) q += ` AND (${whereClause})`; return q; }); @@ -344,7 +344,7 @@ async function handleAggregationQuery( ); let q = `SELECT ${cols.join(", ")} FROM ${t}`; q += ` WHERE project_id = {projectId:String}`; - if (whereClause) q += ` AND ${whereClause}`; + if (whereClause) q += ` AND (${whereClause})`; return q; }); @@ -399,7 +399,7 @@ async function getTotalCount( ); let q = `SELECT count() as cnt FROM ${t}`; q += ` WHERE project_id = {projectId:String}`; - if (whereClause) q += ` AND ${whereClause}`; + if (whereClause) q += ` AND (${whereClause})`; return q; }); diff --git a/src/storage/adapter/postgres/handlers/queryEvents.ts b/src/storage/adapter/postgres/handlers/queryEvents.ts index b54a23a..e239305 100644 --- a/src/storage/adapter/postgres/handlers/queryEvents.ts +++ b/src/storage/adapter/postgres/handlers/queryEvents.ts @@ -304,7 +304,7 @@ async function handleListQuery( const subqueries = tables.map((t, i) => { const base = sql`SELECT ${selectExpr[i]} FROM ${sql.raw(t)}`; const fullWhere = whereExpr[i] - ? sql`${whereExpr[i]} AND ${projectFilter}` + ? sql`(${whereExpr[i]}) AND ${projectFilter}` : projectFilter; return sql`${base} WHERE ${fullWhere}`; }); @@ -373,7 +373,7 @@ async function handleAggregationQuery( const whereClause = buildWhereClause(request.where, t); const base = sql`SELECT ${sql.join(cols, sql`, `)} FROM ${sql.raw(t)}`; const fullWhere = whereClause - ? sql`${whereClause} AND ${projectFilter}` + ? sql`(${whereClause}) AND ${projectFilter}` : projectFilter; return sql`${base} WHERE ${fullWhere}`; }); @@ -431,7 +431,7 @@ async function getTotalCount( const whereClause = buildWhereClause(request.where, t); const base = sql`SELECT count(*)::int as cnt FROM ${sql.raw(t)}`; const fullWhere = whereClause - ? sql`${whereClause} AND ${projectFilter}` + ? sql`(${whereClause}) AND ${projectFilter}` : projectFilter; return sql`${base} WHERE ${fullWhere}`; }); From 0f6fae9db2eaec82c42d312299ae84cbec990140 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 08:05:21 +0530 Subject: [PATCH 26/33] fix(projcets.ts): using left join to insert data for projects --- src/routes/http/api/projects.ts | 10 +++++++--- src/routes/http/api/webhookEndpoints.ts | 24 ++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/routes/http/api/projects.ts b/src/routes/http/api/projects.ts index 804a5c3..483d24c 100644 --- a/src/routes/http/api/projects.ts +++ b/src/routes/http/api/projects.ts @@ -75,7 +75,7 @@ export async function handleListProjects( redirect_url: metadataTable.redirect_url, }) .from(projectsTable) - .innerJoin(metadataTable, eq(projectsTable.id, metadataTable.projectId)) + .leftJoin(metadataTable, eq(projectsTable.id, metadataTable.projectId)) .where(inArray(projectsTable.id, body.projectIds)); builder.setSuccess(200); @@ -85,8 +85,12 @@ export async function handleListProjects( projects: projects.map((p) => ({ id: p.id, name: p.name, - dodoLiveApiKey: maskApiKey(decrypt(p.dodo_live_api_key)), - dodoTestApiKey: maskApiKey(decrypt(p.dodo_test_api_key)), + dodoLiveApiKey: p.dodo_live_api_key + ? maskApiKey(decrypt(p.dodo_live_api_key)) + : null, + dodoTestApiKey: p.dodo_test_api_key + ? maskApiKey(decrypt(p.dodo_test_api_key)) + : null, dodoLiveProductId: p.dodo_live_product_id, dodoTestProductId: p.dodo_test_product_id, currency: p.currency, diff --git a/src/routes/http/api/webhookEndpoints.ts b/src/routes/http/api/webhookEndpoints.ts index febd4be..494e40f 100644 --- a/src/routes/http/api/webhookEndpoints.ts +++ b/src/routes/http/api/webhookEndpoints.ts @@ -291,7 +291,7 @@ export async function handleDeleteWebhookEndpoint( return { error: "No webhook endpoint found for this API key" }; } - invalidateWebhookEndpointCache(auth.apiKeyId); + invalidateWebhookEndpointCache(targetApiKeyId); builder.setSuccess(200); reply.code(200); @@ -439,9 +439,29 @@ export async function handleGetPublicKey( const auth = await authenticateHttpApiKey(request.headers.authorization); builder.setApiKeyContext({ name: `webhook:${auth.apiKeyId}` }); + const query = request.query as { apiKeyId?: string }; + const targetApiKeyId = + query.apiKeyId && auth.role === "dashboard" + ? query.apiKeyId + : auth.apiKeyId; + + if (targetApiKeyId !== auth.apiKeyId) { + const targetKey = await getApiKeyRoleById(targetApiKeyId); + if (!targetKey || targetKey.projectId !== auth.projectId) { + builder.setError(403, { + type: "PermissionDenied", + message: "Target API key not found or belongs to another project", + }); + reply.code(403); + return { + error: "Target API key not found or belongs to another project", + }; + } + } + const endpoint = await getWebhookEndpointByApiKeyId( auth.projectId, - auth.apiKeyId + targetApiKeyId ); if (!endpoint) { From a63ee9bb7b9ba5aa8517bb671661c06cf4e17d08 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 08:38:18 +0530 Subject: [PATCH 27/33] fix(projcets.ts): currency mismatch fixed --- src/routes/http/createdCheckout.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/http/createdCheckout.ts b/src/routes/http/createdCheckout.ts index 84700d3..e818b91 100644 --- a/src/routes/http/createdCheckout.ts +++ b/src/routes/http/createdCheckout.ts @@ -108,7 +108,7 @@ export async function handleDodoWebhook( return ignoredResponse(builder); } - const { payment_id, checkout_session_id } = webhookPayload.data; + const { payment_id, checkout_session_id, currency } = webhookPayload.data; builder.setWebhookContext({ webhookEvent: webhookPayload.type, @@ -238,7 +238,7 @@ export async function handleDodoWebhook( checkoutSessionId: checkout_session_id, userId, amount: creditAmount, - currency: "usd", + currency: currency, mode, billed_upto, createdAt: session.createdAt, From 609ab4ce2f9f24a570a48f1e15c5b39ea44dc3d1 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 08:52:59 +0530 Subject: [PATCH 28/33] fix(projcets.ts): added API key expiry handling to various files --- src/interceptors/auth.ts | 12 +++++++++--- src/routes/gRPC/auth/createAPIKey.ts | 12 +----------- src/routes/gRPC/data/query.ts | 14 ++++++++++---- src/routes/http/api/apiKeys.ts | 2 +- src/utils/authenticateHttpApiKey.ts | 7 +++++++ 5 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/interceptors/auth.ts b/src/interceptors/auth.ts index b271258..dcab5c4 100644 --- a/src/interceptors/auth.ts +++ b/src/interceptors/auth.ts @@ -122,8 +122,7 @@ export function authInterceptor( const wideEventBuilder = call[wideEventContextKey]; const authHeader = call.metadata.get("authorization")?.[0] as - | string - | undefined; + string | undefined; if (!authHeader) { return callback?.(AuthError.missingHeader()); @@ -156,6 +155,12 @@ export function authInterceptor( const cached = apiKeyCache.get(apiKeyHash); if (cached) { + if ( + cached.expiresAt && + DateTime.utc() > DateTime.fromISO(cached.expiresAt, { zone: "utc" }) + ) { + return callback?.(AuthError.expiredAPIKey()); + } if (cached.role !== role) { return callback?.( AuthError.roleMismatch( @@ -201,8 +206,9 @@ export function authInterceptor( } if ( + apiKeyRecord.expiresAt && DateTime.utc() > - DateTime.fromISO(apiKeyRecord.expiresAt, { zone: "utc" }) + DateTime.fromISO(apiKeyRecord.expiresAt, { zone: "utc" }) ) { return callback?.(AuthError.expiredAPIKey()); } diff --git a/src/routes/gRPC/auth/createAPIKey.ts b/src/routes/gRPC/auth/createAPIKey.ts index 535214c..17ebe69 100644 --- a/src/routes/gRPC/auth/createAPIKey.ts +++ b/src/routes/gRPC/auth/createAPIKey.ts @@ -3,7 +3,6 @@ import { CreateAPIKeyRequest, CreateAPIKeyResponse, } from "../../../gen/auth/v1/auth"; -import type { WideEventBuilder } from "../../../context/requestContext"; import { apiKeyContextKey } from "../../../context/auth"; import { createAPIKeySchema } from "../../../zod/apikey"; import { APIKeyError } from "../../../errors/apikey"; @@ -39,18 +38,9 @@ export async function createAPIKey( // Read role from gRPC metadata (not in proto message yet) const roleFromMeta = call.metadata.get("x-scrawn-role")?.[0] as - | string - | undefined; + string | undefined; const validatedData = validateRequest(req, roleFromMeta); - if (validatedData.role === "dashboard" && auth.role !== "dashboard") { - return callback?.( - AuthError.permissionDenied( - "Only dashboard keys can create dashboard keys" - ) - ); - } - wideEventBuilder?.setApiKeyContext({ name: validatedData.name }); const apiKey = generateAPIKey(validatedData.role as ApiKeyRole); diff --git a/src/routes/gRPC/data/query.ts b/src/routes/gRPC/data/query.ts index f52c852..0377e64 100644 --- a/src/routes/gRPC/data/query.ts +++ b/src/routes/gRPC/data/query.ts @@ -197,8 +197,7 @@ export async function queryData( callback?: sendUnaryData ): Promise { const wideEventBuilder = call[wideEventContextKey] as - | WideEventBuilder - | undefined; + WideEventBuilder | undefined; try { const auth = call[apiKeyContextKey]; @@ -225,9 +224,16 @@ export async function queryData( const db = getPostgresDB(); const userWhere = buildWhere(validated.where, tableDef); const projectFilter = eq(tableDef.table.projectId, auth.projectId) as SQL; - const whereClause = userWhere - ? and(projectFilter, userWhere) + + const modeFilter = tableDef.fields.mode + ? eq(tableDef.fields.mode.col, auth.mode) + : undefined; + + const baseFilter = modeFilter + ? and(projectFilter, modeFilter) : projectFilter; + + const whereClause = userWhere ? and(baseFilter, userWhere) : baseFilter; const selectCols = buildSelect(tableDef); const columns = Object.keys(tableDef.fields); diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 12e4ac2..7b9f258 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -372,7 +372,7 @@ export async function handleCreateDashboardKey( builder.setSuccess(201); reply.code(201); - return { project_id, apiKey: dashboardKey }; + return { projectId: project_id, apiKey: dashboardKey }; } catch (error) { if (error instanceof AuthError) { builder.setError(401, { type: error.type, message: error.message }); diff --git a/src/utils/authenticateHttpApiKey.ts b/src/utils/authenticateHttpApiKey.ts index 142853a..b236107 100644 --- a/src/utils/authenticateHttpApiKey.ts +++ b/src/utils/authenticateHttpApiKey.ts @@ -39,6 +39,12 @@ export async function authenticateHttpApiKey( const cached = apiKeyCache.get(apiKeyHash); if (cached) { + if ( + cached.expiresAt && + DateTime.utc() > DateTime.fromISO(cached.expiresAt, { zone: "utc" }) + ) { + throw AuthError.expiredAPIKey(); + } if (cached.role !== role) { throw AuthError.roleMismatch( `Key prefix ${role} doesn't match stored role ${cached.role}` @@ -63,6 +69,7 @@ export async function authenticateHttpApiKey( } if ( + apiKeyRecord.expiresAt && DateTime.utc() > DateTime.fromISO(apiKeyRecord.expiresAt, { zone: "utc" }) ) { throw AuthError.expiredAPIKey(); From ffef9a8aeae06e07d46dfacdb9a6a24bd0facac2 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 09:09:11 +0530 Subject: [PATCH 29/33] fix(projcets.ts): several auth issues are fixed --- src/routes/gRPC/auth/createAPIKey.ts | 8 ++++++++ src/routes/gRPC/data/query.ts | 13 ++++++++++--- src/routes/http/api/apiKeys.ts | 11 +++++++++++ src/routes/http/api/projects.ts | 29 ++++++++++++++++++---------- 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/routes/gRPC/auth/createAPIKey.ts b/src/routes/gRPC/auth/createAPIKey.ts index 17ebe69..1dde9fc 100644 --- a/src/routes/gRPC/auth/createAPIKey.ts +++ b/src/routes/gRPC/auth/createAPIKey.ts @@ -41,6 +41,14 @@ export async function createAPIKey( string | undefined; const validatedData = validateRequest(req, roleFromMeta); + if (validatedData.role === "dashboard") { + return callback?.( + AuthError.permissionDenied( + "Dashboard role creation is reserved for master keys" + ) + ); + } + wideEventBuilder?.setApiKeyContext({ name: validatedData.name }); const apiKey = generateAPIKey(validatedData.role as ApiKeyRole); diff --git a/src/routes/gRPC/data/query.ts b/src/routes/gRPC/data/query.ts index 0377e64..d8e9c9a 100644 --- a/src/routes/gRPC/data/query.ts +++ b/src/routes/gRPC/data/query.ts @@ -205,6 +205,12 @@ export async function queryData( return callback?.(AuthError.invalidAPIKey("API key context not found")); } + if (auth.role !== "dashboard") { + return callback?.( + AuthError.permissionDenied("Only dashboard keys can query data") + ); + } + const req = { ...call.request } as Record; const validated = dataQuerySchema.parse(req); @@ -225,9 +231,10 @@ export async function queryData( const userWhere = buildWhere(validated.where, tableDef); const projectFilter = eq(tableDef.table.projectId, auth.projectId) as SQL; - const modeFilter = tableDef.fields.mode - ? eq(tableDef.fields.mode.col, auth.mode) - : undefined; + const modeFilter = + tableDef.fields.mode && auth.mode + ? eq(tableDef.fields.mode.col, auth.mode) + : undefined; const baseFilter = modeFilter ? and(projectFilter, modeFilter) diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 7b9f258..ca2f4f8 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -59,6 +59,17 @@ export async function handleCreateApiKey( builder.setApiKeyContext({ name: `create-key:${auth.apiKeyId}` }); const body = await request.body; + + if ( + typeof body === "object" && + body !== null && + (body as Record).role === "dashboard" + ) { + throw AuthError.permissionDenied( + "Dashboard role creation is reserved for master keys" + ); + } + const validated = createApiKeySchema.parse(body); if ( diff --git a/src/routes/http/api/projects.ts b/src/routes/http/api/projects.ts index 483d24c..e9bc90c 100644 --- a/src/routes/http/api/projects.ts +++ b/src/routes/http/api/projects.ts @@ -144,12 +144,13 @@ export async function handleUpdateProject( const db = getPostgresDB(); const metaUpdates: any = {}; - if (body.dodoLiveProductId) + if (body.dodoLiveProductId !== undefined) metaUpdates.dodo_live_product_id = body.dodoLiveProductId; - if (body.dodoTestProductId) + if (body.dodoTestProductId !== undefined) metaUpdates.dodo_test_product_id = body.dodoTestProductId; - if (body.currency) metaUpdates.currency = body.currency; - if (body.redirectUrl) metaUpdates.redirect_url = body.redirectUrl; + if (body.currency !== undefined) metaUpdates.currency = body.currency; + if (body.redirectUrl !== undefined) + metaUpdates.redirect_url = body.redirectUrl; if (body.dodoLiveApiKey && !body.dodoLiveApiKey.includes("****")) { metaUpdates.dodo_live_api_key = encrypt(body.dodoLiveApiKey); @@ -244,7 +245,7 @@ export async function handleUpdateProject( await executeInTransaction(db, "update project", async (txn) => { let rowsAffected = 0; - if (body.name) { + if (body.name !== undefined) { const updated = await txn .update(projectsTable) .set({ name: body.name }) @@ -262,11 +263,19 @@ export async function handleUpdateProject( rowsAffected += updated.length; } - if ( - rowsAffected === 0 && - (body.name || Object.keys(metaUpdates).length > 0) - ) { - throw new Error("PROJECT_NOT_FOUND"); + if (rowsAffected === 0) { + if (body.name !== undefined || Object.keys(metaUpdates).length > 0) { + throw new Error("PROJECT_NOT_FOUND"); + } else { + const exists = await txn + .select({ id: projectsTable.id }) + .from(projectsTable) + .where(eq(projectsTable.id, projectId)) + .limit(1); + if (exists.length === 0) { + throw new Error("PROJECT_NOT_FOUND"); + } + } } }); From 0b62aaf96a58c7f6567dd32ba69e6c39447246d8 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 09:41:20 +0530 Subject: [PATCH 30/33] fix(projects.ts): checking for existing checkout links THEN making the new link --- src/gen/auth/v1/auth.ts | 8 +- src/gen/data/v1/data.ts | 83 +------------------ src/gen/event/v1/event.ts | 8 +- src/gen/payment/v1/payment.ts | 8 +- src/gen/query/v1/query.ts | 52 ++++++++++-- src/routes/gRPC/data/query.ts | 11 +-- src/routes/gRPC/payment/createCheckoutLink.ts | 20 ++--- src/routes/gRPC/query/queryEvents.ts | 6 ++ src/routes/http/api/webhookEndpoints.ts | 42 ++++++++++ src/storage/adapter/clickhouse/schema.ts | 4 +- src/storage/db/postgres/helpers/apiKeys.ts | 11 ++- src/zod/data.ts | 2 +- 12 files changed, 123 insertions(+), 132 deletions(-) diff --git a/src/gen/auth/v1/auth.ts b/src/gen/auth/v1/auth.ts index f41d746..08f72c8 100644 --- a/src/gen/auth/v1/auth.ts +++ b/src/gen/auth/v1/auth.ts @@ -330,13 +330,7 @@ export const AuthServiceClient = makeGenericClientConstructor( }; type Builtin = - | Date - | Function - | Uint8Array - | string - | number - | boolean - | undefined; + Date | Function | Uint8Array | string | number | boolean | undefined; export type DeepPartial = T extends Builtin ? T diff --git a/src/gen/data/v1/data.ts b/src/gen/data/v1/data.ts index e796e63..05223da 100644 --- a/src/gen/data/v1/data.ts +++ b/src/gen/data/v1/data.ts @@ -249,81 +249,6 @@ export function sessionsFieldToJSON(object: SessionsField): string { } } -export enum PaymentEventsField { - PAYMENT_EVENTS_FIELD_UNSPECIFIED = 0, - PAYMENT_EVENTS_ID = 1, - PAYMENT_EVENTS_USER_ID = 2, - PAYMENT_EVENTS_API_KEY_ID = 3, - PAYMENT_EVENTS_MODE = 4, - PAYMENT_EVENTS_CREDIT_AMOUNT = 5, - PAYMENT_EVENTS_SESSION_ID = 6, - PAYMENT_EVENTS_REPORTED_TIMESTAMP = 7, - PAYMENT_EVENTS_INGESTED_TIMESTAMP = 8, - UNRECOGNIZED = -1, -} - -export function paymentEventsFieldFromJSON(object: any): PaymentEventsField { - switch (object) { - case 0: - case "PAYMENT_EVENTS_FIELD_UNSPECIFIED": - return PaymentEventsField.PAYMENT_EVENTS_FIELD_UNSPECIFIED; - case 1: - case "PAYMENT_EVENTS_ID": - return PaymentEventsField.PAYMENT_EVENTS_ID; - case 2: - case "PAYMENT_EVENTS_USER_ID": - return PaymentEventsField.PAYMENT_EVENTS_USER_ID; - case 3: - case "PAYMENT_EVENTS_API_KEY_ID": - return PaymentEventsField.PAYMENT_EVENTS_API_KEY_ID; - case 4: - case "PAYMENT_EVENTS_MODE": - return PaymentEventsField.PAYMENT_EVENTS_MODE; - case 5: - case "PAYMENT_EVENTS_CREDIT_AMOUNT": - return PaymentEventsField.PAYMENT_EVENTS_CREDIT_AMOUNT; - case 6: - case "PAYMENT_EVENTS_SESSION_ID": - return PaymentEventsField.PAYMENT_EVENTS_SESSION_ID; - case 7: - case "PAYMENT_EVENTS_REPORTED_TIMESTAMP": - return PaymentEventsField.PAYMENT_EVENTS_REPORTED_TIMESTAMP; - case 8: - case "PAYMENT_EVENTS_INGESTED_TIMESTAMP": - return PaymentEventsField.PAYMENT_EVENTS_INGESTED_TIMESTAMP; - case -1: - case "UNRECOGNIZED": - default: - return PaymentEventsField.UNRECOGNIZED; - } -} - -export function paymentEventsFieldToJSON(object: PaymentEventsField): string { - switch (object) { - case PaymentEventsField.PAYMENT_EVENTS_FIELD_UNSPECIFIED: - return "PAYMENT_EVENTS_FIELD_UNSPECIFIED"; - case PaymentEventsField.PAYMENT_EVENTS_ID: - return "PAYMENT_EVENTS_ID"; - case PaymentEventsField.PAYMENT_EVENTS_USER_ID: - return "PAYMENT_EVENTS_USER_ID"; - case PaymentEventsField.PAYMENT_EVENTS_API_KEY_ID: - return "PAYMENT_EVENTS_API_KEY_ID"; - case PaymentEventsField.PAYMENT_EVENTS_MODE: - return "PAYMENT_EVENTS_MODE"; - case PaymentEventsField.PAYMENT_EVENTS_CREDIT_AMOUNT: - return "PAYMENT_EVENTS_CREDIT_AMOUNT"; - case PaymentEventsField.PAYMENT_EVENTS_SESSION_ID: - return "PAYMENT_EVENTS_SESSION_ID"; - case PaymentEventsField.PAYMENT_EVENTS_REPORTED_TIMESTAMP: - return "PAYMENT_EVENTS_REPORTED_TIMESTAMP"; - case PaymentEventsField.PAYMENT_EVENTS_INGESTED_TIMESTAMP: - return "PAYMENT_EVENTS_INGESTED_TIMESTAMP"; - case PaymentEventsField.UNRECOGNIZED: - default: - return "UNRECOGNIZED"; - } -} - export enum TagsField { TAGS_FIELD_UNSPECIFIED = 0, TAGS_ID = 1, @@ -1148,13 +1073,7 @@ export const DataQueryServiceClient = makeGenericClientConstructor( }; type Builtin = - | Date - | Function - | Uint8Array - | string - | number - | boolean - | undefined; + Date | Function | Uint8Array | string | number | boolean | undefined; export type DeepPartial = T extends Builtin ? T diff --git a/src/gen/event/v1/event.ts b/src/gen/event/v1/event.ts index 456097b..a70b4fa 100644 --- a/src/gen/event/v1/event.ts +++ b/src/gen/event/v1/event.ts @@ -1616,13 +1616,7 @@ export const EventServiceClient = makeGenericClientConstructor( }; type Builtin = - | Date - | Function - | Uint8Array - | string - | number - | boolean - | undefined; + Date | Function | Uint8Array | string | number | boolean | undefined; export type DeepPartial = T extends Builtin ? T diff --git a/src/gen/payment/v1/payment.ts b/src/gen/payment/v1/payment.ts index 7390001..904a221 100644 --- a/src/gen/payment/v1/payment.ts +++ b/src/gen/payment/v1/payment.ts @@ -243,13 +243,7 @@ export const PaymentServiceClient = makeGenericClientConstructor( }; type Builtin = - | Date - | Function - | Uint8Array - | string - | number - | boolean - | undefined; + Date | Function | Uint8Array | string | number | boolean | undefined; export type DeepPartial = T extends Builtin ? T diff --git a/src/gen/query/v1/query.ts b/src/gen/query/v1/query.ts index 49fb0c1..212b222 100644 --- a/src/gen/query/v1/query.ts +++ b/src/gen/query/v1/query.ts @@ -457,6 +457,8 @@ export interface EventRow { inputCacheTokens?: number | undefined; inputCacheDebitAmount?: number | undefined; metadata?: string | undefined; + outputCacheTokens?: number | undefined; + outputCacheDebitAmount?: number | undefined; } export interface AggregationRow { @@ -1000,6 +1002,8 @@ function createBaseEventRow(): EventRow { inputCacheTokens: undefined, inputCacheDebitAmount: undefined, metadata: undefined, + outputCacheTokens: undefined, + outputCacheDebitAmount: undefined, }; } @@ -1056,6 +1060,12 @@ export const EventRow: MessageFns = { if (message.metadata !== undefined) { writer.uint32(138).string(message.metadata); } + if (message.outputCacheTokens !== undefined) { + writer.uint32(144).int32(message.outputCacheTokens); + } + if (message.outputCacheDebitAmount !== undefined) { + writer.uint32(152).int64(message.outputCacheDebitAmount); + } return writer; }, @@ -1195,6 +1205,22 @@ export const EventRow: MessageFns = { message.metadata = reader.string(); continue; } + case 18: { + if (tag !== 144) { + break; + } + + message.outputCacheTokens = reader.int32(); + continue; + } + case 19: { + if (tag !== 152) { + break; + } + + message.outputCacheDebitAmount = longToNumber(reader.int64()); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -1278,6 +1304,16 @@ export const EventRow: MessageFns = { metadata: isSet(object.metadata) ? globalThis.String(object.metadata) : undefined, + outputCacheTokens: isSet(object.outputCacheTokens) + ? globalThis.Number(object.outputCacheTokens) + : isSet(object.output_cache_tokens) + ? globalThis.Number(object.output_cache_tokens) + : undefined, + outputCacheDebitAmount: isSet(object.outputCacheDebitAmount) + ? globalThis.Number(object.outputCacheDebitAmount) + : isSet(object.output_cache_debit_amount) + ? globalThis.Number(object.output_cache_debit_amount) + : undefined, }; }, @@ -1331,6 +1367,12 @@ export const EventRow: MessageFns = { if (message.metadata !== undefined) { obj.metadata = message.metadata; } + if (message.outputCacheTokens !== undefined) { + obj.outputCacheTokens = Math.round(message.outputCacheTokens); + } + if (message.outputCacheDebitAmount !== undefined) { + obj.outputCacheDebitAmount = Math.round(message.outputCacheDebitAmount); + } return obj; }, @@ -1355,6 +1397,8 @@ export const EventRow: MessageFns = { message.inputCacheTokens = object.inputCacheTokens ?? undefined; message.inputCacheDebitAmount = object.inputCacheDebitAmount ?? undefined; message.metadata = object.metadata ?? undefined; + message.outputCacheTokens = object.outputCacheTokens ?? undefined; + message.outputCacheDebitAmount = object.outputCacheDebitAmount ?? undefined; return message; }, }; @@ -1623,13 +1667,7 @@ export const QueryServiceClient = makeGenericClientConstructor( }; type Builtin = - | Date - | Function - | Uint8Array - | string - | number - | boolean - | undefined; + Date | Function | Uint8Array | string | number | boolean | undefined; export type DeepPartial = T extends Builtin ? T diff --git a/src/routes/gRPC/data/query.ts b/src/routes/gRPC/data/query.ts index d8e9c9a..62e4f35 100644 --- a/src/routes/gRPC/data/query.ts +++ b/src/routes/gRPC/data/query.ts @@ -100,14 +100,15 @@ const TABLE_REGISTRY: Record = { }; function castValue( - value: string, + value: string | number | boolean, fieldDef: FieldDef, fieldName: string ): boolean | number | string { if (fieldDef.cast === "boolean") { + if (typeof value === "boolean") return value; if (value !== "true" && value !== "false") { throw EventError.validationFailed( - `Invalid boolean value '${value}' for field '${fieldName}': must be "true" or "false"` + `Invalid boolean value '${String(value)}' for field '${fieldName}': must be "true" or "false"` ); } return value === "true"; @@ -116,18 +117,18 @@ function castValue( const n = Number(value); if (!Number.isFinite(n) || !Number.isInteger(n)) { throw EventError.validationFailed( - `Invalid integer value '${value}' for field '${fieldName}': must be a finite integer` + `Invalid integer value '${String(value)}' for field '${fieldName}': must be a finite integer` ); } return n; } - return value; + return typeof value === "string" ? value : String(value); } function applyOp( col: AnyPgColumn, op: string, - value: string, + value: string | number | boolean, fieldDef: FieldDef, fieldName: string ): SQL { diff --git a/src/routes/gRPC/payment/createCheckoutLink.ts b/src/routes/gRPC/payment/createCheckoutLink.ts index 6973f22..2b2d2e1 100644 --- a/src/routes/gRPC/payment/createCheckoutLink.ts +++ b/src/routes/gRPC/payment/createCheckoutLink.ts @@ -79,16 +79,6 @@ export async function createCheckoutLink( ); wideEventBuilder?.setPaymentContext({ priceAmount: custom_price }); - const checkoutResult = await createCheckoutSession( - auth.projectId, - config, - custom_price, - validatedData.userId, - auth.apiKeyId, - beforeTimestamp, - mode - ); - const checkoutLink = await executeInTransaction( db, "create checkout link", @@ -118,6 +108,16 @@ export async function createCheckoutLink( return proxyUrl; } + const checkoutResult = await createCheckoutSession( + auth.projectId, + config, + custom_price, + validatedData.userId, + auth.apiKeyId, + beforeTimestamp, + mode + ); + const sessionResult = await handleAddSession( auth.projectId, validatedData.userId, diff --git a/src/routes/gRPC/query/queryEvents.ts b/src/routes/gRPC/query/queryEvents.ts index 7378884..e2a3a4a 100644 --- a/src/routes/gRPC/query/queryEvents.ts +++ b/src/routes/gRPC/query/queryEvents.ts @@ -101,6 +101,12 @@ function buildEventRow(row: QueryResponse["rows"][number]): EventRow { if (row.inputCacheDebitAmount != null) { eventRow.inputCacheDebitAmount = Number(row.inputCacheDebitAmount); } + if (row.outputCacheTokens != null) { + eventRow.outputCacheTokens = Number(row.outputCacheTokens); + } + if (row.outputCacheDebitAmount != null) { + eventRow.outputCacheDebitAmount = Number(row.outputCacheDebitAmount); + } if (row.metadata != null) { eventRow.metadata = JSON.stringify(row.metadata); } diff --git a/src/routes/http/api/webhookEndpoints.ts b/src/routes/http/api/webhookEndpoints.ts index 494e40f..e30bd4f 100644 --- a/src/routes/http/api/webhookEndpoints.ts +++ b/src/routes/http/api/webhookEndpoints.ts @@ -106,6 +106,15 @@ export async function handleCreateWebhookEndpoint( return { error: "Target API key not found" }; } + if (targetKey.revoked) { + builder.setError(400, { + type: "ValidationError", + message: "Cannot set webhook for a revoked API key", + }); + reply.code(400); + return { error: "Cannot set webhook for a revoked API key" }; + } + if (targetKey.projectId !== auth.projectId) { builder.setError(403, { type: "PermissionDenied", @@ -212,6 +221,14 @@ export async function handleGetWebhookEndpoint( error: "Target API key not found or belongs to another project", }; } + if (targetKey.revoked) { + builder.setError(403, { + type: "PermissionDenied", + message: "Target API key is revoked", + }); + reply.code(403); + return { error: "Target API key is revoked" }; + } } const endpoint = await getWebhookEndpointByApiKeyId( @@ -278,6 +295,14 @@ export async function handleDeleteWebhookEndpoint( error: "Target API key not found or belongs to another project", }; } + if (targetKey.revoked) { + builder.setError(403, { + type: "PermissionDenied", + message: "Target API key is revoked", + }); + reply.code(403); + return { error: "Target API key is revoked" }; + } } const deleted = await deleteWebhookEndpoint(auth.projectId, targetApiKeyId); @@ -352,6 +377,15 @@ export async function handleSendTestWebhook( return { error: "API key not found" }; } + if (targetKey.revoked) { + builder.setError(400, { + type: "ValidationError", + message: "Cannot send test webhook to a revoked API key", + }); + reply.code(400); + return { error: "Cannot send test webhook to a revoked API key" }; + } + if (targetKey.projectId !== auth.projectId) { builder.setError(403, { type: "PermissionDenied", @@ -457,6 +491,14 @@ export async function handleGetPublicKey( error: "Target API key not found or belongs to another project", }; } + if (targetKey.revoked) { + builder.setError(403, { + type: "PermissionDenied", + message: "Target API key is revoked", + }); + reply.code(403); + return { error: "Target API key is revoked" }; + } } const endpoint = await getWebhookEndpointByApiKeyId( diff --git a/src/storage/adapter/clickhouse/schema.ts b/src/storage/adapter/clickhouse/schema.ts index 653c662..675774d 100644 --- a/src/storage/adapter/clickhouse/schema.ts +++ b/src/storage/adapter/clickhouse/schema.ts @@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS basic_usage_events ( debit_amount Int64, metadata JSON ) ENGINE = ReplacingMergeTree() -ORDER BY (project_id, idempotency_key, user_id) +ORDER BY (project_id, idempotency_key, user_id, event_id) `; const AI_TOKEN_USAGE_EVENTS_TABLE = ` @@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS ai_token_usage_events ( metrics String, metadata JSON ) ENGINE = ReplacingMergeTree() -ORDER BY (project_id, idempotency_key, user_id) +ORDER BY (project_id, idempotency_key, user_id, event_id) `; export async function runClickHouseMigrations(): Promise { diff --git a/src/storage/db/postgres/helpers/apiKeys.ts b/src/storage/db/postgres/helpers/apiKeys.ts index 950ae8b..d58fa42 100644 --- a/src/storage/db/postgres/helpers/apiKeys.ts +++ b/src/storage/db/postgres/helpers/apiKeys.ts @@ -79,17 +79,20 @@ type ApiKeyRecord = { projectId: string; }; -export async function getApiKeyRoleById( - id: string -): Promise<{ +export async function getApiKeyRoleById(id: string): Promise<{ role: "dashboard" | "production" | "test"; projectId: string; + revoked: boolean; } | null> { const db = getPostgresDB(); try { const [record] = await db - .select({ role: apiKeysTable.role, projectId: apiKeysTable.projectId }) + .select({ + role: apiKeysTable.role, + projectId: apiKeysTable.projectId, + revoked: apiKeysTable.revoked, + }) .from(apiKeysTable) .where(eq(apiKeysTable.id, id)) .limit(1); diff --git a/src/zod/data.ts b/src/zod/data.ts index 07c6bcb..9f16c13 100644 --- a/src/zod/data.ts +++ b/src/zod/data.ts @@ -33,7 +33,7 @@ const filterConditionSchema = z.object({ .min(1) .max(7) .transform((v) => OPERATOR_MAP[v as keyof typeof OPERATOR_MAP]), - value: z.string(), + value: z.union([z.string(), z.number(), z.boolean()]), }); const filterGroupSchema = createFilterGroupSchema( From 6f309e7290794e4c59df984fe6cc22306a3c89b6 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 09:44:16 +0530 Subject: [PATCH 31/33] fix(projects.ts): clickhouse startup cleanup --- src/storage/adapter/clickhouse/schema.ts | 1 - src/zod/data.ts | 8 +------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/storage/adapter/clickhouse/schema.ts b/src/storage/adapter/clickhouse/schema.ts index 675774d..94eff8e 100644 --- a/src/storage/adapter/clickhouse/schema.ts +++ b/src/storage/adapter/clickhouse/schema.ts @@ -1,6 +1,5 @@ import { getClickHouseDB } from "../../db/clickhouse"; import { logger } from "../../../errors/logger"; -import { innerProduct } from "drizzle-orm"; const BASIC_USAGE_EVENTS_TABLE = ` CREATE TABLE IF NOT EXISTS basic_usage_events ( diff --git a/src/zod/data.ts b/src/zod/data.ts index 9f16c13..ace275d 100644 --- a/src/zod/data.ts +++ b/src/zod/data.ts @@ -2,13 +2,7 @@ import { z } from "zod"; import { Operator, LogicalOperator } from "../gen/data/v1/data"; import { createFilterGroupSchema } from "./internals"; -const DATA_TABLE_NAMES = [ - "users", - "sessions", - "tags", - "expressions", - "metadata", -] as const; +const DATA_TABLE_NAMES = ["users", "sessions", "tags", "expressions"] as const; const OPERATOR_MAP = { [Operator.EQ]: "EQ", From aa3ea4486cec3a3c38b603308a3b338319daf771 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 10:06:41 +0530 Subject: [PATCH 32/33] fix(projects): handling partial metadata insertion failure --- src/gen/data/v1/data.ts | 45 ------------------------ src/routes/http/api/apiKeys.ts | 13 +++++++ src/routes/http/api/onboarding.ts | 28 ++++++++------- src/routes/http/api/projects.ts | 34 ++++++++---------- src/storage/adapter/clickhouse/schema.ts | 4 +-- src/storage/db/postgres/schema.ts | 14 ++++---- 6 files changed, 53 insertions(+), 85 deletions(-) diff --git a/src/gen/data/v1/data.ts b/src/gen/data/v1/data.ts index 05223da..7f7ec34 100644 --- a/src/gen/data/v1/data.ts +++ b/src/gen/data/v1/data.ts @@ -339,51 +339,6 @@ export function expressionsFieldToJSON(object: ExpressionsField): string { } } -export enum MetadataField { - METADATA_FIELD_UNSPECIFIED = 0, - METADATA_ID = 1, - METADATA_PAYMENT_CRON = 2, - METADATA_PAYMENT_WEBHOOK = 3, - UNRECOGNIZED = -1, -} - -export function metadataFieldFromJSON(object: any): MetadataField { - switch (object) { - case 0: - case "METADATA_FIELD_UNSPECIFIED": - return MetadataField.METADATA_FIELD_UNSPECIFIED; - case 1: - case "METADATA_ID": - return MetadataField.METADATA_ID; - case 2: - case "METADATA_PAYMENT_CRON": - return MetadataField.METADATA_PAYMENT_CRON; - case 3: - case "METADATA_PAYMENT_WEBHOOK": - return MetadataField.METADATA_PAYMENT_WEBHOOK; - case -1: - case "UNRECOGNIZED": - default: - return MetadataField.UNRECOGNIZED; - } -} - -export function metadataFieldToJSON(object: MetadataField): string { - switch (object) { - case MetadataField.METADATA_FIELD_UNSPECIFIED: - return "METADATA_FIELD_UNSPECIFIED"; - case MetadataField.METADATA_ID: - return "METADATA_ID"; - case MetadataField.METADATA_PAYMENT_CRON: - return "METADATA_PAYMENT_CRON"; - case MetadataField.METADATA_PAYMENT_WEBHOOK: - return "METADATA_PAYMENT_WEBHOOK"; - case MetadataField.UNRECOGNIZED: - default: - return "UNRECOGNIZED"; - } -} - export interface FilterCondition { field: string; operator: Operator; diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index ca2f4f8..96ac975 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -8,6 +8,7 @@ import { } from "../../../context/requestContext.ts"; import { logger } from "../../../errors/logger.ts"; import { AuthError } from "../../../errors/auth.ts"; +import { StorageError } from "../../../errors/storage.ts"; import { authenticateHttpApiKey } from "../../../utils/authenticateHttpApiKey.ts"; import { generateAPIKey } from "../../../utils/generateAPIKey"; import { hashAPIKey } from "../../../utils/hashAPIKey"; @@ -139,6 +140,18 @@ export async function handleCreateApiKey( extra: { context: "create API key handler" }, }); + if ( + error instanceof StorageError && + error.type === "CONSTRAINT_VIOLATION" + ) { + builder.setError(409, { + type: "ConflictError", + message: "An API key with this name already exists", + }); + reply.code(409); + return { error: "An API key with this name already exists" }; + } + if (error instanceof AuthError) { builder.setError(401, { type: error.type, message: error.message }); reply.code(401); diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index 00e92db..8213d45 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -105,14 +105,14 @@ export async function handleOnboarding( .secret; } catch (error) { if (liveWebhookId) { - liveClient.webhooks.delete(liveWebhookId).catch((e) => + await liveClient.webhooks.delete(liveWebhookId).catch((e) => Sentry.captureException(e, { extra: { context: "rollback: failed to delete live webhook" }, }) ); } if (testWebhookId) { - testClient.webhooks.delete(testWebhookId).catch((e) => + await testClient.webhooks.delete(testWebhookId).catch((e) => Sentry.captureException(e, { extra: { context: "rollback: failed to delete test webhook" }, }) @@ -164,7 +164,7 @@ export async function handleOnboarding( }); } catch (txnError) { if (liveWebhookId) { - liveClient.webhooks.delete(liveWebhookId).catch((e) => + await liveClient.webhooks.delete(liveWebhookId).catch((e) => Sentry.captureException(e, { extra: { context: @@ -174,7 +174,7 @@ export async function handleOnboarding( ); } if (testWebhookId) { - testClient.webhooks.delete(testWebhookId).catch((e) => + await testClient.webhooks.delete(testWebhookId).catch((e) => Sentry.captureException(e, { extra: { context: @@ -300,16 +300,20 @@ export async function handleGetConfig( reply.code(200); return { configured: true, - dodo_live_api_key: maskApiKey(decrypt(metadata.dodo_live_api_key)), - dodo_test_api_key: maskApiKey(decrypt(metadata.dodo_test_api_key)), + dodo_live_api_key: metadata.dodo_live_api_key + ? maskApiKey(decrypt(metadata.dodo_live_api_key)) + : null, + dodo_test_api_key: metadata.dodo_test_api_key + ? maskApiKey(decrypt(metadata.dodo_test_api_key)) + : null, dodo_live_product_id: metadata.dodo_live_product_id, dodo_test_product_id: metadata.dodo_test_product_id, - dodo_live_webhook_secret: maskApiKey( - decrypt(metadata.dodo_live_webhook_secret) - ), - dodo_test_webhook_secret: maskApiKey( - decrypt(metadata.dodo_test_webhook_secret) - ), + dodo_live_webhook_secret: metadata.dodo_live_webhook_secret + ? maskApiKey(decrypt(metadata.dodo_live_webhook_secret)) + : null, + dodo_test_webhook_secret: metadata.dodo_test_webhook_secret + ? maskApiKey(decrypt(metadata.dodo_test_webhook_secret)) + : null, currency: metadata.currency, redirect_url: metadata.redirect_url, }; diff --git a/src/routes/http/api/projects.ts b/src/routes/http/api/projects.ts index e9bc90c..88112ae 100644 --- a/src/routes/http/api/projects.ts +++ b/src/routes/http/api/projects.ts @@ -243,15 +243,20 @@ export async function handleUpdateProject( } await executeInTransaction(db, "update project", async (txn) => { - let rowsAffected = 0; + const exists = await txn + .select({ id: projectsTable.id }) + .from(projectsTable) + .where(eq(projectsTable.id, projectId)) + .limit(1); + if (exists.length === 0) { + throw new Error("PROJECT_NOT_FOUND"); + } if (body.name !== undefined) { - const updated = await txn + await txn .update(projectsTable) .set({ name: body.name }) - .where(eq(projectsTable.id, projectId)) - .returning({ id: projectsTable.id }); - rowsAffected += updated.length; + .where(eq(projectsTable.id, projectId)); } if (Object.keys(metaUpdates).length > 0) { @@ -260,21 +265,12 @@ export async function handleUpdateProject( .set(metaUpdates) .where(eq(metadataTable.projectId, projectId)) .returning({ projectId: metadataTable.projectId }); - rowsAffected += updated.length; - } - if (rowsAffected === 0) { - if (body.name !== undefined || Object.keys(metaUpdates).length > 0) { - throw new Error("PROJECT_NOT_FOUND"); - } else { - const exists = await txn - .select({ id: projectsTable.id }) - .from(projectsTable) - .where(eq(projectsTable.id, projectId)) - .limit(1); - if (exists.length === 0) { - throw new Error("PROJECT_NOT_FOUND"); - } + if (updated.length === 0) { + await txn.insert(metadataTable).values({ + projectId, + ...metaUpdates, + }); } } }); diff --git a/src/storage/adapter/clickhouse/schema.ts b/src/storage/adapter/clickhouse/schema.ts index 94eff8e..691ebce 100644 --- a/src/storage/adapter/clickhouse/schema.ts +++ b/src/storage/adapter/clickhouse/schema.ts @@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS basic_usage_events ( debit_amount Int64, metadata JSON ) ENGINE = ReplacingMergeTree() -ORDER BY (project_id, idempotency_key, user_id, event_id) +ORDER BY (project_id, idempotency_key, user_id) `; const AI_TOKEN_USAGE_EVENTS_TABLE = ` @@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS ai_token_usage_events ( metrics String, metadata JSON ) ENGINE = ReplacingMergeTree() -ORDER BY (project_id, idempotency_key, user_id, event_id) +ORDER BY (project_id, idempotency_key, user_id) `; export async function runClickHouseMigrations(): Promise { diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index b726540..33bd7f0 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -379,14 +379,14 @@ export const metadataTable = pgTable( withTimezone: true, mode: "string", }), - dodo_live_api_key: text("dodo_live_api_key").notNull(), - dodo_test_api_key: text("dodo_test_api_key").notNull(), - dodo_live_product_id: text("dodo_live_product_id").notNull(), - dodo_test_product_id: text("dodo_test_product_id").notNull(), - dodo_live_webhook_secret: text("dodo_live_webhook_secret").notNull(), - dodo_test_webhook_secret: text("dodo_test_webhook_secret").notNull(), + dodo_live_api_key: text("dodo_live_api_key"), + dodo_test_api_key: text("dodo_test_api_key"), + dodo_live_product_id: text("dodo_live_product_id"), + dodo_test_product_id: text("dodo_test_product_id"), + dodo_live_webhook_secret: text("dodo_live_webhook_secret"), + dodo_test_webhook_secret: text("dodo_test_webhook_secret"), currency: text("currency").notNull().default("usd"), - redirect_url: text("redirect_url").notNull(), + redirect_url: text("redirect_url"), }, (table) => ({ uniqueProjectId: uniqueIndex("unique_project_id").on(table.projectId), From 4951286b7dc91167e9022f348d75284741c69047 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Mon, 29 Jun 2026 10:23:13 +0530 Subject: [PATCH 33/33] fix(projects): fixed multiple greptile issues --- src/routes/http/createdCheckout.ts | 26 ++++++++++----------- src/storage/db/postgres/helpers/sessions.ts | 10 +++++++- src/storage/db/postgres/schema.ts | 7 ++++-- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/routes/http/createdCheckout.ts b/src/routes/http/createdCheckout.ts index e818b91..afa81f0 100644 --- a/src/routes/http/createdCheckout.ts +++ b/src/routes/http/createdCheckout.ts @@ -124,7 +124,10 @@ export async function handleDodoWebhook( ); } - const session = await getSessionByCheckoutId(checkout_session_id); + const session = await getSessionByCheckoutId( + projectId, + checkout_session_id + ); if (!session) { return errorResponse( @@ -135,15 +138,6 @@ export async function handleDodoWebhook( ); } - if (session.projectId !== projectId) { - return errorResponse( - 404, - "NotFoundError", - `Session not found for checkout_session_id: ${checkout_session_id}`, - builder - ); - } - if (session.processed !== "pending") { Sentry.captureMessage( `Webhook received for session ${checkout_session_id} with non-pending status: ${session.processed}`, @@ -157,7 +151,12 @@ export async function handleDodoWebhook( if (webhookPayload.type === "payment.failed") { let claimed: boolean = false; await executeInTransaction(db, "process failed", async (txn) => { - claimed = await updateSessionStatus(checkout_session_id, "failed", txn); + claimed = await updateSessionStatus( + projectId, + checkout_session_id, + "failed", + txn + ); if (!claimed) return; }); if (!claimed) { @@ -169,7 +168,7 @@ export async function handleDodoWebhook( } builder.setSuccess(200); - forwardWebhook(session.projectId, session.apiKeyId, { + await forwardWebhook(session.projectId, session.apiKeyId, { eventType: "payment.failed", resource: "payment", action: "failed", @@ -196,6 +195,7 @@ export async function handleDodoWebhook( await executeInTransaction(db, "process checkout", async (txn) => { claimed = await updateSessionStatus( + projectId, checkout_session_id, "succeeded", txn @@ -229,7 +229,7 @@ export async function handleDodoWebhook( builder.setPaymentContext({ creditAmount }); builder.setSuccess(200); - forwardWebhook(session.projectId, apiKeyId, { + await forwardWebhook(session.projectId, apiKeyId, { eventType: "payment.succeeded", resource: "payment", action: "succeeded", diff --git a/src/storage/db/postgres/helpers/sessions.ts b/src/storage/db/postgres/helpers/sessions.ts index 8598390..3c6cec9 100644 --- a/src/storage/db/postgres/helpers/sessions.ts +++ b/src/storage/db/postgres/helpers/sessions.ts @@ -7,6 +7,7 @@ import type { UserId } from "../../../../config/identifiers"; import type { PgTransaction } from "drizzle-orm/pg-core"; export async function updateSessionStatus( + projectId: string, checkoutSessionId: string, status: "failed" | "succeeded", txn: PgTransaction @@ -17,6 +18,7 @@ export async function updateSessionStatus( .set({ processed: status }) .where( and( + eq(sessionsTable.projectId, projectId), eq(sessionsTable.sessionId, checkoutSessionId), eq(sessionsTable.processed, "pending") ) @@ -139,6 +141,7 @@ export async function handleAddSession( } export async function getSessionByCheckoutId( + projectId: string, checkoutSessionId: string ): Promise { const db = getPostgresDB(); @@ -147,7 +150,12 @@ export async function getSessionByCheckoutId( const [session] = await db .select() .from(sessionsTable) - .where(eq(sessionsTable.sessionId, checkoutSessionId)) + .where( + and( + eq(sessionsTable.projectId, projectId), + eq(sessionsTable.sessionId, checkoutSessionId) + ) + ) .limit(1); return session ?? undefined; diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index 33bd7f0..ecf1111 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -69,7 +69,7 @@ export const sessionsTable = pgTable( projectId: uuid("project_id") .references(() => projectsTable.id) .notNull(), - sessionId: text("session_id").notNull().unique(), + sessionId: text("session_id").notNull(), processed: text("processed", { enum: ["pending", "failed", "succeeded"] }) .default("pending") .notNull(), @@ -93,7 +93,10 @@ export const sessionsTable = pgTable( .default("production"), }, (table) => ({ - uniqueSessionId: uniqueIndex("unique_session_id").on(table.sessionId), + uniqueSessionId: uniqueIndex("unique_session_id").on( + table.projectId, + table.sessionId + ), userFk: foreignKey({ columns: [table.projectId, table.userId], foreignColumns: [usersTable.projectId, usersTable.id],