Skip to content
Merged
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
2 changes: 1 addition & 1 deletion proto
Submodule proto updated from 574d8e to 2d879d
89 changes: 87 additions & 2 deletions src/interceptors/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ import { apiKeyContextKey, type AuthContext } from "../context/auth";
import { AuthError } from "../errors/auth";
import { apiKeyCache } from "../utils/apiKeyCache";
import { getPostgresDB } from "../storage/db/postgres/db";
import { apiKeysTable } from "../storage/db/postgres/schema";
import { eq } from "drizzle-orm";
import {
apiKeysTable,
webhookEndpointsTable,
} from "../storage/db/postgres/schema";
import { eq, and, isNull } from "drizzle-orm";
import { hashAPIKey } from "../utils/hashAPIKey";
import { DateTime } from "luxon";
import {
Expand All @@ -25,9 +28,33 @@ import {
isValidApiKeyFormat,
} from "../utils/keyFormat";
import type { ApiKeyRole } from "../utils/keyFormat";
import { Cache } from "../utils/cacheStore";

const no_auth: string[] = [];

const WEBHOOK_REQUIRED_PATHS = [
"/event.v1.EventService/RegisterEvent",
"/event.v1.EventService/StreamEvents",
"/payment.v1.PaymentService/CreateCheckoutLink",
];

const webhookEndpointCache = Cache.getStore<string, boolean>(
"webhook-endpoints",
{
max: 1000,
ttlMs: 60 * 1000,
}
);

/**
* Invalidate the cached webhook endpoint existence for an API key.
* Must be called after upserting or deleting a webhook endpoint so
* the auth interceptor re-queries the database on the next request.
*/
export function invalidateWebhookEndpointCache(apiKeyId: string): void {
webhookEndpointCache.delete(apiKeyId);
}

interface GrpcCallContext {
[wideEventContextKey]: WideEventBuilder | null;
[apiKeyContextKey]: AuthContext | undefined;
Expand Down Expand Up @@ -58,6 +85,27 @@ export type GrpcFlexibleHandler = (
callback?: sendUnaryData<unknown>
) => void | Promise<void>;

async function checkWebhookEndpoint(apiKeyId: string): Promise<boolean> {
const cached = webhookEndpointCache.get(apiKeyId);
if (cached !== undefined) return cached;

const db = getPostgresDB();
const [endpoint] = await db
.select({ id: webhookEndpointsTable.id })
.from(webhookEndpointsTable)
.where(
and(
eq(webhookEndpointsTable.apiKeyId, apiKeyId),
isNull(webhookEndpointsTable.deletedAt)
)
)
.limit(1);

const exists = !!endpoint;
webhookEndpointCache.set(apiKeyId, exists);
return exists;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Auth interceptor for gRPC — validates API key, extracts role, sets context.
*/
Expand Down Expand Up @@ -103,6 +151,9 @@ export function authInterceptor<Req, Res>(
const mode = getModeForRole(role);
const apiKeyHash = hashAPIKey(apiKey);

const needsWebhook =
role !== "dashboard" && WEBHOOK_REQUIRED_PATHS.includes(fullPath);

const cached = apiKeyCache.get(apiKeyHash);
if (cached) {
if (cached.role !== role) {
Expand All @@ -118,6 +169,24 @@ export function authInterceptor<Req, Res>(
mode: cached.mode,
};
wideEventBuilder?.setAuth(cached.id, true);

if (needsWebhook) {
checkWebhookEndpoint(cached.id)
.then((hasEndpoint) => {
if (!hasEndpoint) {
return callback?.(
AuthError.permissionDenied(
"A webhook endpoint must be configured before using this API key. " +
"Register one via POST /api/v1/internals/webhook-endpoint"
)
);
}
return handler(call, callback);
})
.catch((error) => callback?.(error));
return;
}

return handler(call, callback);
}

Expand Down Expand Up @@ -162,6 +231,22 @@ export function authInterceptor<Req, Res>(
};
wideEventBuilder?.setAuth(apiKeyRecord.id, false);

if (needsWebhook) {
return checkWebhookEndpoint(apiKeyRecord.id)
.then((hasEndpoint) => {
if (!hasEndpoint) {
return callback?.(
AuthError.permissionDenied(
"A webhook endpoint must be configured before using this API key. " +
"Register one via POST /api/v1/internals/webhook-endpoint"
)
);
}
return handler(call, callback);
})
.catch((error) => callback?.(error));
}

return handler(call, callback);
})
.catch((error) => {
Expand Down
42 changes: 42 additions & 0 deletions src/routes/http/api/registerApiRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import type { FastifyRequest, FastifyReply } from "fastify";
import { handleOnboarding } from "./onboarding.ts";
import { handleListTags } from "./tags.ts";
import { handleListExpressions } from "./expressions.ts";
import {
handleCreateWebhookEndpoint,
handleGetWebhookEndpoint,
handleDeleteWebhookEndpoint,
handleSendTestWebhook,
handleGetPublicKey,
} from "./webhookEndpoints.ts";

export async function registerApiRoutes(
server: ReturnType<(typeof import("fastify"))["fastify"]>
Expand All @@ -26,4 +33,39 @@ export async function registerApiRoutes(
return handleListExpressions(request, reply);
}
);

server.post(
"/api/v1/internals/webhook-endpoint",
async (request: FastifyRequest, reply: FastifyReply) => {
return handleCreateWebhookEndpoint(request, reply);
}
);

server.get(
"/api/v1/internals/webhook-endpoint",
async (request: FastifyRequest, reply: FastifyReply) => {
return handleGetWebhookEndpoint(request, reply);
}
);

server.delete(
"/api/v1/internals/webhook-endpoint",
async (request: FastifyRequest, reply: FastifyReply) => {
return handleDeleteWebhookEndpoint(request, reply);
}
);

server.get(
"/api/v1/internals/webhook-endpoint/public-key",
async (request: FastifyRequest, reply: FastifyReply) => {
return handleGetPublicKey(request, reply);
}
);

server.post(
"/api/v1/internals/webhook-endpoint/send-test",
async (request: FastifyRequest, reply: FastifyReply) => {
return handleSendTestWebhook(request, reply);
}
);
}
Loading
Loading