From 65f5c49de9ce9aa71de0d8c8aa8d8957a67d00a2 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Sat, 13 Jun 2026 02:23:37 +0530 Subject: [PATCH 01/10] feat(schema.ts): added project_id in schema and functions --- src/context/auth.ts | 1 + src/errors/auth.ts | 9 +++ src/interceptors/auth.ts | 8 ++ src/routes/gRPC/auth/createAPIKey.ts | 1 + src/routes/gRPC/payment/createCheckoutLink.ts | 4 +- src/routes/http/api/apiKeys.ts | 4 +- src/routes/http/api/expressions.ts | 4 +- src/routes/http/api/handleProjects.ts | 68 ++++++++++++++++ src/routes/http/api/onboarding.ts | 4 +- src/routes/http/api/registerApiRoutes.ts | 10 ++- src/routes/http/api/tags.ts | 4 +- src/routes/http/api/webhookEndpoints.ts | 3 +- src/routes/http/createdCheckout.ts | 3 +- src/routes/http/forwardWebhook.ts | 20 +++-- .../clickhouse/handlers/addAiTokenUsage.ts | 2 +- .../clickhouse/handlers/addBasicUsage.ts | 2 +- .../handlers/priceRequestAiTokenUsage.ts | 4 +- .../handlers/priceRequestBasicUsage.ts | 4 +- src/storage/adapter/clickhouse/utils.ts | 1 + .../postgres/handlers/addAiTokenUsage.ts | 3 +- .../postgres/handlers/addBasicUsage.ts | 7 +- .../adapter/postgres/handlers/priceRequest.ts | 7 +- src/storage/db/postgres/helpers/apiKeys.ts | 4 + .../db/postgres/helpers/expressions.ts | 5 +- src/storage/db/postgres/helpers/metadata.ts | 3 + src/storage/db/postgres/helpers/payments.ts | 2 + src/storage/db/postgres/helpers/projects.ts | 46 +++++++++++ src/storage/db/postgres/helpers/sessions.ts | 2 + src/storage/db/postgres/helpers/tags.ts | 8 +- src/storage/db/postgres/helpers/users.ts | 3 +- .../db/postgres/helpers/webhookEndpoints.ts | 4 +- src/storage/db/postgres/schema.ts | 80 ++++++++++++++++++- src/utils/apiKeyCache.ts | 1 + src/utils/authenticateHttpApiKey.ts | 19 ++++- src/zod/internals.ts | 1 + 35 files changed, 317 insertions(+), 34 deletions(-) create mode 100644 src/routes/http/api/handleProjects.ts create mode 100644 src/storage/db/postgres/helpers/projects.ts diff --git a/src/context/auth.ts b/src/context/auth.ts index 8bfe9da..e63ee5f 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; + project_id: string; } diff --git a/src/errors/auth.ts b/src/errors/auth.ts index c5ffd86..2718e42 100644 --- a/src/errors/auth.ts +++ b/src/errors/auth.ts @@ -8,6 +8,7 @@ enum AuthErrorType { REVOKED_API_KEY = "REVOKED_API_KEY", ROLE_MISMATCH = "ROLE_MISMATCH", PERMISSION_DENIED = "PERMISSION_DENIED", + PROJECT_NOT_FOUND = "PROJECT_NOT_FOUND", } export interface AuthErrorContext { @@ -70,6 +71,14 @@ export class AuthError extends Error { }); } + static projectNotFound(): AuthError { + return new AuthError({ + type: AuthErrorType.PROJECT_NOT_FOUND, + message: "Project not found", + code: Status.NOT_FOUND, + }); + } + static roleMismatch(details?: string): AuthError { return new AuthError({ type: AuthErrorType.ROLE_MISMATCH, diff --git a/src/interceptors/auth.ts b/src/interceptors/auth.ts index 076b955..f5ea138 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, + project_id: cached.project_id, }; wideEventBuilder?.setAuth(cached.id, true); @@ -214,6 +215,10 @@ export function authInterceptor( ); } + if (!apiKeyRecord.project_id || apiKeyRecord.project_id === "") { + return callback?.(AuthError.projectNotFound()); + } + const recordMode = getModeForRole(apiKeyRecord.role as ApiKeyRole); apiKeyCache.set(apiKeyHash, { @@ -221,12 +226,14 @@ export function authInterceptor( role: apiKeyRecord.role as ApiKeyRole, mode: recordMode, expiresAt: apiKeyRecord.expiresAt, + project_id: apiKeyRecord.project_id, }); call[apiKeyContextKey] = { apiKeyId: apiKeyRecord.id, role: apiKeyRecord.role as ApiKeyRole, mode: recordMode, + project_id: apiKeyRecord.project_id, }; wideEventBuilder?.setAuth(apiKeyRecord.id, false); @@ -270,6 +277,7 @@ async function lookupApiKey(apiKeyHash: string) { role: apiKeysTable.role, expiresAt: apiKeysTable.expiresAt, revoked: apiKeysTable.revoked, + project_id: apiKeysTable.project_id, }) .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..1535c27 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(), + project_id: auth.project_id, }); if (!keyEventData) { diff --git a/src/routes/gRPC/payment/createCheckoutLink.ts b/src/routes/gRPC/payment/createCheckoutLink.ts index 097573b..1939fa7 100644 --- a/src/routes/gRPC/payment/createCheckoutLink.ts +++ b/src/routes/gRPC/payment/createCheckoutLink.ts @@ -63,6 +63,7 @@ export async function createCheckoutLink( } const mode = auth.mode; + const project_id = auth.project_id; const config = await getPaymentProviderConfig(mode); const validatedData = validateRequest(req); @@ -92,7 +93,7 @@ export async function createCheckoutLink( db, "create checkout link", async (txn) => { - await ensureUserExists(validatedData.userId, txn); + await ensureUserExists(validatedData.userId, project_id, txn); await txn .select({ id: usersTable.id }) @@ -118,6 +119,7 @@ export async function createCheckoutLink( auth.apiKeyId, mode, checkoutResult.checkoutUrl, + project_id, txn ); wideEventBuilder?.setPaymentContext({ sessionId: sessionResult.id }); diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 2a18fd5..4de4ff9 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -62,6 +62,7 @@ export async function handleCreateApiKey( key: apiKeyHash, role: validated.role, expiresAt: expiresAt.toISO(), + project_id: auth.project_id, }); const keyPair = generateWebhookKeyPair(); @@ -69,7 +70,8 @@ export async function handleCreateApiKey( keyRecord.id, validated.webhookUrl, keyPair.privateKeyPem, - keyPair.publicKeyPrefixed + keyPair.publicKeyPrefixed, + auth.project_id ); invalidateWebhookEndpointCache(keyRecord.id); diff --git a/src/routes/http/api/expressions.ts b/src/routes/http/api/expressions.ts index 59e63d9..401da84 100644 --- a/src/routes/http/api/expressions.ts +++ b/src/routes/http/api/expressions.ts @@ -84,7 +84,7 @@ export async function handleCreateExpression( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const { project_id } = await authenticateHttpApiKey(authHeader); const body = await request.body; const validated = createExpressionSchema.parse(body); @@ -92,7 +92,7 @@ export async function handleCreateExpression( validateExprSyntax(validated.expr); await resolveExprRefsInExpression(validated.expr); - await createExpression(validated.key, validated.expr); + await createExpression(validated.key, validated.expr, project_id); builder.setSuccess(200); reply.code(200); diff --git a/src/routes/http/api/handleProjects.ts b/src/routes/http/api/handleProjects.ts new file mode 100644 index 0000000..5e74c24 --- /dev/null +++ b/src/routes/http/api/handleProjects.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import * as Sentry from "@sentry/bun"; +import { ZodError } from "zod"; +import type { FastifyRequest, FastifyReply } from "fastify"; +import { + createWideEventBuilder, + generateRequestId, +} from "../../../context/requestContext"; +import { authenticateHttpApiKey } from "../../../utils/authenticateHttpApiKey"; +import { createProject } from "./../../../storage/db/postgres/helpers/projects"; +import { AuthError } from "../../../errors/auth"; +import { logger } from "../../../errors/logger"; + +const checkProject = z.object({ + project_id: z.string().min(1, "Project ID is required").max(128), + product_id: z.string().min(1, "Product ID is required").max(128), +}); + +export async function handleCreateProject( + request: FastifyRequest, + reply: FastifyReply +) { + const builder = createWideEventBuilder( + generateRequestId(), + request.method, + request.url + ); + + try { + const authHeader = request.headers.authorization; + const { project_id } = await authenticateHttpApiKey(authHeader); + + const body = await request.body; + const validated = checkProject.parse(body); + + await createProject(validated.project_id, validated.product_id); + + builder.setSuccess(200); + reply.code(200); + return { message: `Project '${project_id}' saved` }; + } catch (error) { + Sentry.captureException(error, { + extra: { context: "create project route handler" }, + }); + + 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" }; + } finally { + logger.emit(builder.build()); + } +} diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index f3d5721..3ec634a 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -8,7 +8,7 @@ import { generateRequestId, } from "../../../context/requestContext.ts"; import { logger } from "../../../errors/logger.ts"; -import { AuthError } from "../../../errors/auth"; +import { AuthError } from "../../../errors/auth.ts"; import { authenticateHttpApiKey } from "../../../utils/authenticateHttpApiKey.ts"; import { upsertMetadata, @@ -93,6 +93,7 @@ export async function handleOnboarding( dodo_test_webhook_secret: encrypt(testSecret), currency: validated.currency, redirect_url: validated.redirectUrl, + project_id: validated.project_id, }); clearClients(); @@ -183,6 +184,7 @@ export async function handleGetConfig( ), currency: metadata.currency, redirect_url: metadata.redirect_url, + project_id: metadata.project_id, }; } catch (error) { Sentry.captureException(error, { diff --git a/src/routes/http/api/registerApiRoutes.ts b/src/routes/http/api/registerApiRoutes.ts index e17b649..3af61b0 100644 --- a/src/routes/http/api/registerApiRoutes.ts +++ b/src/routes/http/api/registerApiRoutes.ts @@ -1,5 +1,5 @@ import type { FastifyRequest, FastifyReply } from "fastify"; -import { handleOnboarding, handleGetConfig } from "./onboarding.ts"; +import { handleOnboarding, handleGetConfig } from "./onBoarding.ts"; import { handleListTags, handleCreateTag, handleDeleteTag } from "./tags.ts"; import { handleListExpressions, @@ -19,6 +19,7 @@ import { handleRevokeApiKey, } from "./apiKeys.ts"; import { handleListDeliveries } from "./webhookDeliveries.ts"; +import { handleCreateProject } from "./project.ts"; export async function registerApiRoutes( server: ReturnType<(typeof import("fastify"))["fastify"]> @@ -38,6 +39,13 @@ export async function registerApiRoutes( } ); + server.post( + "/api/v1/internals/projects", + async (request: FastifyRequest, reply: FastifyReply) => { + return handleCreateProject(request, reply); + } + ); + // Tags server.get( "/api/v1/tags", diff --git a/src/routes/http/api/tags.ts b/src/routes/http/api/tags.ts index 6be7c28..056e9fb 100644 --- a/src/routes/http/api/tags.ts +++ b/src/routes/http/api/tags.ts @@ -86,12 +86,12 @@ export async function handleCreateTag( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const { project_id } = await authenticateHttpApiKey(authHeader); const body = await request.body; const validated = createTagSchema.parse(body); - await createTag(validated.key, validated.amount); + await createTag(validated.key, validated.amount, project_id); builder.setSuccess(200); reply.code(200); diff --git a/src/routes/http/api/webhookEndpoints.ts b/src/routes/http/api/webhookEndpoints.ts index a02547b..2ab0fc5 100644 --- a/src/routes/http/api/webhookEndpoints.ts +++ b/src/routes/http/api/webhookEndpoints.ts @@ -133,7 +133,8 @@ export async function handleCreateWebhookEndpoint( targetApiKeyId, validated.url, keyPair.privateKeyPem, - keyPair.publicKeyPrefixed + keyPair.publicKeyPrefixed, + auth.project_id ); invalidateWebhookEndpointCache(targetApiKeyId); diff --git a/src/routes/http/createdCheckout.ts b/src/routes/http/createdCheckout.ts index 878b648..ac6b93b 100644 --- a/src/routes/http/createdCheckout.ts +++ b/src/routes/http/createdCheckout.ts @@ -180,7 +180,7 @@ export async function handleDodoWebhook( if (webhookPayload.type === "payment.succeeded") { const creditAmount = Math.round(webhookPayload.data.total_amount); - const { userId, billed_upto, apiKeyId, mode } = session; + const { userId, billed_upto, apiKeyId, mode, project_id } = session; let claimed: boolean = false; await executeInTransaction(db, "process checkout", async (txn) => { @@ -197,6 +197,7 @@ export async function handleDodoWebhook( apiKeyId, mode, session.proxy_link_id, + project_id, txn ); }); diff --git a/src/routes/http/forwardWebhook.ts b/src/routes/http/forwardWebhook.ts index d27b4b2..b7cbccc 100644 --- a/src/routes/http/forwardWebhook.ts +++ b/src/routes/http/forwardWebhook.ts @@ -71,9 +71,16 @@ export async function forwardWebhook( Sentry.captureException(error, { extra: { context: "webhook signing failed", error: errorMsg }, }); - await recordDelivery(endpoint.id, webhookId, event, "failed", { - error: errorMsg, - }); + await recordDelivery( + endpoint.id, + webhookId, + event, + "failed", + { + error: errorMsg, + }, + endpoint.project_id + ); return; } @@ -124,7 +131,8 @@ export async function forwardWebhook( { responseStatus, error: errorMessage, - } + }, + endpoint.project_id ); } @@ -136,7 +144,8 @@ async function recordDelivery( details: { responseStatus?: number | null; error?: string | null; - } + }, + project_id: string ): Promise { try { const db = getPostgresDB(); @@ -150,6 +159,7 @@ async function recordDelivery( requestBody: (event.data ?? {}) as Record, responseStatus: details.responseStatus ?? 0, error: details.error ?? "", + project_id, }); } catch (error) { Sentry.captureException(error, { diff --git a/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts b/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts index 052bde7..5279336 100644 --- a/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts +++ b/src/storage/adapter/clickhouse/handlers/addAiTokenUsage.ts @@ -166,7 +166,7 @@ export async function handleAddAiTokenUsage( const firstEvent = events[0]; if (firstEvent) { - await ensureUserExists(firstEvent.userId); + await ensureUserExists(firstEvent.userId, auth.project_id); } const aggregatedEvents = aggregateAiTokenEvents(events); diff --git a/src/storage/adapter/clickhouse/handlers/addBasicUsage.ts b/src/storage/adapter/clickhouse/handlers/addBasicUsage.ts index 55a474d..ce9b97b 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(event_data.userId, auth.project_id); const id = crypto.randomUUID(); diff --git a/src/storage/adapter/clickhouse/handlers/priceRequestAiTokenUsage.ts b/src/storage/adapter/clickhouse/handlers/priceRequestAiTokenUsage.ts index 13310f9..660362f 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 user_id = {userId:String} AND mode = {mode:String} AND project_id = {projectId: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 project_id = {projectId: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..39496c6 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 user_id = {userId:String} AND mode = {mode:String} AND project_id = {projectId: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 user_id = {userId:String} AND mode = {mode:String} AND project_id = {projectId: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/utils.ts b/src/storage/adapter/clickhouse/utils.ts index 2858ba9..b19c7ec 100644 --- a/src/storage/adapter/clickhouse/utils.ts +++ b/src/storage/adapter/clickhouse/utils.ts @@ -71,6 +71,7 @@ export async function runClickHousePriceQuery( } params.mode = auth.mode; + params.projectId = auth.project_id; const rs = await chClient.query({ query, diff --git a/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts b/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts index 09a8bb1..5861a1f 100644 --- a/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts +++ b/src/storage/adapter/postgres/handlers/addAiTokenUsage.ts @@ -141,6 +141,7 @@ function buildAiTokenInsertValues( }, } satisfies Metrics), metadata: aggEvent.metadata ?? {}, + project_id: auth.project_id, })); } @@ -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(firstEvent.userId, auth.project_id, txn); } try { diff --git a/src/storage/adapter/postgres/handlers/addBasicUsage.ts b/src/storage/adapter/postgres/handlers/addBasicUsage.ts index 127b4c0..dd2bd62 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( + event_data.userId, + auth.project_id, + txn + ); const reportedTimestamp = await validateAndPrepareTimestamp( event_data.reported_timestamp @@ -48,6 +52,7 @@ export async function handleAddBasicUsage( type: event_data.data.basicUsageType, debitAmount: event_data.data.debitAmount, metadata: event_data.data.metadata ?? {}, + project_id: auth.project_id, }) .returning({ id: basicUsageEventsTable.id }); diff --git a/src/storage/adapter/postgres/handlers/priceRequest.ts b/src/storage/adapter/postgres/handlers/priceRequest.ts index 2cde117..36c2a5d 100644 --- a/src/storage/adapter/postgres/handlers/priceRequest.ts +++ b/src/storage/adapter/postgres/handlers/priceRequest.ts @@ -37,7 +37,12 @@ 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 = and( + sql`${priceTable.reportedTimestamp} > ${usersTable.last_billed_timestamp}`, + eq(priceTable.userId, userId), + eq(priceTable.mode, auth.mode as "test" | "production"), + eq(priceTable.project_id, auth.project_id) + ); const whereClause = beforeTimestamp ? and( baseCondition, diff --git a/src/storage/db/postgres/helpers/apiKeys.ts b/src/storage/db/postgres/helpers/apiKeys.ts index 5b36c3b..6cae4ef 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; + project_id: 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, + project_id: input.project_id, }) .returning({ id: apiKeysTable.id }); @@ -74,6 +76,7 @@ type ApiKeyRecord = { role: string; expiresAt: string; revoked: boolean; + project_id: string; }; export async function getApiKeyRoleById( @@ -109,6 +112,7 @@ export async function findApiKeyByHash( role: apiKeysTable.role, expiresAt: apiKeysTable.expiresAt, revoked: apiKeysTable.revoked, + project_id: apiKeysTable.project_id, }) .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..c7d5432 100644 --- a/src/storage/db/postgres/helpers/expressions.ts +++ b/src/storage/db/postgres/helpers/expressions.ts @@ -44,7 +44,8 @@ export async function findExpressionByKey(key: string): Promise { export async function createExpression( key: string, - expr: string + expr: string, + project_id: string ): Promise { const db = getPostgresDB(); @@ -65,7 +66,7 @@ export async function createExpression( return; } - await db.insert(expressionsTable).values({ key, expr }); + await db.insert(expressionsTable).values({ key, expr, project_id }); } catch (e) { throw StorageError.insertFailed( `Failed to upsert expression '${key}'`, diff --git a/src/storage/db/postgres/helpers/metadata.ts b/src/storage/db/postgres/helpers/metadata.ts index 4d83430..cdc30f7 100644 --- a/src/storage/db/postgres/helpers/metadata.ts +++ b/src/storage/db/postgres/helpers/metadata.ts @@ -13,6 +13,7 @@ export type UpsertMetadataInput = { dodo_test_webhook_secret?: string; currency?: string; redirect_url?: string; + project_id?: string; }; export async function upsertMetadata( @@ -44,6 +45,8 @@ export async function upsertMetadata( if (input.currency !== undefined) setValues.currency = input.currency; if (input.redirect_url !== undefined) setValues.redirect_url = input.redirect_url; + if (input.project_id !== undefined) + setValues.project_id = input.project_id; if (existingMetadata) { if (Object.keys(setValues).length > 0) { diff --git a/src/storage/db/postgres/helpers/payments.ts b/src/storage/db/postgres/helpers/payments.ts index 9af0688..f8117e4 100644 --- a/src/storage/db/postgres/helpers/payments.ts +++ b/src/storage/db/postgres/helpers/payments.ts @@ -10,6 +10,7 @@ export async function handleAddPayment( apiKeyId: string, mode: "test" | "production", proxyId: string, + project_id: string, txn?: PgTransaction ): Promise<{ id: string }> { if ( @@ -36,6 +37,7 @@ export async function handleAddPayment( mode, creditAmount, proxyId, + project_id, }) .returning({ id: paymentEventsTable.id }); diff --git a/src/storage/db/postgres/helpers/projects.ts b/src/storage/db/postgres/helpers/projects.ts new file mode 100644 index 0000000..ab1777d --- /dev/null +++ b/src/storage/db/postgres/helpers/projects.ts @@ -0,0 +1,46 @@ +import { eq } from "drizzle-orm"; +import { getPostgresDB } from "../db"; +import { projectTable } from "./../schema"; +import { StorageError } from "../../../../errors/storage"; + +export async function createProject( + project_id: string, + product_id: string +): Promise { + const db = getPostgresDB(); + + try { + const existing = await db + .select({ project_id: projectTable.project_id }) + .from(projectTable) + .where(eq(projectTable.project_id, project_id)) + .limit(1); + + if (existing[0]) { + await db + .update(projectTable) + .set({ product_id }) + .where(eq(projectTable.project_id, existing[0].project_id)); + return; + } + + await db.insert(projectTable).values({ project_id, product_id }); + } catch (e) { + throw StorageError.insertFailed( + `Failed to create project '${project_id}'`, + e instanceof Error ? e : new Error(String(e)) + ); + } +} + +export async function getProject( + project_id: string +): Promise { + const db = getPostgresDB(); + const [row] = await db + .select() + .from(projectTable) + .where(eq(projectTable.project_id, project_id)) + .limit(1); + return row; +} diff --git a/src/storage/db/postgres/helpers/sessions.ts b/src/storage/db/postgres/helpers/sessions.ts index 4e3cf69..a2fe38e 100644 --- a/src/storage/db/postgres/helpers/sessions.ts +++ b/src/storage/db/postgres/helpers/sessions.ts @@ -74,6 +74,7 @@ export async function handleAddSession( apiKeyId: string, mode: "test" | "production", checkoutUrl: string, + project_id: string, txn?: PgTransaction ): Promise<{ id: string }> { const connectionObject = txn ?? getPostgresDB(); @@ -97,6 +98,7 @@ export async function handleAddSession( apiKeyId: apiKeyId, mode: mode, checkoutUrl: checkoutUrl, + project_id: project_id, }) .returning({ proxy_link_id: sessionsTable.proxy_link_id }); diff --git a/src/storage/db/postgres/helpers/tags.ts b/src/storage/db/postgres/helpers/tags.ts index 9056f0f..4191697 100644 --- a/src/storage/db/postgres/helpers/tags.ts +++ b/src/storage/db/postgres/helpers/tags.ts @@ -22,7 +22,11 @@ export async function listTags(): Promise<{ key: string; amount: number }[]> { } } -export async function createTag(key: string, amount: number): Promise { +export async function createTag( + key: string, + amount: number, + project_id: string +): Promise { const db = getPostgresDB(); try { @@ -41,7 +45,7 @@ export async function createTag(key: string, amount: number): Promise { return; } - await db.insert(tagsTable).values({ key, amount }); + await db.insert(tagsTable).values({ key, amount, project_id }); tagCache.delete(key); } catch (e) { throw StorageError.insertFailed( diff --git a/src/storage/db/postgres/helpers/users.ts b/src/storage/db/postgres/helpers/users.ts index eab1db6..3ce705d 100644 --- a/src/storage/db/postgres/helpers/users.ts +++ b/src/storage/db/postgres/helpers/users.ts @@ -36,6 +36,7 @@ export async function userExists(userId: string): Promise { export async function ensureUserExists( userId: string, + project_id: string, txn?: PgTransaction ): Promise { const db = txn ?? getPostgresDB(); @@ -43,7 +44,7 @@ export async function ensureUserExists( try { await db .insert(usersTable) - .values({ id: userId }) + .values({ id: userId, project_id: project_id }) .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..9dbcf7c 100644 --- a/src/storage/db/postgres/helpers/webhookEndpoints.ts +++ b/src/storage/db/postgres/helpers/webhookEndpoints.ts @@ -36,7 +36,8 @@ export async function upsertWebhookEndpoint( apiKeyId: string, url: string, privateKey: string, - publicKey: string + publicKey: string, + project_id: string ): Promise { const db = getPostgresDB(); @@ -52,6 +53,7 @@ export async function upsertWebhookEndpoint( publicKey, createdAt: now, updatedAt: now, + project_id, }) .onConflictDoUpdate({ target: webhookEndpointsTable.apiKeyId, diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index c266d0a..00e5485 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -23,16 +23,23 @@ export const usersTable = pgTable("users", { .default(DateTime.utc(1).toString()) .notNull(), payment_provider_user_id: text("payment_provider_user_id"), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), mode: text("mode", { enum: ["test", "production"] }) .notNull() .default("production"), }); -export const usersRelation = relations(usersTable, ({ many }) => ({ +export const usersRelation = relations(usersTable, ({ many, one }) => ({ sessions: many(sessionsTable), basicUsageEvents: many(basicUsageEventsTable), paymentEvents: many(paymentEventsTable), aiTokenUsageEvents: many(aiTokenUsageEventsTable), + project_id: one(projectTable, { + fields: [usersTable.project_id], + references: [projectTable.project_id], + }), })); export const sessionsTable = pgTable( @@ -60,6 +67,9 @@ export const sessionsTable = pgTable( }) .defaultNow() .notNull(), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), mode: text("mode", { enum: ["test", "production"] }) .notNull() .default("production"), @@ -79,6 +89,10 @@ export const sessionRelations = relations(sessionsTable, ({ one, many }) => ({ references: [apiKeysTable.id], }), paymentEvents: many(paymentEventsTable), + project_id: one(projectTable, { + fields: [sessionsTable.project_id], + references: [projectTable.project_id], + }), })); export const apiKeysTable = pgTable( @@ -100,6 +114,9 @@ export const apiKeysTable = pgTable( withTimezone: true, mode: "string", }).notNull(), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), revoked: boolean("revoked").default(false).notNull(), revokedAt: timestamp("revoked_at", { withTimezone: true, @@ -113,11 +130,15 @@ export const apiKeysTable = pgTable( }) ); -export const apiKeysRelation = relations(apiKeysTable, ({ many }) => ({ +export const apiKeysRelation = relations(apiKeysTable, ({ many, one }) => ({ sessions: many(sessionsTable), basicUsageEvents: many(basicUsageEventsTable), paymentEvents: many(paymentEventsTable), aiTokenUsageEvents: many(aiTokenUsageEventsTable), + project_id: one(projectTable, { + fields: [apiKeysTable.project_id], + references: [projectTable.project_id], + }), })); export const basicUsageEventsTable = pgTable("basic_usage_events", { @@ -140,6 +161,9 @@ export const basicUsageEventsTable = pgTable("basic_usage_events", { apiKeyId: uuid("api_key_id") .references(() => apiKeysTable.id) .notNull(), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), mode: text("mode", { enum: ["test", "production"] }).notNull(), type: text("type", { enum: ["RAW", "MIDDLEWARE_CALL"] }).notNull(), debitAmount: bigint("debit_amount", { mode: "number" }).notNull(), @@ -157,6 +181,10 @@ export const basicUsageEventsRelation = relations( fields: [basicUsageEventsTable.apiKeyId], references: [apiKeysTable.id], }), + project_id: one(projectTable, { + fields: [basicUsageEventsTable.project_id], + references: [projectTable.project_id], + }), }) ); @@ -178,6 +206,9 @@ export const paymentEventsTable = pgTable("payment_events", { apiKeyId: uuid("api_key_id") .references(() => apiKeysTable.id) .notNull(), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), mode: text("mode", { enum: ["test", "production"] }).notNull(), creditAmount: bigint("credit_amount", { mode: "number" }).notNull(), proxyId: uuid("proxy_id") @@ -200,6 +231,10 @@ export const paymentEventsRelation = relations( fields: [paymentEventsTable.proxyId], references: [sessionsTable.proxy_link_id], }), + project_id: one(projectTable, { + fields: [paymentEventsTable.project_id], + references: [projectTable.project_id], + }), }) ); @@ -223,6 +258,9 @@ export const aiTokenUsageEventsTable = pgTable("ai_token_usage_events", { apiKeyId: uuid("api_key_id") .references(() => apiKeysTable.id) .notNull(), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), mode: text("mode", { enum: ["test", "production"] }).notNull(), model: text("model").notNull(), provider: text("provider").notNull(), @@ -241,6 +279,10 @@ export const aiTokenUsageEventsRelation = relations( fields: [aiTokenUsageEventsTable.apiKeyId], references: [apiKeysTable.id], }), + project_id: one(projectTable, { + fields: [aiTokenUsageEventsTable.project_id], + references: [projectTable.project_id], + }), }) ); @@ -252,6 +294,9 @@ export const tagsTable = pgTable("tags", { withTimezone: true, mode: "string", }), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), }); export const metadataTable = pgTable("metadata", { @@ -267,6 +312,9 @@ export const metadataTable = pgTable("metadata", { 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"), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), redirect_url: text("redirect_url").notNull(), }); @@ -274,6 +322,9 @@ export const expressionsTable = pgTable("expressions", { id: uuid("id").primaryKey().defaultRandom(), key: text("key").notNull(), expr: text("expr").notNull(), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), deletedAt: timestamp("deleted_at", { withTimezone: true, mode: "string", @@ -290,6 +341,9 @@ export const webhookEndpointsTable = pgTable( url: text("url").notNull(), privateKey: text("private_key").notNull(), publicKey: text("public_key").notNull(), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), createdAt: timestamp("created_at", { withTimezone: true, mode: "string", @@ -319,6 +373,10 @@ export const webhookEndpointsRelation = relations( fields: [webhookEndpointsTable.apiKeyId], references: [apiKeysTable.id], }), + project_id: one(projectTable, { + fields: [webhookEndpointsTable.project_id], + references: [projectTable.project_id], + }), }) ); @@ -341,6 +399,9 @@ export const webhookDeliveriesTable = pgTable("webhook_deliveries", { }) .defaultNow() .notNull(), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), }); export const webhookDeliveriesRelation = relations( @@ -350,5 +411,20 @@ export const webhookDeliveriesRelation = relations( fields: [webhookDeliveriesTable.endpointId], references: [webhookEndpointsTable.id], }), + project_id: one(projectTable, { + fields: [webhookDeliveriesTable.project_id], + references: [projectTable.project_id], + }), }) ); + +export const projectTable = pgTable("projects", { + project_id: uuid("project_id").primaryKey().defaultRandom(), + product_id: text("product_id").notNull(), + createdAt: timestamp("created_at", { + withTimezone: true, + mode: "string", + }) + .defaultNow() + .notNull(), +}); diff --git a/src/utils/apiKeyCache.ts b/src/utils/apiKeyCache.ts index 86f7cf9..41f81f7 100644 --- a/src/utils/apiKeyCache.ts +++ b/src/utils/apiKeyCache.ts @@ -7,6 +7,7 @@ interface CachedAPIKey { role: ApiKeyRole; mode: "production" | "test" | null; expiresAt: string; + project_id: string; } const store = Cache.getStore("api-keys", { diff --git a/src/utils/authenticateHttpApiKey.ts b/src/utils/authenticateHttpApiKey.ts index a467705..f7644c8 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, + project_id: cached.project_id, + }; } const apiKeyRecord = await findApiKeyByHash(apiKeyHash); @@ -69,6 +74,10 @@ export async function authenticateHttpApiKey( ); } + if (!apiKeyRecord.project_id || apiKeyRecord.project_id === "") { + throw AuthError.projectNotFound(); + } + const recordRole = apiKeyRecord.role as ApiKeyRole; const mode = getModeForRole(recordRole); @@ -77,7 +86,13 @@ export async function authenticateHttpApiKey( role: recordRole, mode, expiresAt: apiKeyRecord.expiresAt, + project_id: apiKeyRecord.project_id, }); - return { apiKeyId: apiKeyRecord.id, role: recordRole, mode }; + return { + apiKeyId: apiKeyRecord.id, + role: recordRole, + mode, + project_id: apiKeyRecord.project_id, + }; } diff --git a/src/zod/internals.ts b/src/zod/internals.ts index 938d40b..3cd933f 100644 --- a/src/zod/internals.ts +++ b/src/zod/internals.ts @@ -39,4 +39,5 @@ export const onboardingSchema = z.object({ dodoTestProductId: z.string().min(1, "Dodo test product ID is required"), currency: z.string().min(1, "Currency is required"), redirectUrl: z.url("Redirect URL must be a valid URL"), + project_id: z.string().min(1, "Project ID is required"), }); From 17f06ffd7d09201501d2c644d9752405bcff1205 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Sun, 14 Jun 2026 00:03:19 +0530 Subject: [PATCH 02/10] feat(handleProject.ts): multiple project implemented --- src/__tests__/auth.test.ts | 3 +- src/__tests__/createAPIKey.test.ts | 9 ++- src/__tests__/db/index.ts | 5 +- src/__tests__/fixtures/apiKey.ts | 16 +++++ src/routes/gRPC/payment/createCheckoutLink.ts | 18 ++++-- src/routes/gRPC/payment/paymentProvider.ts | 58 ++++++++++--------- src/routes/http/api/onboarding.ts | 9 ++- src/routes/http/api/registerApiRoutes.ts | 2 +- src/routes/http/createdCheckout.ts | 22 ++++++- src/storage/db/postgres/helpers/metadata.ts | 18 +++--- src/storage/db/postgres/helpers/sessions.ts | 4 +- 11 files changed, 116 insertions(+), 48 deletions(-) diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index 71819ac..32a3d95 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -15,7 +15,7 @@ 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(); @@ -24,6 +24,7 @@ async function insertWebhookEndpoint(apiKeyId: string): Promise { url: "https://example.com/webhook", privateKey: "test-private-key", publicKey: "test-public-key", + project_id: TEST_PROJECT_ID, }); } diff --git a/src/__tests__/createAPIKey.test.ts b/src/__tests__/createAPIKey.test.ts index e0a2521..6cc712e 100644 --- a/src/__tests__/createAPIKey.test.ts +++ b/src/__tests__/createAPIKey.test.ts @@ -14,7 +14,11 @@ import { registerEvent, } from "./fixtures/grpc"; import { verifyApiKeyCreated } from "./assertions/events"; -import { createTestApiKey } from "./fixtures/apiKey"; +import { + createTestApiKey, + ensureTestProject, + TEST_PROJECT_ID, +} from "./fixtures/apiKey"; import { getPostgresDB } from "../storage/db/postgres/db"; import { hashAPIKey } from "../utils/hashAPIKey"; import { @@ -35,6 +39,7 @@ async function createDashboardApiKey(): Promise<{ rawKey: string; id: string; }> { + await ensureTestProject(); const db = getPostgresDB(); const rawKey = `scrn_dash_${crypto.randomUUID().replace(/-/g, "").slice(0, 32)}`; const [key] = await db @@ -44,6 +49,7 @@ async function createDashboardApiKey(): Promise<{ key: hashAPIKey(rawKey), role: "dashboard", expiresAt: DateTime.utc().plus({ years: 1 }).toISO(), + project_id: TEST_PROJECT_ID, }) .returning({ id: apiKeysTable.id }); return { rawKey, id: key!.id }; @@ -153,6 +159,7 @@ describe("AuthService", () => { url: "https://example.com/webhook", privateKey: "test-private-key", publicKey: "test-public-key", + project_id: TEST_PROJECT_ID, }); const userId = crypto.randomUUID(); diff --git a/src/__tests__/db/index.ts b/src/__tests__/db/index.ts index ada334a..c97eed9 100644 --- a/src/__tests__/db/index.ts +++ b/src/__tests__/db/index.ts @@ -31,7 +31,10 @@ export async function clearDatabase() { users, tags, metadata, - expressions + expressions, + webhook_endpoints, + webhook_deliveries, + projects RESTART IDENTITY CASCADE `); diff --git a/src/__tests__/fixtures/apiKey.ts b/src/__tests__/fixtures/apiKey.ts index dbdb718..611fd1e 100644 --- a/src/__tests__/fixtures/apiKey.ts +++ b/src/__tests__/fixtures/apiKey.ts @@ -2,14 +2,26 @@ import { getPostgresDB } from "../../storage/db/postgres/db"; import { apiKeysTable, webhookEndpointsTable, + projectTable, } from "../../storage/db/postgres/schema"; import { hashAPIKey } from "../../utils/hashAPIKey"; import { DateTime } from "luxon"; +export const TEST_PROJECT_ID = "00000000-0000-0000-0000-000000000001"; + +export async function ensureTestProject(): Promise { + const db = getPostgresDB(); + await db + .insert(projectTable) + .values({ project_id: TEST_PROJECT_ID, product_id: "test-product" }) + .onConflictDoNothing({ target: projectTable.project_id }); +} + export async function createTestApiKey(): Promise<{ rawKey: string; id: string; }> { + await ensureTestProject(); const db = getPostgresDB(); const rawKey = `scrn_test_${crypto.randomUUID().replace(/-/g, "").slice(0, 32)}`; const [key] = await db @@ -19,6 +31,7 @@ export async function createTestApiKey(): Promise<{ key: hashAPIKey(rawKey), role: "test", expiresAt: DateTime.utc().plus({ years: 1 }).toISO(), + project_id: TEST_PROJECT_ID, }) .returning({ id: apiKeysTable.id }); @@ -27,6 +40,7 @@ export async function createTestApiKey(): Promise<{ url: "https://example.com/webhook", privateKey: "test-private-key", publicKey: "test-public-key", + project_id: TEST_PROJECT_ID, }); return { rawKey, id: key!.id }; @@ -37,6 +51,7 @@ export async function insertKey( role: "dashboard" | "test" | "production", overrides: Partial<{ revoked: boolean; expiresAt: string }> = {} ): Promise { + await ensureTestProject(); const db = getPostgresDB(); const [key] = await db .insert(apiKeysTable) @@ -47,6 +62,7 @@ export async function insertKey( expiresAt: overrides.expiresAt ?? DateTime.utc().plus({ years: 1 }).toISO(), revoked: overrides.revoked ?? false, + project_id: TEST_PROJECT_ID, }) .returning({ id: apiKeysTable.id }); return key!.id; diff --git a/src/routes/gRPC/payment/createCheckoutLink.ts b/src/routes/gRPC/payment/createCheckoutLink.ts index 1939fa7..1336250 100644 --- a/src/routes/gRPC/payment/createCheckoutLink.ts +++ b/src/routes/gRPC/payment/createCheckoutLink.ts @@ -65,7 +65,7 @@ export async function createCheckoutLink( const mode = auth.mode; const project_id = auth.project_id; - const config = await getPaymentProviderConfig(mode); + const config = await getPaymentProviderConfig(mode, project_id); const validatedData = validateRequest(req); wideEventBuilder?.setUser(validatedData.userId); @@ -86,7 +86,8 @@ export async function createCheckoutLink( validatedData.userId, auth.apiKeyId, beforeTimestamp, - mode + mode, + project_id ); const checkoutLink = await executeInTransaction( @@ -104,7 +105,8 @@ export async function createCheckoutLink( const existingId = await checkIfExistingCheckoutLink( txn, validatedData.userId, - mode + mode, + project_id ); if (existingId) { @@ -171,7 +173,8 @@ async function createCheckoutSession( userId: string, apiKeyId: string, beforeTimestamp: DateTime, - mode: "test" | "production" + mode: "test" | "production", + project_id: string ): Promise { const params: CheckoutParams = { customPrice, @@ -179,7 +182,12 @@ async function createCheckoutSession( apiKeyId, }; - const checkoutResult = await createProviderCheckout(config, params, mode); + const checkoutResult = await createProviderCheckout( + config, + params, + mode, + project_id + ); if ( !checkoutResult.checkoutUrl || diff --git a/src/routes/gRPC/payment/paymentProvider.ts b/src/routes/gRPC/payment/paymentProvider.ts index 3f938e9..2228e3a 100644 --- a/src/routes/gRPC/payment/paymentProvider.ts +++ b/src/routes/gRPC/payment/paymentProvider.ts @@ -3,56 +3,62 @@ 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; - -function clearClients(): void { - liveClient = null; - testClient = null; +const liveClients = new Map(); +const testClients = new Map(); + +function clearClients(project_id?: string): void { + if (project_id) { + liveClients.delete(project_id); + testClients.delete(project_id); + } else { + liveClients.clear(); + testClients.clear(); + } } export async function getDodoClient( - mode?: "test" | "production" + mode: "test" | "production", + project_id: string ): Promise { - if (!mode) { - mode = process.env.NODE_ENV === "production" ? "production" : "test"; - } - if (mode === "production") { - if (liveClient) return liveClient; + const cached = liveClients.get(project_id); + if (cached) return cached; - const metadata = await getMetadata(); + const metadata = await getMetadata(project_id); const apiKey = metadata?.dodo_live_api_key; if (!apiKey) { throw PaymentError.missingApiKey(); } - liveClient = new DodoPayments({ + const client = new DodoPayments({ bearerToken: decrypt(apiKey), environment: "live_mode", webhookKey: metadata?.dodo_live_webhook_secret ? decrypt(metadata.dodo_live_webhook_secret) : undefined, }); - return liveClient; + liveClients.set(project_id, client); + return client; } - if (testClient) return testClient; + const cached = testClients.get(project_id); + if (cached) return cached; - const metadata = await getMetadata(); + const metadata = await getMetadata(project_id); const apiKey = metadata?.dodo_test_api_key; if (!apiKey) { throw PaymentError.missingApiKey(); } - testClient = new DodoPayments({ + const client = new DodoPayments({ bearerToken: decrypt(apiKey), environment: "test_mode", webhookKey: metadata?.dodo_test_webhook_secret ? decrypt(metadata.dodo_test_webhook_secret) : undefined, }); - return testClient; + testClients.set(project_id, client); + return client; } // Re-export for callers who need to invalidate cached clients after onboarding updates @@ -76,13 +82,10 @@ export interface CheckoutResult { } export async function getPaymentProviderConfig( - mode: "test" | "production" + mode: "test" | "production", + project_id: string ): Promise { - if (!mode) { - mode = process.env.NODE_ENV === "production" ? "production" : "test"; - } - - const metadata = await getMetadata(); + const metadata = await getMetadata(project_id); if (!metadata) { throw PaymentError.missingMetadata(); @@ -104,9 +107,10 @@ export async function getPaymentProviderConfig( export async function createProviderCheckout( config: PaymentProviderConfig, params: CheckoutParams, - mode: "test" | "production" + mode: "test" | "production", + project_id: string ): Promise { - const client = await getDodoClient(mode); + const client = await getDodoClient(mode, project_id); const session = await client.checkoutSessions.create({ product_cart: [ diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index 3ec634a..159f375 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -16,6 +16,7 @@ import { } from "../../../storage/db/postgres/helpers/metadata.ts"; import { clearClients } from "../../gRPC/payment/paymentProvider.ts"; import { encrypt, decrypt } from "../../../utils/encryptMetadata.ts"; +import { createProject } from "../../../storage/db/postgres/helpers/projects.ts"; export async function handleOnboarding( request: FastifyRequest, @@ -44,6 +45,8 @@ export async function handleOnboarding( return {}; } + await createProject(validated.project_id, validated.dodoLiveProductId); + const liveClient = new DodoPayments({ bearerToken: validated.dodoLiveApiKey, environment: "live_mode", @@ -96,7 +99,7 @@ export async function handleOnboarding( project_id: validated.project_id, }); - clearClients(); + clearClients(validated.project_id); builder.setSuccess(200); @@ -158,9 +161,9 @@ export async function handleGetConfig( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const { project_id } = await authenticateHttpApiKey(authHeader); - const metadata = await getMetadata(); + const metadata = await getMetadata(project_id); if (!metadata) { builder.setSuccess(200); diff --git a/src/routes/http/api/registerApiRoutes.ts b/src/routes/http/api/registerApiRoutes.ts index 3af61b0..870667f 100644 --- a/src/routes/http/api/registerApiRoutes.ts +++ b/src/routes/http/api/registerApiRoutes.ts @@ -19,7 +19,7 @@ import { handleRevokeApiKey, } from "./apiKeys.ts"; import { handleListDeliveries } from "./webhookDeliveries.ts"; -import { handleCreateProject } from "./project.ts"; +import { handleCreateProject } from "./handleProjects.ts"; export async function registerApiRoutes( server: ReturnType<(typeof import("fastify"))["fastify"]> diff --git a/src/routes/http/createdCheckout.ts b/src/routes/http/createdCheckout.ts index ac6b93b..8e53ed8 100644 --- a/src/routes/http/createdCheckout.ts +++ b/src/routes/http/createdCheckout.ts @@ -75,8 +75,28 @@ export async function handleDodoWebhook( builder: WideEventBuilder ): Promise { try { + if (!webhookId) { + return errorResponse( + 400, + "ValidationError", + "Missing webhook-id header", + builder + ); + } + + const preSession = await getSessionByCheckoutId(webhookId); + if (!preSession) { + return errorResponse( + 404, + "NotFoundError", + "No session found for this webhook", + builder + ); + } + const client = await getDodoClient( - mode === "production" ? "production" : "test" + mode === "production" ? "production" : "test", + preSession.project_id ); const headers = buildWebhookHeaders(signature, timestamp, webhookId); const webhookPayload = unwrapWebhookPayload(client, rawBody, headers); diff --git a/src/storage/db/postgres/helpers/metadata.ts b/src/storage/db/postgres/helpers/metadata.ts index cdc30f7..0b5296b 100644 --- a/src/storage/db/postgres/helpers/metadata.ts +++ b/src/storage/db/postgres/helpers/metadata.ts @@ -13,7 +13,7 @@ export type UpsertMetadataInput = { dodo_test_webhook_secret?: string; currency?: string; redirect_url?: string; - project_id?: string; + project_id: string; }; export async function upsertMetadata( @@ -26,6 +26,7 @@ export async function upsertMetadata( const [existingMetadata] = await txn .select({ id: metadataTable.id }) .from(metadataTable) + .where(eq(metadataTable.project_id, input.project_id)) .limit(1) .for("update"); @@ -45,8 +46,6 @@ export async function upsertMetadata( if (input.currency !== undefined) setValues.currency = input.currency; if (input.redirect_url !== undefined) setValues.redirect_url = input.redirect_url; - if (input.project_id !== undefined) - setValues.project_id = input.project_id; if (existingMetadata) { if (Object.keys(setValues).length > 0) { @@ -60,6 +59,7 @@ export async function upsertMetadata( const insertValues: typeof metadataTable.$inferInsert = { ...setValues, + project_id: input.project_id, } as typeof metadataTable.$inferInsert; await txn.insert(metadataTable).values(insertValues); } catch (e) { @@ -71,10 +71,14 @@ export async function upsertMetadata( }); } -export async function getMetadata(): Promise< - typeof metadataTable.$inferSelect | undefined -> { +export async function getMetadata( + project_id: string +): Promise { const db = getPostgresDB(); - const [metadata] = await db.select().from(metadataTable).limit(1); + const [metadata] = await db + .select() + .from(metadataTable) + .where(eq(metadataTable.project_id, project_id)) + .limit(1); return metadata; } diff --git a/src/storage/db/postgres/helpers/sessions.ts b/src/storage/db/postgres/helpers/sessions.ts index a2fe38e..b84ae48 100644 --- a/src/storage/db/postgres/helpers/sessions.ts +++ b/src/storage/db/postgres/helpers/sessions.ts @@ -33,7 +33,8 @@ export async function updateSessionStatus( export async function checkIfExistingCheckoutLink( txn: PgTransaction, userId: UserId, - mode: "test" | "production" + mode: "test" | "production", + project_id: string ): Promise { try { if (!txn) { @@ -48,6 +49,7 @@ export async function checkIfExistingCheckoutLink( eq(sessionsTable.userId, userId), eq(sessionsTable.processed, "pending"), eq(sessionsTable.mode, mode), + eq(sessionsTable.project_id, project_id), sql`${sessionsTable.createdAt} > ${DateTime.utc().minus({ hours: 24 }).toISO()}` ) ) From 5c8cc16132c4663d1f5f41a342315f22a8dd5312 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Sun, 14 Jun 2026 00:19:27 +0530 Subject: [PATCH 03/10] fix(handleProjects.ts): fixed greptile issues --- src/routes/http/api/onboarding.ts | 14 +++++++------- src/routes/http/api/registerApiRoutes.ts | 2 +- src/routes/http/createdCheckout.ts | 22 ++-------------------- src/routes/http/registerWebhookRoutes.ts | 14 +++++++++++++- src/zod/internals.ts | 1 - 5 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index 159f375..e095345 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -30,11 +30,11 @@ export async function handleOnboarding( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); - const body = await request.body; const validated = onboardingSchema.parse(body); + const { project_id } = await authenticateHttpApiKey(authHeader); + const appUrl = process.env.APP_URL; if (!appUrl) { builder.setError(500, { @@ -45,7 +45,7 @@ export async function handleOnboarding( return {}; } - await createProject(validated.project_id, validated.dodoLiveProductId); + await createProject(project_id, validated.dodoLiveProductId); const liveClient = new DodoPayments({ bearerToken: validated.dodoLiveApiKey, @@ -60,7 +60,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&project_id=${project_id}`, description: "Scrawn live payment webhook", filter_types: ["payment.succeeded", "payment.failed"], }); @@ -68,7 +68,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&project_id=${project_id}`, description: "Scrawn test payment webhook", filter_types: ["payment.succeeded", "payment.failed"], }); @@ -96,10 +96,10 @@ export async function handleOnboarding( dodo_test_webhook_secret: encrypt(testSecret), currency: validated.currency, redirect_url: validated.redirectUrl, - project_id: validated.project_id, + project_id, }); - clearClients(validated.project_id); + clearClients(project_id); builder.setSuccess(200); diff --git a/src/routes/http/api/registerApiRoutes.ts b/src/routes/http/api/registerApiRoutes.ts index 870667f..85d2dac 100644 --- a/src/routes/http/api/registerApiRoutes.ts +++ b/src/routes/http/api/registerApiRoutes.ts @@ -1,5 +1,5 @@ import type { FastifyRequest, FastifyReply } from "fastify"; -import { handleOnboarding, handleGetConfig } from "./onBoarding.ts"; +import { handleOnboarding, handleGetConfig } from "./onboarding.ts"; import { handleListTags, handleCreateTag, handleDeleteTag } from "./tags.ts"; import { handleListExpressions, diff --git a/src/routes/http/createdCheckout.ts b/src/routes/http/createdCheckout.ts index 8e53ed8..17b8f53 100644 --- a/src/routes/http/createdCheckout.ts +++ b/src/routes/http/createdCheckout.ts @@ -72,31 +72,13 @@ export async function handleDodoWebhook( timestamp: string | undefined, webhookId: string | undefined, mode: "production" | "test", + project_id: string, builder: WideEventBuilder ): Promise { try { - if (!webhookId) { - return errorResponse( - 400, - "ValidationError", - "Missing webhook-id header", - builder - ); - } - - const preSession = await getSessionByCheckoutId(webhookId); - if (!preSession) { - return errorResponse( - 404, - "NotFoundError", - "No session found for this webhook", - builder - ); - } - const client = await getDodoClient( mode === "production" ? "production" : "test", - preSession.project_id + project_id ); const headers = buildWebhookHeaders(signature, timestamp, webhookId); const webhookPayload = unwrapWebhookPayload(client, rawBody, headers); diff --git a/src/routes/http/registerWebhookRoutes.ts b/src/routes/http/registerWebhookRoutes.ts index 0e15cd3..907b388 100644 --- a/src/routes/http/registerWebhookRoutes.ts +++ b/src/routes/http/registerWebhookRoutes.ts @@ -36,7 +36,8 @@ export async function registerWebhookRoutes( ); try { - const mode = (request.query as Record)?.mode; + const query = request.query as Record; + const mode = query?.mode; if (mode !== "production" && mode !== "test") { builder.setError(400, { type: "ValidationError", @@ -47,6 +48,16 @@ export async function registerWebhookRoutes( return { error: "Invalid mode query parameter" }; } + const project_id = query?.project_id; + if (!project_id) { + builder.setError(400, { + type: "ValidationError", + message: "Missing 'project_id' query parameter", + }); + reply.code(400); + return { error: "Missing project_id query parameter" }; + } + const signatureHeader = request.headers["webhook-signature"]; const timestampHeader = request.headers["webhook-timestamp"]; const webhookIdHeader = request.headers["webhook-id"]; @@ -72,6 +83,7 @@ export async function registerWebhookRoutes( timestamp, webhookId, mode, + project_id, builder ); diff --git a/src/zod/internals.ts b/src/zod/internals.ts index 3cd933f..938d40b 100644 --- a/src/zod/internals.ts +++ b/src/zod/internals.ts @@ -39,5 +39,4 @@ export const onboardingSchema = z.object({ dodoTestProductId: z.string().min(1, "Dodo test product ID is required"), currency: z.string().min(1, "Currency is required"), redirectUrl: z.url("Redirect URL must be a valid URL"), - project_id: z.string().min(1, "Project ID is required"), }); From 3ad7be60ea65b0fba81772abcaac7a37f1ad8d2f Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Sun, 14 Jun 2026 00:48:35 +0530 Subject: [PATCH 04/10] fix(handleProjects.ts): fixed greptile issues --- src/routes/http/api/handleProjects.ts | 11 ++++++++--- src/routes/http/api/onboarding.ts | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/routes/http/api/handleProjects.ts b/src/routes/http/api/handleProjects.ts index 5e74c24..bd6bb62 100644 --- a/src/routes/http/api/handleProjects.ts +++ b/src/routes/http/api/handleProjects.ts @@ -12,7 +12,6 @@ import { AuthError } from "../../../errors/auth"; import { logger } from "../../../errors/logger"; const checkProject = z.object({ - project_id: z.string().min(1, "Project ID is required").max(128), product_id: z.string().min(1, "Product ID is required").max(128), }); @@ -28,12 +27,18 @@ export async function handleCreateProject( try { const authHeader = request.headers.authorization; - const { project_id } = await authenticateHttpApiKey(authHeader); + const { project_id, role } = await authenticateHttpApiKey(authHeader); + + if (role !== "dashboard") { + throw AuthError.permissionDenied( + "Only dashboard keys can manage projects" + ); + } const body = await request.body; const validated = checkProject.parse(body); - await createProject(validated.project_id, validated.product_id); + await createProject(project_id, validated.product_id); builder.setSuccess(200); reply.code(200); diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index e095345..edbf211 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -30,11 +30,11 @@ export async function handleOnboarding( try { const authHeader = request.headers.authorization; + const { project_id } = await authenticateHttpApiKey(authHeader); + const body = await request.body; const validated = onboardingSchema.parse(body); - const { project_id } = await authenticateHttpApiKey(authHeader); - const appUrl = process.env.APP_URL; if (!appUrl) { builder.setError(500, { From 432ada2141e0b9910015e09bacff903d37ebf1a1 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Sun, 14 Jun 2026 02:51:56 +0530 Subject: [PATCH 05/10] fix(onboarding.ts): moved createProject function calling --- src/routes/http/api/onboarding.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index edbf211..252fabc 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -45,8 +45,6 @@ export async function handleOnboarding( return {}; } - await createProject(project_id, validated.dodoLiveProductId); - const liveClient = new DodoPayments({ bearerToken: validated.dodoLiveApiKey, environment: "live_mode", @@ -87,6 +85,7 @@ export async function handleOnboarding( return {}; } + await createProject(project_id, validated.dodoLiveProductId); await upsertMetadata({ dodo_live_api_key: encrypt(validated.dodoLiveApiKey), dodo_test_api_key: encrypt(validated.dodoTestApiKey), From 5af07f66de61de2c019071c54eeab7eff77ecd61 Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Sun, 14 Jun 2026 21:40:15 +0530 Subject: [PATCH 06/10] fix(onboarding.ts): wrapping two db calls in a single transaction --- src/routes/http/api/onboarding.ts | 37 ++++++++++++++------- src/storage/db/postgres/helpers/metadata.ts | 18 +++++++--- src/storage/db/postgres/helpers/projects.ts | 8 +++-- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/routes/http/api/onboarding.ts b/src/routes/http/api/onboarding.ts index 252fabc..d628b8c 100644 --- a/src/routes/http/api/onboarding.ts +++ b/src/routes/http/api/onboarding.ts @@ -17,6 +17,8 @@ import { import { clearClients } from "../../gRPC/payment/paymentProvider.ts"; import { encrypt, decrypt } from "../../../utils/encryptMetadata.ts"; import { createProject } from "../../../storage/db/postgres/helpers/projects.ts"; +import { executeInTransaction } from "../../../storage/adapter/postgres/handlers/addEventUtils.ts"; +import { getPostgresDB } from "../../../storage/db/postgres/db.ts"; export async function handleOnboarding( request: FastifyRequest, @@ -85,18 +87,29 @@ export async function handleOnboarding( return {}; } - await createProject(project_id, validated.dodoLiveProductId); - 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, - project_id, - }); + const db = getPostgresDB(); + + await executeInTransaction( + db, + "update db with project and metadata", + async (txn) => { + await createProject(project_id, validated.dodoLiveProductId, txn); + 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, + project_id, + }, + txn + ); + } + ); clearClients(project_id); diff --git a/src/storage/db/postgres/helpers/metadata.ts b/src/storage/db/postgres/helpers/metadata.ts index 0b5296b..efd3857 100644 --- a/src/storage/db/postgres/helpers/metadata.ts +++ b/src/storage/db/postgres/helpers/metadata.ts @@ -1,9 +1,12 @@ +import type { PgDatabase, PgTransaction } from "drizzle-orm/pg-core"; 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 DbClient = PgDatabase | PgTransaction; + export type UpsertMetadataInput = { dodo_live_api_key?: string; dodo_test_api_key?: string; @@ -17,11 +20,12 @@ export type UpsertMetadataInput = { }; export async function upsertMetadata( - input: UpsertMetadataInput + input: UpsertMetadataInput, + tx?: DbClient ): Promise { - const db = getPostgresDB(); + const db = tx ?? getPostgresDB(); - await executeInTransaction(db, "upsert metadata", async (txn) => { + const run = async (txn: DbClient) => { try { const [existingMetadata] = await txn .select({ id: metadataTable.id }) @@ -68,7 +72,13 @@ export async function upsertMetadata( e instanceof Error ? e : new Error(String(e)) ); } - }); + }; + + if (tx) { + await run(tx); + } else { + await executeInTransaction(db, "upsert metadata", run); + } } export async function getMetadata( diff --git a/src/storage/db/postgres/helpers/projects.ts b/src/storage/db/postgres/helpers/projects.ts index ab1777d..4b37240 100644 --- a/src/storage/db/postgres/helpers/projects.ts +++ b/src/storage/db/postgres/helpers/projects.ts @@ -1,13 +1,17 @@ import { eq } from "drizzle-orm"; +import type { PgDatabase, PgTransaction } from "drizzle-orm/pg-core"; import { getPostgresDB } from "../db"; import { projectTable } from "./../schema"; import { StorageError } from "../../../../errors/storage"; +export type DbClient = PgDatabase | PgTransaction; + export async function createProject( project_id: string, - product_id: string + product_id: string, + tx?: DbClient ): Promise { - const db = getPostgresDB(); + const db = tx ?? getPostgresDB(); try { const existing = await db From a13fd965dc773a93fd060b66427b8b2eaf60b11d Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Tue, 16 Jun 2026 03:11:36 +0530 Subject: [PATCH 07/10] fix(projects): fixed billing window contamination bug --- src/routes/http/api/expressions.ts | 10 +- src/routes/http/api/tags.ts | 8 +- src/routes/http/createdCheckout.ts | 2 +- .../db/postgres/helpers/expressions.ts | 46 +++- src/storage/db/postgres/helpers/tags.ts | 32 ++- src/storage/db/postgres/helpers/users.ts | 9 +- src/storage/db/postgres/schema.ts | 256 ++++++++++-------- src/utils/fetchTagAmount.ts | 14 +- src/utils/parseExpr.ts | 36 ++- 9 files changed, 259 insertions(+), 154 deletions(-) diff --git a/src/routes/http/api/expressions.ts b/src/routes/http/api/expressions.ts index 401da84..da00ff5 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 { project_id } = await authenticateHttpApiKey(authHeader); - const expressions = await listExpressions(); + const expressions = await listExpressions(project_id); builder.setSuccess(200).addContext({ expressionCount: expressions.length }); reply.code(200); @@ -90,7 +90,7 @@ export async function handleCreateExpression( const validated = createExpressionSchema.parse(body); validateExprSyntax(validated.expr); - await resolveExprRefsInExpression(validated.expr); + await resolveExprRefsInExpression(validated.expr, new Set(), project_id); await createExpression(validated.key, validated.expr, project_id); @@ -147,10 +147,10 @@ export async function handleDeleteExpression( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const { project_id } = await authenticateHttpApiKey(authHeader); const params = request.params as { key: string }; - const deleted = await deleteExpression(params.key); + const deleted = await deleteExpression(params.key, project_id); if (!deleted) { builder.setError(404, { diff --git a/src/routes/http/api/tags.ts b/src/routes/http/api/tags.ts index 056e9fb..4241d3a 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 { project_id } = await authenticateHttpApiKey(authHeader); - const tags = await listTags(); + const tags = await listTags(project_id); builder.setSuccess(200).addContext({ tagCount: tags.length }); reply.code(200); @@ -137,10 +137,10 @@ export async function handleDeleteTag( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const { project_id } = await authenticateHttpApiKey(authHeader); const params = tagParamsSchema.parse(request.params); - const deleted = await deleteTag(params.key); + const deleted = await deleteTag(params.key, project_id); if (!deleted) { builder.setError(404, { diff --git a/src/routes/http/createdCheckout.ts b/src/routes/http/createdCheckout.ts index 17b8f53..0974413 100644 --- a/src/routes/http/createdCheckout.ts +++ b/src/routes/http/createdCheckout.ts @@ -192,7 +192,7 @@ export async function handleDodoWebhook( txn ); if (!claimed) return; - await updateUserBilledTimestamp(userId, billed_upto, txn); + await updateUserBilledTimestamp(userId, project_id, billed_upto, txn); await handleAddPayment( userId, creditAmount, diff --git a/src/storage/db/postgres/helpers/expressions.ts b/src/storage/db/postgres/helpers/expressions.ts index c7d5432..5b11c3c 100644 --- a/src/storage/db/postgres/helpers/expressions.ts +++ b/src/storage/db/postgres/helpers/expressions.ts @@ -4,14 +4,18 @@ 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(project_id?: string): Promise { const db = getPostgresDB(); try { + const conditions = [isNull(expressionsTable.deletedAt)]; + if (project_id) { + conditions.push(eq(expressionsTable.project_id, project_id)); + } const rows = await db .select({ key: expressionsTable.key }) .from(expressionsTable) - .where(isNull(expressionsTable.deletedAt)); + .where(and(...conditions)); return rows.map((row) => row.key); } catch (e) { throw StorageError.queryFailed( @@ -21,16 +25,24 @@ export async function listExpressions(): Promise { } } -export async function findExpressionByKey(key: string): Promise { +export async function findExpressionByKey( + key: string, + project_id?: string +): Promise { const db = getPostgresDB(); try { + const conditions = [ + eq(expressionsTable.key, key), + isNull(expressionsTable.deletedAt), + ]; + if (project_id) { + conditions.push(eq(expressionsTable.project_id, project_id)); + } const [record] = await db .select({ expr: expressionsTable.expr }) .from(expressionsTable) - .where( - and(eq(expressionsTable.key, key), isNull(expressionsTable.deletedAt)) - ) + .where(and(...conditions)) .limit(1); return record?.expr ?? null; @@ -54,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.key, key), + eq(expressionsTable.project_id, project_id), + isNull(expressionsTable.deletedAt) + ) ) .limit(1); @@ -75,17 +91,25 @@ export async function createExpression( } } -export async function deleteExpression(key: string): Promise { +export async function deleteExpression( + key: string, + project_id?: string +): Promise { const db = getPostgresDB(); try { const now = DateTime.utc().toISO(); + const conditions = [ + eq(expressionsTable.key, key), + isNull(expressionsTable.deletedAt), + ]; + if (project_id) { + conditions.push(eq(expressionsTable.project_id, project_id)); + } const result = await db .update(expressionsTable) .set({ deletedAt: now }) - .where( - and(eq(expressionsTable.key, key), isNull(expressionsTable.deletedAt)) - ); + .where(and(...conditions)); return (result.count ?? 0) > 0; } catch (e) { diff --git a/src/storage/db/postgres/helpers/tags.ts b/src/storage/db/postgres/helpers/tags.ts index 4191697..57fcfab 100644 --- a/src/storage/db/postgres/helpers/tags.ts +++ b/src/storage/db/postgres/helpers/tags.ts @@ -5,14 +5,20 @@ 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( + project_id?: string +): Promise<{ key: string; amount: number }[]> { const db = getPostgresDB(); try { + const conditions = [isNull(tagsTable.deletedAt)]; + if (project_id) { + conditions.push(eq(tagsTable.project_id, project_id)); + } const rows = await db .select({ key: tagsTable.key, amount: tagsTable.amount }) .from(tagsTable) - .where(isNull(tagsTable.deletedAt)); + .where(and(...conditions)); return rows; } catch (e) { throw StorageError.queryFailed( @@ -33,7 +39,13 @@ export async function createTag( const existing = await db .select({ id: tagsTable.id }) .from(tagsTable) - .where(and(eq(tagsTable.key, key), isNull(tagsTable.deletedAt))) + .where( + and( + eq(tagsTable.key, key), + eq(tagsTable.project_id, project_id), + isNull(tagsTable.deletedAt) + ) + ) .limit(1); if (existing[0]) { @@ -42,11 +54,13 @@ export async function createTag( .set({ amount }) .where(eq(tagsTable.id, existing[0].id)); tagCache.delete(key); + tagCache.delete(`${project_id}:${key}`); return; } await db.insert(tagsTable).values({ key, amount, project_id }); tagCache.delete(key); + tagCache.delete(`${project_id}:${key}`); } catch (e) { throw StorageError.insertFailed( `Failed to upsert tag '${key}'`, @@ -55,18 +69,26 @@ export async function createTag( } } -export async function deleteTag(key: string): Promise { +export async function deleteTag( + key: string, + project_id?: string +): Promise { const db = getPostgresDB(); try { const now = DateTime.utc().toISO(); + const conditions = [eq(tagsTable.key, key), isNull(tagsTable.deletedAt)]; + if (project_id) { + conditions.push(eq(tagsTable.project_id, project_id)); + } const result = await db .update(tagsTable) .set({ deletedAt: now }) - .where(and(eq(tagsTable.key, key), isNull(tagsTable.deletedAt))); + .where(and(...conditions)); if ((result.count ?? 0) > 0) { tagCache.delete(key); + if (project_id) tagCache.delete(`${project_id}:${key}`); return true; } return false; diff --git a/src/storage/db/postgres/helpers/users.ts b/src/storage/db/postgres/helpers/users.ts index 3ce705d..83494cb 100644 --- a/src/storage/db/postgres/helpers/users.ts +++ b/src/storage/db/postgres/helpers/users.ts @@ -1,11 +1,12 @@ 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"; export async function updateUserBilledTimestamp( userId: string, + project_id: string, billedUpto: string, txn?: PgTransaction ): Promise { @@ -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.id, userId), eq(usersTable.project_id, project_id)) + ); } catch (e) { throw StorageError.queryFailed( "Failed to update user billed timestamp", @@ -45,7 +48,7 @@ export async function ensureUserExists( await db .insert(usersTable) .values({ id: userId, project_id: project_id }) - .onConflictDoNothing({ target: usersTable.id }); + .onConflictDoNothing({ target: [usersTable.id, usersTable.project_id] }); } catch (e) { if ( e instanceof Error && diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index 00e5485..2b66020 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -9,27 +9,35 @@ import { boolean, jsonb, uniqueIndex, + primaryKey, + foreignKey, } from "drizzle-orm/pg-core"; import { USER_ID_CONFIG } from "../../../config/identifiers"; import { DateTime } from "luxon"; import { type Metrics } from "../../../zod/metrics"; -export const usersTable = pgTable("users", { - id: USER_ID_CONFIG.dbType("id").primaryKey(), - last_billed_timestamp: timestamp("last_billed_timestamp", { - withTimezone: true, - mode: "string", +export const usersTable = pgTable( + "users", + { + id: USER_ID_CONFIG.dbType("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"), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), + mode: text("mode", { enum: ["test", "production"] }) + .notNull() + .default("production"), + }, + (table) => ({ + pk: primaryKey({ columns: [table.id, table.project_id] }), }) - .default(DateTime.utc(1).toString()) - .notNull(), - payment_provider_user_id: text("payment_provider_user_id"), - project_id: uuid("project_id") - .references(() => projectTable.project_id) - .notNull(), - mode: text("mode", { enum: ["test", "production"] }) - .notNull() - .default("production"), -}); +); export const usersRelation = relations(usersTable, ({ many, one }) => ({ sessions: many(sessionsTable), @@ -50,9 +58,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(), @@ -76,13 +82,17 @@ export const sessionsTable = pgTable( }, (table) => ({ uniqueSessionId: uniqueIndex("unique_session_id").on(table.sessionId), + userFk: foreignKey({ + columns: [table.userId, table.project_id], + foreignColumns: [usersTable.id, usersTable.project_id], + }), }) ); export const sessionRelations = relations(sessionsTable, ({ one, many }) => ({ user: one(usersTable, { - fields: [sessionsTable.userId], - references: [usersTable.id], + fields: [sessionsTable.userId, sessionsTable.project_id], + references: [usersTable.id, usersTable.project_id], }), apiKey: one(apiKeysTable, { fields: [sessionsTable.apiKeyId], @@ -101,9 +111,9 @@ export const apiKeysTable = pgTable( id: uuid("id").primaryKey().defaultRandom(), name: text("name").notNull(), key: text("key").notNull().unique(), - role: text("role", { enum: ["dashboard", "production", "test"] }) + role: text("role", { enum: ["project", "production", "test"] }) .notNull() - .default("dashboard"), + .default("project"), createdAt: timestamp("created_at", { withTimezone: true, mode: "string", @@ -141,41 +151,48 @@ export const apiKeysRelation = relations(apiKeysTable, ({ many, one }) => ({ }), })); -export const basicUsageEventsTable = pgTable("basic_usage_events", { - id: uuid("id").primaryKey().defaultRandom(), - 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(), + 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(), + project_id: uuid("project_id") + .references(() => projectTable.project_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.userId, table.project_id], + foreignColumns: [usersTable.id, usersTable.project_id], + }), }) - .defaultNow() - .notNull(), - userId: USER_ID_CONFIG.dbType("user_id") - .references(() => usersTable.id) - .notNull(), - apiKeyId: uuid("api_key_id") - .references(() => apiKeysTable.id) - .notNull(), - project_id: uuid("project_id") - .references(() => projectTable.project_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, ({ one }) => ({ user: one(usersTable, { - fields: [basicUsageEventsTable.userId], - references: [usersTable.id], + fields: [basicUsageEventsTable.userId, basicUsageEventsTable.project_id], + references: [usersTable.id, usersTable.project_id], }), apiKey: one(apiKeysTable, { fields: [basicUsageEventsTable.apiKeyId], @@ -188,44 +205,47 @@ export const basicUsageEventsRelation = relations( }) ); -export const paymentEventsTable = pgTable("payment_events", { - id: uuid("id").primaryKey().defaultRandom(), - 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(), + 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(), + project_id: uuid("project_id") + .references(() => projectTable.project_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.userId, table.project_id], + foreignColumns: [usersTable.id, usersTable.project_id], + }), }) - .defaultNow() - .notNull(), - userId: USER_ID_CONFIG.dbType("user_id") - .references(() => usersTable.id) - .notNull(), - apiKeyId: uuid("api_key_id") - .references(() => apiKeysTable.id) - .notNull(), - project_id: uuid("project_id") - .references(() => projectTable.project_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, ({ one }) => ({ user: one(usersTable, { - fields: [paymentEventsTable.userId], - references: [usersTable.id], - }), - apiKey: one(apiKeysTable, { - fields: [paymentEventsTable.apiKeyId], - references: [apiKeysTable.id], + fields: [paymentEventsTable.userId, paymentEventsTable.project_id], + references: [usersTable.id, usersTable.project_id], }), session: one(sessionsTable, { fields: [paymentEventsTable.proxyId], @@ -238,42 +258,52 @@ export const paymentEventsRelation = relations( }) ); -export const aiTokenUsageEventsTable = pgTable("ai_token_usage_events", { - id: uuid("id").primaryKey().defaultRandom(), - 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(), + 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(), + project_id: uuid("project_id") + .references(() => projectTable.project_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.userId, table.project_id], + foreignColumns: [usersTable.id, usersTable.project_id], + }), }) - .defaultNow() - .notNull(), - userId: USER_ID_CONFIG.dbType("user_id") - .references(() => usersTable.id) - .notNull(), - apiKeyId: uuid("api_key_id") - .references(() => apiKeysTable.id) - .notNull(), - project_id: uuid("project_id") - .references(() => projectTable.project_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, ({ one }) => ({ user: one(usersTable, { - fields: [aiTokenUsageEventsTable.userId], - references: [usersTable.id], + fields: [ + aiTokenUsageEventsTable.userId, + aiTokenUsageEventsTable.project_id, + ], + references: [usersTable.id, usersTable.project_id], }), apiKey: one(apiKeysTable, { fields: [aiTokenUsageEventsTable.apiKeyId], diff --git a/src/utils/fetchTagAmount.ts b/src/utils/fetchTagAmount.ts index 3b72d32..eb3ddf6 100644 --- a/src/utils/fetchTagAmount.ts +++ b/src/utils/fetchTagAmount.ts @@ -6,24 +6,30 @@ import { tagCache } from "./tagCache"; export async function fetchTagAmount( tag: string, - notFoundMessage: string + notFoundMessage: string, + project_id?: string ): Promise { - const cachedAmount = tagCache.get(tag); + const cacheKey = project_id ? `${project_id}:${tag}` : tag; + const cachedAmount = tagCache.get(cacheKey); if (cachedAmount !== undefined) { return cachedAmount; } const db = getPostgresDB(); + const conditions = [eq(tagsTable.key, tag), isNull(tagsTable.deletedAt)]; + if (project_id) { + conditions.push(eq(tagsTable.project_id, project_id)); + } const [tagRow] = await db .select() .from(tagsTable) - .where(and(eq(tagsTable.key, tag), isNull(tagsTable.deletedAt))) + .where(and(...conditions)) .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..a77d4b4 100644 --- a/src/utils/parseExpr.ts +++ b/src/utils/parseExpr.ts @@ -184,7 +184,8 @@ export function validateExprSyntax(exprString: string): void { */ export async function resolveExprRefsInExpression( exprString: string, - resolving: Set = new Set() + resolving: Set = new Set(), + project_id?: string ): Promise { const refs = extractExprRefs(exprString); @@ -201,14 +202,18 @@ export async function resolveExprRefsInExpression( ); } - const storedExpr = await findExpressionByKey(refName); + const storedExpr = await findExpressionByKey(refName, project_id); if (!storedExpr) { throw EventError.validationFailed(`Expression not found: ${refName}`); } resolving.add(refName); - const expanded = await resolveExprRefsInExpression(storedExpr, resolving); + const expanded = await resolveExprRefsInExpression( + storedExpr, + resolving, + project_id + ); 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, + project_id?: 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( + tagName, + `Tag not found: ${tagName}`, + project_id + ); tagValues.set(tagName, value); } @@ -323,16 +335,24 @@ function resolveTokenPlaceholders( */ export async function parseAndEvaluateExpr( exprString: string, - tokenContext?: EvalTokenContext + tokenContext?: EvalTokenContext, + project_id?: string ): 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, + new Set(), + project_id + ); // Step 3: Resolve all tags to their values - const tagResolvedExpr = await resolveTagsInExpression(expandedExpr); + const tagResolvedExpr = await resolveTagsInExpression( + expandedExpr, + project_id + ); // Step 4: Resolve token placeholders if context provided const finalExpr = tokenContext From 7fd82bb3942793703bb36b4a390548a04db85ffe Mon Sep 17 00:00:00 2001 From: 0xanshu Date: Tue, 16 Jun 2026 03:22:58 +0530 Subject: [PATCH 08/10] fix(projects): fixed enum value --- src/storage/db/postgres/schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index 2b66020..49d6c0a 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -111,9 +111,9 @@ export const apiKeysTable = pgTable( id: uuid("id").primaryKey().defaultRandom(), name: text("name").notNull(), key: text("key").notNull().unique(), - role: text("role", { enum: ["project", "production", "test"] }) + role: text("role", { enum: ["dashboard", "production", "test"] }) .notNull() - .default("project"), + .default("dashboard"), createdAt: timestamp("created_at", { withTimezone: true, mode: "string", From 7c81a1ff497cf037e2148f77627921f4149127df Mon Sep 17 00:00:00 2001 From: Jayadeep Bejoy Date: Tue, 16 Jun 2026 23:02:54 +0530 Subject: [PATCH 09/10] fix(race): Made it a singular insert on conflict --- drizzle/0000_flaky_mandroid.sql | 170 +++ drizzle/meta/0000_snapshot.json | 1123 +++++++++++++++++ drizzle/meta/_journal.json | 13 + .../postgres/handlers/addEventUtils.ts | 2 +- src/storage/db/postgres/helpers/metadata.ts | 72 +- src/storage/db/postgres/schema.ts | 48 +- 6 files changed, 1387 insertions(+), 41 deletions(-) create mode 100644 drizzle/0000_flaky_mandroid.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json diff --git a/drizzle/0000_flaky_mandroid.sql b/drizzle/0000_flaky_mandroid.sql new file mode 100644 index 0000000..ebbdb3a --- /dev/null +++ b/drizzle/0000_flaky_mandroid.sql @@ -0,0 +1,170 @@ +CREATE TABLE "ai_token_usage_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "event_id" uuid NOT NULL, + "idempotency_key" text NOT NULL, + "reported_timestamp" timestamp with time zone NOT NULL, + "ingested_timestamp" timestamp with time zone DEFAULT now() NOT NULL, + "user_id" uuid NOT NULL, + "api_key_id" uuid NOT NULL, + "project_id" uuid NOT NULL, + "mode" text NOT NULL, + "model" text NOT NULL, + "provider" text NOT NULL, + "metrics" jsonb NOT NULL, + "metadata" jsonb, + CONSTRAINT "ai_token_usage_events_idempotency_key_unique" UNIQUE("idempotency_key") +); +--> statement-breakpoint +CREATE TABLE "api_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "key" text NOT NULL, + "role" text DEFAULT 'dashboard' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "project_id" uuid NOT NULL, + "revoked" boolean DEFAULT false NOT NULL, + "revoked_at" timestamp with time zone, + CONSTRAINT "api_keys_key_unique" UNIQUE("key") +); +--> statement-breakpoint +CREATE TABLE "basic_usage_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "event_id" uuid NOT NULL, + "idempotency_key" text NOT NULL, + "reported_timestamp" timestamp with time zone NOT NULL, + "ingested_timestamp" timestamp with time zone DEFAULT now() NOT NULL, + "user_id" uuid NOT NULL, + "api_key_id" uuid NOT NULL, + "project_id" uuid NOT NULL, + "mode" text NOT NULL, + "type" text NOT NULL, + "debit_amount" bigint NOT NULL, + "metadata" jsonb, + CONSTRAINT "basic_usage_events_idempotency_key_unique" UNIQUE("idempotency_key") +); +--> statement-breakpoint +CREATE TABLE "expressions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "expr" text NOT NULL, + "project_id" uuid NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "metadata" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "last_run_at" timestamp with time zone, + "dodo_live_api_key" text NOT NULL, + "dodo_test_api_key" text NOT NULL, + "dodo_live_product_id" text NOT NULL, + "dodo_test_product_id" text NOT NULL, + "dodo_live_webhook_secret" text NOT NULL, + "dodo_test_webhook_secret" text NOT NULL, + "currency" text DEFAULT 'usd' NOT NULL, + "project_id" uuid NOT NULL, + "redirect_url" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "payment_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "reported_timestamp" timestamp with time zone NOT NULL, + "ingested_timestamp" timestamp with time zone DEFAULT now() NOT NULL, + "user_id" uuid NOT NULL, + "api_key_id" uuid NOT NULL, + "project_id" uuid NOT NULL, + "mode" text NOT NULL, + "credit_amount" bigint NOT NULL, + "proxy_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "projects" ( + "project_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "product_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "proxy_link_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "session_id" text NOT NULL, + "processed" text DEFAULT 'pending' NOT NULL, + "user_id" uuid NOT NULL, + "api_key_id" uuid NOT NULL, + "billed_upto" timestamp with time zone NOT NULL, + "checkout_url" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "project_id" uuid NOT NULL, + "mode" text DEFAULT 'production' NOT NULL, + CONSTRAINT "sessions_session_id_unique" UNIQUE("session_id") +); +--> statement-breakpoint +CREATE TABLE "tags" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "amount" integer NOT NULL, + "deleted_at" timestamp with time zone, + "project_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid NOT NULL, + "last_billed_timestamp" timestamp with time zone DEFAULT '0001-01-01T00:00:00.000Z' NOT NULL, + "payment_provider_user_id" text, + "project_id" uuid NOT NULL, + "mode" text DEFAULT 'production' NOT NULL, + CONSTRAINT "users_id_project_id_pk" PRIMARY KEY("id","project_id") +); +--> statement-breakpoint +CREATE TABLE "webhook_deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "endpoint_id" uuid NOT NULL, + "event_id" text NOT NULL, + "event_type" text NOT NULL, + "resource" text NOT NULL, + "action" text NOT NULL, + "status" text NOT NULL, + "request_body" jsonb, + "response_status" integer, + "error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "project_id" uuid NOT NULL +); +--> statement-breakpoint +CREATE TABLE "webhook_endpoints" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "api_key_id" uuid NOT NULL, + "url" text NOT NULL, + "private_key" text NOT NULL, + "public_key" text NOT NULL, + "project_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "ai_token_usage_events" ADD CONSTRAINT "ai_token_usage_events_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ai_token_usage_events" ADD CONSTRAINT "ai_token_usage_events_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ai_token_usage_events" ADD CONSTRAINT "ai_token_usage_events_user_id_project_id_users_id_project_id_fk" FOREIGN KEY ("user_id","project_id") REFERENCES "public"."users"("id","project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "basic_usage_events" ADD CONSTRAINT "basic_usage_events_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "basic_usage_events" ADD CONSTRAINT "basic_usage_events_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "basic_usage_events" ADD CONSTRAINT "basic_usage_events_user_id_project_id_users_id_project_id_fk" FOREIGN KEY ("user_id","project_id") REFERENCES "public"."users"("id","project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "expressions" ADD CONSTRAINT "expressions_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "metadata" ADD CONSTRAINT "metadata_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payment_events" ADD CONSTRAINT "payment_events_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payment_events" ADD CONSTRAINT "payment_events_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payment_events" ADD CONSTRAINT "payment_events_proxy_id_sessions_proxy_link_id_fk" FOREIGN KEY ("proxy_id") REFERENCES "public"."sessions"("proxy_link_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payment_events" ADD CONSTRAINT "payment_events_user_id_project_id_users_id_project_id_fk" FOREIGN KEY ("user_id","project_id") REFERENCES "public"."users"("id","project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_project_id_users_id_project_id_fk" FOREIGN KEY ("user_id","project_id") REFERENCES "public"."users"("id","project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tags" ADD CONSTRAINT "tags_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoints"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "unique_active_name" ON "api_keys" USING btree ("name") WHERE "api_keys"."revoked" = false;--> statement-breakpoint +CREATE UNIQUE INDEX "metadata_project_id_unique" ON "metadata" USING btree ("project_id");--> statement-breakpoint +CREATE UNIQUE INDEX "unique_session_id" ON "sessions" USING btree ("session_id");--> statement-breakpoint +CREATE UNIQUE INDEX "unique_webhook_api_key" ON "webhook_endpoints" USING btree ("api_key_id"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..d0152e9 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1123 @@ +{ + "id": "537f83cc-724b-4367-8588-57d2a7c2ae66", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_token_usage_events": { + "name": "ai_token_usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reported_timestamp": { + "name": "reported_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ingested_timestamp": { + "name": "ingested_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metrics": { + "name": "metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ai_token_usage_events_api_key_id_api_keys_id_fk": { + "name": "ai_token_usage_events_api_key_id_api_keys_id_fk", + "tableFrom": "ai_token_usage_events", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ai_token_usage_events_project_id_projects_project_id_fk": { + "name": "ai_token_usage_events_project_id_projects_project_id_fk", + "tableFrom": "ai_token_usage_events", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["project_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ai_token_usage_events_user_id_project_id_users_id_project_id_fk": { + "name": "ai_token_usage_events_user_id_project_id_users_id_project_id_fk", + "tableFrom": "ai_token_usage_events", + "tableTo": "users", + "columnsFrom": ["user_id", "project_id"], + "columnsTo": ["id", "project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ai_token_usage_events_idempotency_key_unique": { + "name": "ai_token_usage_events_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": ["idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'dashboard'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_active_name": { + "name": "unique_active_name", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"api_keys\".\"revoked\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_project_id_projects_project_id_fk": { + "name": "api_keys_project_id_projects_project_id_fk", + "tableFrom": "api_keys", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_unique": { + "name": "api_keys_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.basic_usage_events": { + "name": "basic_usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reported_timestamp": { + "name": "reported_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ingested_timestamp": { + "name": "ingested_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "debit_amount": { + "name": "debit_amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "basic_usage_events_api_key_id_api_keys_id_fk": { + "name": "basic_usage_events_api_key_id_api_keys_id_fk", + "tableFrom": "basic_usage_events", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "basic_usage_events_project_id_projects_project_id_fk": { + "name": "basic_usage_events_project_id_projects_project_id_fk", + "tableFrom": "basic_usage_events", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["project_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "basic_usage_events_user_id_project_id_users_id_project_id_fk": { + "name": "basic_usage_events_user_id_project_id_users_id_project_id_fk", + "tableFrom": "basic_usage_events", + "tableTo": "users", + "columnsFrom": ["user_id", "project_id"], + "columnsTo": ["id", "project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "basic_usage_events_idempotency_key_unique": { + "name": "basic_usage_events_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": ["idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.expressions": { + "name": "expressions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expr": { + "name": "expr", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "expressions_project_id_projects_project_id_fk": { + "name": "expressions_project_id_projects_project_id_fk", + "tableFrom": "expressions", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.metadata": { + "name": "metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dodo_live_api_key": { + "name": "dodo_live_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dodo_test_api_key": { + "name": "dodo_test_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dodo_live_product_id": { + "name": "dodo_live_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dodo_test_product_id": { + "name": "dodo_test_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dodo_live_webhook_secret": { + "name": "dodo_live_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dodo_test_webhook_secret": { + "name": "dodo_test_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "metadata_project_id_unique": { + "name": "metadata_project_id_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "metadata_project_id_projects_project_id_fk": { + "name": "metadata_project_id_projects_project_id_fk", + "tableFrom": "metadata", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_events": { + "name": "payment_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reported_timestamp": { + "name": "reported_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ingested_timestamp": { + "name": "ingested_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credit_amount": { + "name": "credit_amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "proxy_id": { + "name": "proxy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "payment_events_api_key_id_api_keys_id_fk": { + "name": "payment_events_api_key_id_api_keys_id_fk", + "tableFrom": "payment_events", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payment_events_project_id_projects_project_id_fk": { + "name": "payment_events_project_id_projects_project_id_fk", + "tableFrom": "payment_events", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["project_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payment_events_proxy_id_sessions_proxy_link_id_fk": { + "name": "payment_events_proxy_id_sessions_proxy_link_id_fk", + "tableFrom": "payment_events", + "tableTo": "sessions", + "columnsFrom": ["proxy_id"], + "columnsTo": ["proxy_link_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payment_events_user_id_project_id_users_id_project_id_fk": { + "name": "payment_events_user_id_project_id_users_id_project_id_fk", + "tableFrom": "payment_events", + "tableTo": "users", + "columnsFrom": ["user_id", "project_id"], + "columnsTo": ["id", "project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "proxy_link_id": { + "name": "proxy_link_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "billed_upto": { + "name": "billed_upto", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "checkout_url": { + "name": "checkout_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'production'" + } + }, + "indexes": { + "unique_session_id": { + "name": "unique_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_api_key_id_api_keys_id_fk": { + "name": "sessions_api_key_id_api_keys_id_fk", + "tableFrom": "sessions", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sessions_project_id_projects_project_id_fk": { + "name": "sessions_project_id_projects_project_id_fk", + "tableFrom": "sessions", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["project_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sessions_user_id_project_id_users_id_project_id_fk": { + "name": "sessions_user_id_project_id_users_id_project_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id", "project_id"], + "columnsTo": ["id", "project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_session_id_unique": { + "name": "sessions_session_id_unique", + "nullsNotDistinct": false, + "columns": ["session_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tags_project_id_projects_project_id_fk": { + "name": "tags_project_id_projects_project_id_fk", + "tableFrom": "tags", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_billed_timestamp": { + "name": "last_billed_timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "'0001-01-01T00:00:00.000Z'" + }, + "payment_provider_user_id": { + "name": "payment_provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'production'" + } + }, + "indexes": {}, + "foreignKeys": { + "users_project_id_projects_project_id_fk": { + "name": "users_project_id_projects_project_id_fk", + "tableFrom": "users", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_id_project_id_pk": { + "name": "users_id_project_id_pk", + "columns": ["id", "project_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_body": { + "name": "request_body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk": { + "name": "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhook_endpoints", + "columnsFrom": ["endpoint_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_deliveries_project_id_projects_project_id_fk": { + "name": "webhook_deliveries_project_id_projects_project_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_endpoints": { + "name": "webhook_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_webhook_api_key": { + "name": "unique_webhook_api_key", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_endpoints_api_key_id_api_keys_id_fk": { + "name": "webhook_endpoints_api_key_id_api_keys_id_fk", + "tableFrom": "webhook_endpoints", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "webhook_endpoints_project_id_projects_project_id_fk": { + "name": "webhook_endpoints_project_id_projects_project_id_fk", + "tableFrom": "webhook_endpoints", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..9d448d4 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1781630177894, + "tag": "0000_flaky_mandroid", + "breakpoints": true + } + ] +} diff --git a/src/storage/adapter/postgres/handlers/addEventUtils.ts b/src/storage/adapter/postgres/handlers/addEventUtils.ts index f2a9aaa..0b85c56 100644 --- a/src/storage/adapter/postgres/handlers/addEventUtils.ts +++ b/src/storage/adapter/postgres/handlers/addEventUtils.ts @@ -12,7 +12,7 @@ export type TransactionFn = ( txn: PgTransaction ) => Promise; -function getPostgresErrorCode(e: unknown): string | null { +export function getPostgresErrorCode(e: unknown): string | null { if (e && typeof e === "object" && "code" in e) { const code = (e as { code: unknown }).code; if (typeof code === "string") return code; diff --git a/src/storage/db/postgres/helpers/metadata.ts b/src/storage/db/postgres/helpers/metadata.ts index efd3857..14b46ec 100644 --- a/src/storage/db/postgres/helpers/metadata.ts +++ b/src/storage/db/postgres/helpers/metadata.ts @@ -2,7 +2,7 @@ import type { PgDatabase, PgTransaction } from "drizzle-orm/pg-core"; import { getPostgresDB } from "../db"; import { metadataTable } from "../schema"; import { StorageError } from "../../../../errors/storage"; -import { eq } from "drizzle-orm"; +import { eq, asc } from "drizzle-orm"; import { executeInTransaction } from "../../../adapter/postgres/handlers/addEventUtils"; export type DbClient = PgDatabase | PgTransaction; @@ -19,6 +19,18 @@ export type UpsertMetadataInput = { project_id: string; }; +function requireField(value: T | undefined, name: string): T { + if (value === undefined) { + throw StorageError.insertFailed( + `Missing required field '${name}' for metadata insert`, + new Error( + `Field '${name}' was not provided but is required for a new metadata row` + ) + ); + } + return value; +} + export async function upsertMetadata( input: UpsertMetadataInput, tx?: DbClient @@ -27,13 +39,6 @@ export async function upsertMetadata( const run = async (txn: DbClient) => { try { - const [existingMetadata] = await txn - .select({ id: metadataTable.id }) - .from(metadataTable) - .where(eq(metadataTable.project_id, input.project_id)) - .limit(1) - .for("update"); - const setValues: Partial = {}; if (input.dodo_live_api_key !== undefined) setValues.dodo_live_api_key = input.dodo_live_api_key; @@ -51,21 +56,43 @@ export async function upsertMetadata( 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; - } + if (Object.keys(setValues).length === 0) return; - const insertValues: typeof metadataTable.$inferInsert = { - ...setValues, - project_id: input.project_id, - } as typeof metadataTable.$inferInsert; - await txn.insert(metadataTable).values(insertValues); + await txn + .insert(metadataTable) + .values({ + project_id: input.project_id, + dodo_live_api_key: requireField( + input.dodo_live_api_key, + "dodo_live_api_key" + ), + dodo_test_api_key: requireField( + input.dodo_test_api_key, + "dodo_test_api_key" + ), + dodo_live_product_id: requireField( + input.dodo_live_product_id, + "dodo_live_product_id" + ), + dodo_test_product_id: requireField( + input.dodo_test_product_id, + "dodo_test_product_id" + ), + dodo_live_webhook_secret: requireField( + input.dodo_live_webhook_secret, + "dodo_live_webhook_secret" + ), + dodo_test_webhook_secret: requireField( + input.dodo_test_webhook_secret, + "dodo_test_webhook_secret" + ), + redirect_url: requireField(input.redirect_url, "redirect_url"), + currency: input.currency, + }) + .onConflictDoUpdate({ + target: metadataTable.project_id, + set: setValues, + }); } catch (e) { throw StorageError.insertFailed( "Failed to upsert metadata record", @@ -89,6 +116,7 @@ export async function getMetadata( .select() .from(metadataTable) .where(eq(metadataTable.project_id, project_id)) + .orderBy(asc(metadataTable.id)) .limit(1); return metadata; } diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index 49d6c0a..56b10fd 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -247,6 +247,10 @@ export const paymentEventsRelation = relations( fields: [paymentEventsTable.userId, paymentEventsTable.project_id], references: [usersTable.id, usersTable.project_id], }), + apiKey: one(apiKeysTable, { + fields: [paymentEventsTable.apiKeyId], + references: [apiKeysTable.id], + }), session: one(sessionsTable, { fields: [paymentEventsTable.proxyId], references: [sessionsTable.proxy_link_id], @@ -329,24 +333,32 @@ export const tagsTable = pgTable("tags", { .notNull(), }); -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"), - project_id: uuid("project_id") - .references(() => projectTable.project_id) - .notNull(), - redirect_url: text("redirect_url").notNull(), -}); +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"), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .notNull(), + redirect_url: text("redirect_url").notNull(), + }, + (table) => ({ + projectIdUnique: uniqueIndex("metadata_project_id_unique").on( + table.project_id + ), + }) +); export const expressionsTable = pgTable("expressions", { id: uuid("id").primaryKey().defaultRandom(), From ecdf7d07b0b7d8b7e12dada817f8fc8d35bc8959 Mon Sep 17 00:00:00 2001 From: Jayadeep Bejoy Date: Tue, 16 Jun 2026 23:38:13 +0530 Subject: [PATCH 10/10] fix(security): here and there --- drizzle/0000_flaky_mandroid.sql | 170 --- drizzle/meta/0000_snapshot.json | 1123 -------------------- drizzle/meta/_journal.json | 13 - src/routes/http/api/apiKeys.ts | 16 +- src/routes/http/api/webhookEndpoints.ts | 18 + src/storage/db/postgres/helpers/apiKeys.ts | 9 +- src/storage/db/postgres/schema.ts | 2 +- 7 files changed, 36 insertions(+), 1315 deletions(-) delete mode 100644 drizzle/0000_flaky_mandroid.sql delete mode 100644 drizzle/meta/0000_snapshot.json delete mode 100644 drizzle/meta/_journal.json diff --git a/drizzle/0000_flaky_mandroid.sql b/drizzle/0000_flaky_mandroid.sql deleted file mode 100644 index ebbdb3a..0000000 --- a/drizzle/0000_flaky_mandroid.sql +++ /dev/null @@ -1,170 +0,0 @@ -CREATE TABLE "ai_token_usage_events" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "event_id" uuid NOT NULL, - "idempotency_key" text NOT NULL, - "reported_timestamp" timestamp with time zone NOT NULL, - "ingested_timestamp" timestamp with time zone DEFAULT now() NOT NULL, - "user_id" uuid NOT NULL, - "api_key_id" uuid NOT NULL, - "project_id" uuid NOT NULL, - "mode" text NOT NULL, - "model" text NOT NULL, - "provider" text NOT NULL, - "metrics" jsonb NOT NULL, - "metadata" jsonb, - CONSTRAINT "ai_token_usage_events_idempotency_key_unique" UNIQUE("idempotency_key") -); ---> statement-breakpoint -CREATE TABLE "api_keys" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "name" text NOT NULL, - "key" text NOT NULL, - "role" text DEFAULT 'dashboard' NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "project_id" uuid NOT NULL, - "revoked" boolean DEFAULT false NOT NULL, - "revoked_at" timestamp with time zone, - CONSTRAINT "api_keys_key_unique" UNIQUE("key") -); ---> statement-breakpoint -CREATE TABLE "basic_usage_events" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "event_id" uuid NOT NULL, - "idempotency_key" text NOT NULL, - "reported_timestamp" timestamp with time zone NOT NULL, - "ingested_timestamp" timestamp with time zone DEFAULT now() NOT NULL, - "user_id" uuid NOT NULL, - "api_key_id" uuid NOT NULL, - "project_id" uuid NOT NULL, - "mode" text NOT NULL, - "type" text NOT NULL, - "debit_amount" bigint NOT NULL, - "metadata" jsonb, - CONSTRAINT "basic_usage_events_idempotency_key_unique" UNIQUE("idempotency_key") -); ---> statement-breakpoint -CREATE TABLE "expressions" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "key" text NOT NULL, - "expr" text NOT NULL, - "project_id" uuid NOT NULL, - "deleted_at" timestamp with time zone -); ---> statement-breakpoint -CREATE TABLE "metadata" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "last_run_at" timestamp with time zone, - "dodo_live_api_key" text NOT NULL, - "dodo_test_api_key" text NOT NULL, - "dodo_live_product_id" text NOT NULL, - "dodo_test_product_id" text NOT NULL, - "dodo_live_webhook_secret" text NOT NULL, - "dodo_test_webhook_secret" text NOT NULL, - "currency" text DEFAULT 'usd' NOT NULL, - "project_id" uuid NOT NULL, - "redirect_url" text NOT NULL -); ---> statement-breakpoint -CREATE TABLE "payment_events" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "reported_timestamp" timestamp with time zone NOT NULL, - "ingested_timestamp" timestamp with time zone DEFAULT now() NOT NULL, - "user_id" uuid NOT NULL, - "api_key_id" uuid NOT NULL, - "project_id" uuid NOT NULL, - "mode" text NOT NULL, - "credit_amount" bigint NOT NULL, - "proxy_id" uuid NOT NULL -); ---> statement-breakpoint -CREATE TABLE "projects" ( - "project_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "product_id" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "sessions" ( - "proxy_link_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "session_id" text NOT NULL, - "processed" text DEFAULT 'pending' NOT NULL, - "user_id" uuid NOT NULL, - "api_key_id" uuid NOT NULL, - "billed_upto" timestamp with time zone NOT NULL, - "checkout_url" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "project_id" uuid NOT NULL, - "mode" text DEFAULT 'production' NOT NULL, - CONSTRAINT "sessions_session_id_unique" UNIQUE("session_id") -); ---> statement-breakpoint -CREATE TABLE "tags" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "key" text NOT NULL, - "amount" integer NOT NULL, - "deleted_at" timestamp with time zone, - "project_id" uuid NOT NULL -); ---> statement-breakpoint -CREATE TABLE "users" ( - "id" uuid NOT NULL, - "last_billed_timestamp" timestamp with time zone DEFAULT '0001-01-01T00:00:00.000Z' NOT NULL, - "payment_provider_user_id" text, - "project_id" uuid NOT NULL, - "mode" text DEFAULT 'production' NOT NULL, - CONSTRAINT "users_id_project_id_pk" PRIMARY KEY("id","project_id") -); ---> statement-breakpoint -CREATE TABLE "webhook_deliveries" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "endpoint_id" uuid NOT NULL, - "event_id" text NOT NULL, - "event_type" text NOT NULL, - "resource" text NOT NULL, - "action" text NOT NULL, - "status" text NOT NULL, - "request_body" jsonb, - "response_status" integer, - "error" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "project_id" uuid NOT NULL -); ---> statement-breakpoint -CREATE TABLE "webhook_endpoints" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "api_key_id" uuid NOT NULL, - "url" text NOT NULL, - "private_key" text NOT NULL, - "public_key" text NOT NULL, - "project_id" uuid NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "deleted_at" timestamp with time zone -); ---> statement-breakpoint -ALTER TABLE "ai_token_usage_events" ADD CONSTRAINT "ai_token_usage_events_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "ai_token_usage_events" ADD CONSTRAINT "ai_token_usage_events_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "ai_token_usage_events" ADD CONSTRAINT "ai_token_usage_events_user_id_project_id_users_id_project_id_fk" FOREIGN KEY ("user_id","project_id") REFERENCES "public"."users"("id","project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "basic_usage_events" ADD CONSTRAINT "basic_usage_events_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "basic_usage_events" ADD CONSTRAINT "basic_usage_events_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "basic_usage_events" ADD CONSTRAINT "basic_usage_events_user_id_project_id_users_id_project_id_fk" FOREIGN KEY ("user_id","project_id") REFERENCES "public"."users"("id","project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "expressions" ADD CONSTRAINT "expressions_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "metadata" ADD CONSTRAINT "metadata_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "payment_events" ADD CONSTRAINT "payment_events_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "payment_events" ADD CONSTRAINT "payment_events_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "payment_events" ADD CONSTRAINT "payment_events_proxy_id_sessions_proxy_link_id_fk" FOREIGN KEY ("proxy_id") REFERENCES "public"."sessions"("proxy_link_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "payment_events" ADD CONSTRAINT "payment_events_user_id_project_id_users_id_project_id_fk" FOREIGN KEY ("user_id","project_id") REFERENCES "public"."users"("id","project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "sessions" ADD CONSTRAINT "sessions_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "sessions" ADD CONSTRAINT "sessions_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_project_id_users_id_project_id_fk" FOREIGN KEY ("user_id","project_id") REFERENCES "public"."users"("id","project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "tags" ADD CONSTRAINT "tags_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "users" ADD CONSTRAINT "users_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoints"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -CREATE UNIQUE INDEX "unique_active_name" ON "api_keys" USING btree ("name") WHERE "api_keys"."revoked" = false;--> statement-breakpoint -CREATE UNIQUE INDEX "metadata_project_id_unique" ON "metadata" USING btree ("project_id");--> statement-breakpoint -CREATE UNIQUE INDEX "unique_session_id" ON "sessions" USING btree ("session_id");--> statement-breakpoint -CREATE UNIQUE INDEX "unique_webhook_api_key" ON "webhook_endpoints" USING btree ("api_key_id"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json deleted file mode 100644 index d0152e9..0000000 --- a/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,1123 +0,0 @@ -{ - "id": "537f83cc-724b-4367-8588-57d2a7c2ae66", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.ai_token_usage_events": { - "name": "ai_token_usage_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "event_id": { - "name": "event_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "idempotency_key": { - "name": "idempotency_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "reported_timestamp": { - "name": "reported_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "ingested_timestamp": { - "name": "ingested_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "api_key_id": { - "name": "api_key_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "mode": { - "name": "mode", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "model": { - "name": "model", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "metrics": { - "name": "metrics", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "ai_token_usage_events_api_key_id_api_keys_id_fk": { - "name": "ai_token_usage_events_api_key_id_api_keys_id_fk", - "tableFrom": "ai_token_usage_events", - "tableTo": "api_keys", - "columnsFrom": ["api_key_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "ai_token_usage_events_project_id_projects_project_id_fk": { - "name": "ai_token_usage_events_project_id_projects_project_id_fk", - "tableFrom": "ai_token_usage_events", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["project_id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "ai_token_usage_events_user_id_project_id_users_id_project_id_fk": { - "name": "ai_token_usage_events_user_id_project_id_users_id_project_id_fk", - "tableFrom": "ai_token_usage_events", - "tableTo": "users", - "columnsFrom": ["user_id", "project_id"], - "columnsTo": ["id", "project_id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "ai_token_usage_events_idempotency_key_unique": { - "name": "ai_token_usage_events_idempotency_key_unique", - "nullsNotDistinct": false, - "columns": ["idempotency_key"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.api_keys": { - "name": "api_keys", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'dashboard'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "revoked": { - "name": "revoked", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "unique_active_name": { - "name": "unique_active_name", - "columns": [ - { - "expression": "name", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"api_keys\".\"revoked\" = false", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "api_keys_project_id_projects_project_id_fk": { - "name": "api_keys_project_id_projects_project_id_fk", - "tableFrom": "api_keys", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["project_id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "api_keys_key_unique": { - "name": "api_keys_key_unique", - "nullsNotDistinct": false, - "columns": ["key"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.basic_usage_events": { - "name": "basic_usage_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "event_id": { - "name": "event_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "idempotency_key": { - "name": "idempotency_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "reported_timestamp": { - "name": "reported_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "ingested_timestamp": { - "name": "ingested_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "api_key_id": { - "name": "api_key_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "mode": { - "name": "mode", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "debit_amount": { - "name": "debit_amount", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "basic_usage_events_api_key_id_api_keys_id_fk": { - "name": "basic_usage_events_api_key_id_api_keys_id_fk", - "tableFrom": "basic_usage_events", - "tableTo": "api_keys", - "columnsFrom": ["api_key_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "basic_usage_events_project_id_projects_project_id_fk": { - "name": "basic_usage_events_project_id_projects_project_id_fk", - "tableFrom": "basic_usage_events", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["project_id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "basic_usage_events_user_id_project_id_users_id_project_id_fk": { - "name": "basic_usage_events_user_id_project_id_users_id_project_id_fk", - "tableFrom": "basic_usage_events", - "tableTo": "users", - "columnsFrom": ["user_id", "project_id"], - "columnsTo": ["id", "project_id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "basic_usage_events_idempotency_key_unique": { - "name": "basic_usage_events_idempotency_key_unique", - "nullsNotDistinct": false, - "columns": ["idempotency_key"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.expressions": { - "name": "expressions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expr": { - "name": "expr", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "expressions_project_id_projects_project_id_fk": { - "name": "expressions_project_id_projects_project_id_fk", - "tableFrom": "expressions", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["project_id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.metadata": { - "name": "metadata", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "last_run_at": { - "name": "last_run_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "dodo_live_api_key": { - "name": "dodo_live_api_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "dodo_test_api_key": { - "name": "dodo_test_api_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "dodo_live_product_id": { - "name": "dodo_live_product_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "dodo_test_product_id": { - "name": "dodo_test_product_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "dodo_live_webhook_secret": { - "name": "dodo_live_webhook_secret", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "dodo_test_webhook_secret": { - "name": "dodo_test_webhook_secret", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "currency": { - "name": "currency", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'usd'" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "redirect_url": { - "name": "redirect_url", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "metadata_project_id_unique": { - "name": "metadata_project_id_unique", - "columns": [ - { - "expression": "project_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "metadata_project_id_projects_project_id_fk": { - "name": "metadata_project_id_projects_project_id_fk", - "tableFrom": "metadata", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["project_id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.payment_events": { - "name": "payment_events", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "reported_timestamp": { - "name": "reported_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "ingested_timestamp": { - "name": "ingested_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "api_key_id": { - "name": "api_key_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "mode": { - "name": "mode", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "credit_amount": { - "name": "credit_amount", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "proxy_id": { - "name": "proxy_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "payment_events_api_key_id_api_keys_id_fk": { - "name": "payment_events_api_key_id_api_keys_id_fk", - "tableFrom": "payment_events", - "tableTo": "api_keys", - "columnsFrom": ["api_key_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "payment_events_project_id_projects_project_id_fk": { - "name": "payment_events_project_id_projects_project_id_fk", - "tableFrom": "payment_events", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["project_id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "payment_events_proxy_id_sessions_proxy_link_id_fk": { - "name": "payment_events_proxy_id_sessions_proxy_link_id_fk", - "tableFrom": "payment_events", - "tableTo": "sessions", - "columnsFrom": ["proxy_id"], - "columnsTo": ["proxy_link_id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "payment_events_user_id_project_id_users_id_project_id_fk": { - "name": "payment_events_user_id_project_id_users_id_project_id_fk", - "tableFrom": "payment_events", - "tableTo": "users", - "columnsFrom": ["user_id", "project_id"], - "columnsTo": ["id", "project_id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.projects": { - "name": "projects", - "schema": "", - "columns": { - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "product_id": { - "name": "product_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sessions": { - "name": "sessions", - "schema": "", - "columns": { - "proxy_link_id": { - "name": "proxy_link_id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "processed": { - "name": "processed", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "api_key_id": { - "name": "api_key_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "billed_upto": { - "name": "billed_upto", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "checkout_url": { - "name": "checkout_url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "mode": { - "name": "mode", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'production'" - } - }, - "indexes": { - "unique_session_id": { - "name": "unique_session_id", - "columns": [ - { - "expression": "session_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "sessions_api_key_id_api_keys_id_fk": { - "name": "sessions_api_key_id_api_keys_id_fk", - "tableFrom": "sessions", - "tableTo": "api_keys", - "columnsFrom": ["api_key_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "sessions_project_id_projects_project_id_fk": { - "name": "sessions_project_id_projects_project_id_fk", - "tableFrom": "sessions", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["project_id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "sessions_user_id_project_id_users_id_project_id_fk": { - "name": "sessions_user_id_project_id_users_id_project_id_fk", - "tableFrom": "sessions", - "tableTo": "users", - "columnsFrom": ["user_id", "project_id"], - "columnsTo": ["id", "project_id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "sessions_session_id_unique": { - "name": "sessions_session_id_unique", - "nullsNotDistinct": false, - "columns": ["session_id"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tags": { - "name": "tags", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "amount": { - "name": "amount", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "tags_project_id_projects_project_id_fk": { - "name": "tags_project_id_projects_project_id_fk", - "tableFrom": "tags", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["project_id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "last_billed_timestamp": { - "name": "last_billed_timestamp", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "'0001-01-01T00:00:00.000Z'" - }, - "payment_provider_user_id": { - "name": "payment_provider_user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "mode": { - "name": "mode", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'production'" - } - }, - "indexes": {}, - "foreignKeys": { - "users_project_id_projects_project_id_fk": { - "name": "users_project_id_projects_project_id_fk", - "tableFrom": "users", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["project_id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "users_id_project_id_pk": { - "name": "users_id_project_id_pk", - "columns": ["id", "project_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.webhook_deliveries": { - "name": "webhook_deliveries", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "endpoint_id": { - "name": "endpoint_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "event_id": { - "name": "event_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "event_type": { - "name": "event_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "resource": { - "name": "resource", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "request_body": { - "name": "request_body", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "response_status": { - "name": "response_status", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk": { - "name": "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk", - "tableFrom": "webhook_deliveries", - "tableTo": "webhook_endpoints", - "columnsFrom": ["endpoint_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "webhook_deliveries_project_id_projects_project_id_fk": { - "name": "webhook_deliveries_project_id_projects_project_id_fk", - "tableFrom": "webhook_deliveries", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["project_id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.webhook_endpoints": { - "name": "webhook_endpoints", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "api_key_id": { - "name": "api_key_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "private_key": { - "name": "private_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "public_key": { - "name": "public_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "project_id": { - "name": "project_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "unique_webhook_api_key": { - "name": "unique_webhook_api_key", - "columns": [ - { - "expression": "api_key_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "webhook_endpoints_api_key_id_api_keys_id_fk": { - "name": "webhook_endpoints_api_key_id_api_keys_id_fk", - "tableFrom": "webhook_endpoints", - "tableTo": "api_keys", - "columnsFrom": ["api_key_id"], - "columnsTo": ["id"], - "onDelete": "no action", - "onUpdate": "no action" - }, - "webhook_endpoints_project_id_projects_project_id_fk": { - "name": "webhook_endpoints_project_id_projects_project_id_fk", - "tableFrom": "webhook_endpoints", - "tableTo": "projects", - "columnsFrom": ["project_id"], - "columnsTo": ["project_id"], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json deleted file mode 100644 index 9d448d4..0000000 --- a/drizzle/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1781630177894, - "tag": "0000_flaky_mandroid", - "breakpoints": true - } - ] -} diff --git a/src/routes/http/api/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 4de4ff9..1c86982 100644 --- a/src/routes/http/api/apiKeys.ts +++ b/src/routes/http/api/apiKeys.ts @@ -129,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 @@ -153,7 +153,11 @@ export async function handleListApiKeys( ) ) .where( - and(ne(apiKeysTable.role, "dashboard"), eq(apiKeysTable.revoked, false)) + and( + ne(apiKeysTable.role, "dashboard"), + eq(apiKeysTable.revoked, false), + eq(apiKeysTable.project_id, auth.project_id) + ) ) .orderBy(apiKeysTable.createdAt); @@ -191,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(); @@ -201,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.id, params.id), + eq(apiKeysTable.revoked, false), + eq(apiKeysTable.project_id, auth.project_id) + ) ); if ((result.count ?? 0) === 0) { diff --git a/src/routes/http/api/webhookEndpoints.ts b/src/routes/http/api/webhookEndpoints.ts index 2ab0fc5..e9075f0 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.project_id !== auth.project_id) { + 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", @@ -300,6 +309,15 @@ export async function handleSendTestWebhook( return { error: "API key not found" }; } + if (targetKey.project_id !== auth.project_id) { + 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 !== "test") { builder.setError(400, { type: "ValidationError", diff --git a/src/storage/db/postgres/helpers/apiKeys.ts b/src/storage/db/postgres/helpers/apiKeys.ts index 6cae4ef..545fd60 100644 --- a/src/storage/db/postgres/helpers/apiKeys.ts +++ b/src/storage/db/postgres/helpers/apiKeys.ts @@ -79,14 +79,15 @@ type ApiKeyRecord = { project_id: string; }; -export async function getApiKeyRoleById( - id: string -): Promise<{ role: "dashboard" | "production" | "test" } | null> { +export async function getApiKeyRoleById(id: string): Promise<{ + role: "dashboard" | "production" | "test"; + project_id: string; +} | null> { const db = getPostgresDB(); try { const [record] = await db - .select({ role: apiKeysTable.role }) + .select({ role: apiKeysTable.role, project_id: apiKeysTable.project_id }) .from(apiKeysTable) .where(eq(apiKeysTable.id, id)) .limit(1); diff --git a/src/storage/db/postgres/schema.ts b/src/storage/db/postgres/schema.ts index 56b10fd..11f561d 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -135,7 +135,7 @@ export const apiKeysTable = pgTable( }, (table) => ({ uniqueActiveName: uniqueIndex("unique_active_name") - .on(table.name) + .on(table.project_id, table.name) .where(sql`${table.revoked} = false`), }) );