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/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..1336250 100644 --- a/src/routes/gRPC/payment/createCheckoutLink.ts +++ b/src/routes/gRPC/payment/createCheckoutLink.ts @@ -63,8 +63,9 @@ 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); @@ -85,14 +86,15 @@ export async function createCheckoutLink( validatedData.userId, auth.apiKeyId, beforeTimestamp, - mode + mode, + project_id ); const checkoutLink = await executeInTransaction( db, "create checkout link", async (txn) => { - await ensureUserExists(validatedData.userId, txn); + await ensureUserExists(validatedData.userId, project_id, txn); await txn .select({ id: usersTable.id }) @@ -103,7 +105,8 @@ export async function createCheckoutLink( const existingId = await checkIfExistingCheckoutLink( txn, validatedData.userId, - mode + mode, + project_id ); if (existingId) { @@ -118,6 +121,7 @@ export async function createCheckoutLink( auth.apiKeyId, mode, checkoutResult.checkoutUrl, + project_id, txn ); wideEventBuilder?.setPaymentContext({ sessionId: sessionResult.id }); @@ -169,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, @@ -177,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/apiKeys.ts b/src/routes/http/api/apiKeys.ts index 2a18fd5..1c86982 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); @@ -127,7 +129,7 @@ export async function handleListApiKeys( ); try { - await authenticateHttpApiKey(request.headers.authorization); + const auth = await authenticateHttpApiKey(request.headers.authorization); const db = getPostgresDB(); const keys = await db @@ -151,7 +153,11 @@ export async function handleListApiKeys( ) ) .where( - and(ne(apiKeysTable.role, "dashboard"), eq(apiKeysTable.revoked, false)) + and( + ne(apiKeysTable.role, "dashboard"), + eq(apiKeysTable.revoked, false), + eq(apiKeysTable.project_id, auth.project_id) + ) ) .orderBy(apiKeysTable.createdAt); @@ -189,7 +195,7 @@ export async function handleRevokeApiKey( ); try { - await authenticateHttpApiKey(request.headers.authorization); + const auth = await authenticateHttpApiKey(request.headers.authorization); const params = request.params as { id: string }; const db = getPostgresDB(); @@ -199,7 +205,11 @@ export async function handleRevokeApiKey( .update(apiKeysTable) .set({ revoked: true, revokedAt: now }) .where( - and(eq(apiKeysTable.id, params.id), eq(apiKeysTable.revoked, false)) + and( + eq(apiKeysTable.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/expressions.ts b/src/routes/http/api/expressions.ts index 59e63d9..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); @@ -84,15 +84,15 @@ 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); validateExprSyntax(validated.expr); - await resolveExprRefsInExpression(validated.expr); + await resolveExprRefsInExpression(validated.expr, new Set(), project_id); - await createExpression(validated.key, validated.expr); + await createExpression(validated.key, validated.expr, project_id); builder.setSuccess(200); reply.code(200); @@ -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/handleProjects.ts b/src/routes/http/api/handleProjects.ts new file mode 100644 index 0000000..bd6bb62 --- /dev/null +++ b/src/routes/http/api/handleProjects.ts @@ -0,0 +1,73 @@ +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({ + 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, 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(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..d628b8c 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, @@ -16,6 +16,9 @@ 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"; +import { executeInTransaction } from "../../../storage/adapter/postgres/handlers/addEventUtils.ts"; +import { getPostgresDB } from "../../../storage/db/postgres/db.ts"; export async function handleOnboarding( request: FastifyRequest, @@ -29,7 +32,7 @@ export async function handleOnboarding( try { const authHeader = request.headers.authorization; - await authenticateHttpApiKey(authHeader); + const { project_id } = await authenticateHttpApiKey(authHeader); const body = await request.body; const validated = onboardingSchema.parse(body); @@ -57,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"], }); @@ -65,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"], }); @@ -84,18 +87,31 @@ export async function handleOnboarding( return {}; } - await upsertMetadata({ - dodo_live_api_key: encrypt(validated.dodoLiveApiKey), - dodo_test_api_key: encrypt(validated.dodoTestApiKey), - dodo_live_product_id: validated.dodoLiveProductId, - dodo_test_product_id: validated.dodoTestProductId, - dodo_live_webhook_secret: encrypt(liveSecret), - dodo_test_webhook_secret: encrypt(testSecret), - currency: validated.currency, - redirect_url: validated.redirectUrl, - }); - - clearClients(); + 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); builder.setSuccess(200); @@ -157,9 +173,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); @@ -183,6 +199,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..85d2dac 100644 --- a/src/routes/http/api/registerApiRoutes.ts +++ b/src/routes/http/api/registerApiRoutes.ts @@ -19,6 +19,7 @@ import { handleRevokeApiKey, } from "./apiKeys.ts"; import { handleListDeliveries } from "./webhookDeliveries.ts"; +import { handleCreateProject } from "./handleProjects.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..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); @@ -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); @@ -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/api/webhookEndpoints.ts b/src/routes/http/api/webhookEndpoints.ts index a02547b..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", @@ -133,7 +142,8 @@ export async function handleCreateWebhookEndpoint( targetApiKeyId, validated.url, keyPair.privateKeyPem, - keyPair.publicKeyPrefixed + keyPair.publicKeyPrefixed, + auth.project_id ); invalidateWebhookEndpointCache(targetApiKeyId); @@ -299,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/routes/http/createdCheckout.ts b/src/routes/http/createdCheckout.ts index 878b648..0974413 100644 --- a/src/routes/http/createdCheckout.ts +++ b/src/routes/http/createdCheckout.ts @@ -72,11 +72,13 @@ export async function handleDodoWebhook( timestamp: string | undefined, webhookId: string | undefined, mode: "production" | "test", + project_id: string, builder: WideEventBuilder ): Promise { try { const client = await getDodoClient( - mode === "production" ? "production" : "test" + mode === "production" ? "production" : "test", + project_id ); const headers = buildWebhookHeaders(signature, timestamp, webhookId); const webhookPayload = unwrapWebhookPayload(client, rawBody, headers); @@ -180,7 +182,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) => { @@ -190,13 +192,14 @@ 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, 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/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/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/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/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..545fd60 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,16 +76,18 @@ type ApiKeyRecord = { role: string; expiresAt: string; revoked: boolean; + 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); @@ -109,6 +113,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..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; @@ -44,7 +56,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(); @@ -53,7 +66,11 @@ export async function createExpression( .select({ id: expressionsTable.id }) .from(expressionsTable) .where( - and(eq(expressionsTable.key, key), isNull(expressionsTable.deletedAt)) + and( + eq(expressionsTable.key, key), + eq(expressionsTable.project_id, project_id), + isNull(expressionsTable.deletedAt) + ) ) .limit(1); @@ -65,7 +82,7 @@ export async function createExpression( return; } - await db.insert(expressionsTable).values({ key, expr }); + await db.insert(expressionsTable).values({ key, expr, project_id }); } catch (e) { throw StorageError.insertFailed( `Failed to upsert expression '${key}'`, @@ -74,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/metadata.ts b/src/storage/db/postgres/helpers/metadata.ts index 4d83430..14b46ec 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 { eq, asc } 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; @@ -13,21 +16,29 @@ export type UpsertMetadataInput = { dodo_test_webhook_secret?: string; currency?: string; redirect_url?: string; + 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 + 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 }) - .from(metadataTable) - .limit(1) - .for("update"); - const setValues: Partial = {}; if (input.dodo_live_api_key !== undefined) setValues.dodo_live_api_key = input.dodo_live_api_key; @@ -45,33 +56,67 @@ 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, - } 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", e instanceof Error ? e : new Error(String(e)) ); } - }); + }; + + if (tx) { + await run(tx); + } else { + await executeInTransaction(db, "upsert metadata", run); + } } -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)) + .orderBy(asc(metadataTable.id)) + .limit(1); return metadata; } 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..4b37240 --- /dev/null +++ b/src/storage/db/postgres/helpers/projects.ts @@ -0,0 +1,50 @@ +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, + tx?: DbClient +): Promise { + const db = tx ?? 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..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()}` ) ) @@ -74,6 +76,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 +100,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..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( @@ -22,14 +28,24 @@ 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 { 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]) { @@ -38,11 +54,13 @@ export async function createTag(key: string, amount: number): Promise { .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 }); + 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}'`, @@ -51,18 +69,26 @@ export async function createTag(key: string, amount: number): Promise { } } -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 eab1db6..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", @@ -36,6 +39,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,8 +47,8 @@ export async function ensureUserExists( try { await db .insert(usersTable) - .values({ id: userId }) - .onConflictDoNothing({ target: usersTable.id }); + .values({ id: userId, project_id: project_id }) + .onConflictDoNothing({ target: [usersTable.id, usersTable.project_id] }); } catch (e) { if ( e instanceof Error && 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..11f561d 100644 --- a/src/storage/db/postgres/schema.ts +++ b/src/storage/db/postgres/schema.ts @@ -9,30 +9,45 @@ 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"), - 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( @@ -43,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(), @@ -60,25 +73,36 @@ 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"), }, (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], references: [apiKeysTable.id], }), paymentEvents: many(paymentEventsTable), + project_id: one(projectTable, { + fields: [sessionsTable.project_id], + references: [projectTable.project_id], + }), })); export const apiKeysTable = pgTable( @@ -100,6 +124,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, @@ -108,89 +135,117 @@ export const apiKeysTable = pgTable( }, (table) => ({ uniqueActiveName: uniqueIndex("unique_active_name") - .on(table.name) + .on(table.project_id, table.name) .where(sql`${table.revoked} = false`), }) ); -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", { - 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(), - 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], references: [apiKeysTable.id], }), + project_id: one(projectTable, { + fields: [basicUsageEventsTable.project_id], + references: [projectTable.project_id], + }), }) ); -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(), - 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], + fields: [paymentEventsTable.userId, paymentEventsTable.project_id], + references: [usersTable.id, usersTable.project_id], }), apiKey: one(apiKeysTable, { fields: [paymentEventsTable.apiKeyId], @@ -200,47 +255,68 @@ export const paymentEventsRelation = relations( fields: [paymentEventsTable.proxyId], references: [sessionsTable.proxy_link_id], }), + project_id: one(projectTable, { + fields: [paymentEventsTable.project_id], + references: [projectTable.project_id], + }), }) ); -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(), - 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], references: [apiKeysTable.id], }), + project_id: one(projectTable, { + fields: [aiTokenUsageEventsTable.project_id], + references: [projectTable.project_id], + }), }) ); @@ -252,28 +328,45 @@ export const tagsTable = pgTable("tags", { withTimezone: true, mode: "string", }), + project_id: uuid("project_id") + .references(() => projectTable.project_id) + .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"), - 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(), 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 +383,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 +415,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 +441,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 +453,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/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