Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const db = getPostgresDB();
Expand All @@ -24,6 +24,7 @@ async function insertWebhookEndpoint(apiKeyId: string): Promise<void> {
url: "https://example.com/webhook",
privateKey: "test-private-key",
publicKey: "test-public-key",
project_id: TEST_PROJECT_ID,
});
}

Expand Down
9 changes: 8 additions & 1 deletion src/__tests__/createAPIKey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 };
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion src/__tests__/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ export async function clearDatabase() {
users,
tags,
metadata,
expressions
expressions,
webhook_endpoints,
webhook_deliveries,
projects
RESTART IDENTITY CASCADE
`);

Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/fixtures/apiKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand All @@ -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 });

Expand All @@ -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 };
Expand All @@ -37,6 +51,7 @@ export async function insertKey(
role: "dashboard" | "test" | "production",
overrides: Partial<{ revoked: boolean; expiresAt: string }> = {}
): Promise<string> {
await ensureTestProject();
const db = getPostgresDB();
const [key] = await db
.insert(apiKeysTable)
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/context/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export interface AuthContext {
apiKeyId: string;
role: ApiKeyRole;
mode: "production" | "test" | null;
project_id: string;
}
9 changes: 9 additions & 0 deletions src/errors/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/interceptors/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export function authInterceptor<Req, Res>(
apiKeyId: cached.id,
role: cached.role,
mode: cached.mode,
project_id: cached.project_id,
};
wideEventBuilder?.setAuth(cached.id, true);

Expand Down Expand Up @@ -214,19 +215,25 @@ export function authInterceptor<Req, Res>(
);
}

if (!apiKeyRecord.project_id || apiKeyRecord.project_id === "") {
return callback?.(AuthError.projectNotFound());
}

const recordMode = getModeForRole(apiKeyRecord.role as ApiKeyRole);

apiKeyCache.set(apiKeyHash, {
id: apiKeyRecord.id,
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);

Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions src/routes/gRPC/auth/createAPIKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export async function createAPIKey(
key: apiKeyHash,
role: validatedData.role,
expiresAt: expiresAt.toISO(),
project_id: auth.project_id,
});

if (!keyEventData) {
Expand Down
22 changes: 16 additions & 6 deletions src/routes/gRPC/payment/createCheckoutLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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 })
Expand All @@ -103,7 +105,8 @@ export async function createCheckoutLink(
const existingId = await checkIfExistingCheckoutLink(
txn,
validatedData.userId,
mode
mode,
project_id
);

if (existingId) {
Expand All @@ -118,6 +121,7 @@ export async function createCheckoutLink(
auth.apiKeyId,
mode,
checkoutResult.checkoutUrl,
project_id,
txn
);
wideEventBuilder?.setPaymentContext({ sessionId: sessionResult.id });
Expand Down Expand Up @@ -169,15 +173,21 @@ async function createCheckoutSession(
userId: string,
apiKeyId: string,
beforeTimestamp: DateTime,
mode: "test" | "production"
mode: "test" | "production",
project_id: string
): Promise<CheckoutResult> {
const params: CheckoutParams = {
customPrice,
userId,
apiKeyId,
};

const checkoutResult = await createProviderCheckout(config, params, mode);
const checkoutResult = await createProviderCheckout(
config,
params,
mode,
project_id
);

if (
!checkoutResult.checkoutUrl ||
Expand Down
58 changes: 31 additions & 27 deletions src/routes/gRPC/payment/paymentProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, DodoPayments>();
const testClients = new Map<string, DodoPayments>();

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<DodoPayments> {
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
Expand All @@ -76,13 +82,10 @@ export interface CheckoutResult {
}

export async function getPaymentProviderConfig(
mode: "test" | "production"
mode: "test" | "production",
project_id: string
): Promise<PaymentProviderConfig> {
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();
Expand All @@ -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<CheckoutResult> {
const client = await getDodoClient(mode);
const client = await getDodoClient(mode, project_id);

const session = await client.checkoutSessions.create({
product_cart: [
Expand Down
Loading
Loading