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
59 changes: 52 additions & 7 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,16 @@ import { tracingMiddleware } from "./middleware/tracing.js";
import { beacon } from "./routes/beacon.js";
import { routes } from "./routes/index.js";
import { internalModels } from "./routes/internal-models.js";
import { platformConnect } from "./routes/platform-connect.js";
import { platformCustomers } from "./routes/platform-customers.js";
import { platformSessionRefresh } from "./routes/platform-session-refresh.js";
import { platformSessions } from "./routes/platform-sessions.js";
import { platformWallet } from "./routes/platform-wallet.js";
import { platformWebhooks } from "./routes/platform-webhooks.js";
import { publicApps } from "./routes/public-apps.js";
import { publicChatShares } from "./routes/public-chat-shares.js";
import { publicChatSupport } from "./routes/public-chat-support.js";
import { publicConfig } from "./routes/public-config.js";
import { publicContact } from "./routes/public-contact.js";
import { publicDiscounts } from "./routes/public-discounts.js";
import { publicNewsletter } from "./routes/public-newsletter.js";
Expand Down Expand Up @@ -61,17 +68,41 @@ app.use("*", tracingMiddleware);
app.use("*", requestLifecycleMiddleware);
app.use("*", honoRequestLogger);

const corsAllowList = process.env.ORIGIN_URLS?.split(",") ?? [
"http://localhost:3002",
"http://localhost:3003",
"http://localhost:3004",
"http://localhost:3005",
"http://localhost:3006",
];

// Embeddable SDK endpoints are called cross-origin from arbitrary developer
// frontends with a bearer session token (no cookies), so they reflect the
// request origin. The per-project `allowedOrigins` allowlist is enforced
// server-side in the end-user session middleware / gateway handler.
const EMBEDDABLE_CORS_PREFIXES = ["/v1/wallet", "/v1/sessions", "/v1/config"];

app.use(
"*",
cors({
origin: process.env.ORIGIN_URLS?.split(",") ?? [
"http://localhost:3002",
"http://localhost:3003",
"http://localhost:3004",
"http://localhost:3005",
"http://localhost:3006",
origin: (origin, c) => {
if (!origin) {
return corsAllowList[0];
}
if (corsAllowList.includes(origin)) {
return origin;
}
if (EMBEDDABLE_CORS_PREFIXES.some((p) => c.req.path.startsWith(p))) {
return origin;
}
return corsAllowList[0];
},
allowHeaders: [
"Content-Type",
"Authorization",
"Cache-Control",
"x-api-key",
],
allowHeaders: ["Content-Type", "Authorization", "Cache-Control"],
allowMethods: ["POST", "GET", "OPTIONS", "PUT", "PATCH", "DELETE"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
Expand Down Expand Up @@ -246,4 +277,18 @@ app.route("/", authHandler);

app.route("/v1/master", v1Master);

app.route("/v1", platformSessions);

app.route("/v1/sessions", platformSessionRefresh);

app.route("/v1/wallet", platformWallet);

app.route("/v1/customers", platformCustomers);

app.route("/v1/connect", platformConnect);

app.route("/v1/webhooks", platformWebhooks);

app.route("/v1/config", publicConfig);

app.route("/", routes);
110 changes: 110 additions & 0 deletions apps/api/src/lib/end-user-session-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { HTTPException } from "hono/http-exception";

import { db } from "@llmgateway/db";

import type { Context, Next } from "hono";

/**
* Embeddable SDK: authentication for browser requests bearing an ephemeral
* end-user session token (`es_…`). Validates the token + expiry, loads the bound
* wallet, and stashes the resolved session on the context. Shared by the wallet
* endpoints and the session-refresh endpoint.
*/
export interface AuthenticatedSession {
sessionId: string;
walletId: string;
endCustomerId: string;
projectId: string;
organizationId: string;
markupPercent: number;
/** Origins allowed to call with this session (CORS), from the project. */
allowedOrigins: string[] | null;
}

declare module "hono" {
interface ContextVariableMap {
endUserSession?: AuthenticatedSession;
}
}

export async function endUserSessionAuth(c: Context, next: Next) {
const authHeader = c.req.header("Authorization");
const token = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length).trim()
: c.req.header("x-api-key")?.trim();

if (!token) {
throw new HTTPException(401, {
message:
"Missing session token. Pass it as 'Authorization: Bearer es_…'.",
});
}

const session = await db.query.endUserSession.findFirst({
where: {
token: { eq: token },
status: { eq: "active" },
},
with: { wallet: { with: { endCustomer: true, project: true } } },
});

if (!session || !session.wallet) {
throw new HTTPException(401, { message: "Invalid session token" });
}

if (session.expiresAt.getTime() < Date.now()) {
throw new HTTPException(401, {
message: "Session expired. Mint a fresh session token from your backend.",
});
}

if (session.wallet.status !== "active") {
throw new HTTPException(402, { message: "Wallet is frozen" });
}

// Reject sessions whose end customer was blocked/deleted or whose project was
// deactivated/deleted after the token was minted.
if (
session.wallet.endCustomer &&
session.wallet.endCustomer.status !== "active"
) {
throw new HTTPException(401, { message: "End customer is inactive" });
}

const projectStatus = session.wallet.project?.status;
if (projectStatus && projectStatus !== "active") {
throw new HTTPException(401, { message: "Project is inactive" });
}

// Defense-in-depth origin allowlist (see gateway chat handler).
const allowedOrigins = session.wallet.project?.allowedOrigins ?? null;
const origin = c.req.header("Origin");
if (
origin &&
allowedOrigins &&
allowedOrigins.length > 0 &&
!allowedOrigins.includes(origin)
) {
throw new HTTPException(403, {
message: "Origin not allowed for this project",
});
}

const markupPercent = Number(
session.wallet.markupPercentOverride ??
session.wallet.project?.endUserMarkupPercent ??
"0",
);

c.set("endUserSession", {
sessionId: session.id,
walletId: session.wallet.id,
endCustomerId: session.wallet.endCustomerId,
projectId: session.wallet.projectId,
organizationId: session.wallet.organizationId,
markupPercent: Number.isFinite(markupPercent) ? markupPercent : 0,
allowedOrigins: session.wallet.project?.allowedOrigins ?? null,
});

await next();
}
79 changes: 79 additions & 0 deletions apps/api/src/lib/platform-secret-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { HTTPException } from "hono/http-exception";

import { db } from "@llmgateway/db";

import type { Context, Next } from "hono";

/**
* Embeddable SDK: authentication for a developer's backend using a platform
* **secret key** (`sk_…`, an apiKey row with keyType="platform_secret"). Shared
* by the session-mint, wallet-management, customer-analytics, and Connect-payout
* endpoints. Mirrors the v1-master.ts token-auth pattern.
*/
export interface AuthenticatedPlatformKey {
apiKeyId: string;
projectId: string;
organizationId: string;
createdBy: string;
}

declare module "hono" {
interface ContextVariableMap {
platformKey?: AuthenticatedPlatformKey;
}
}

export async function platformSecretAuth(c: Context, next: Next) {
const authHeader = c.req.header("Authorization");
const token = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length).trim()
: c.req.header("x-api-key")?.trim();

if (!token) {
throw new HTTPException(401, {
message:
"Missing secret key. Pass it as 'Authorization: Bearer sk_…' or 'x-api-key'.",
});
}

const row = await db.query.apiKey.findFirst({
where: {
token: { eq: token },
keyType: { eq: "platform_secret" },
status: { eq: "active" },
},
with: { project: { with: { organization: true } } },
});

if (!row || !row.project) {
throw new HTTPException(401, { message: "Invalid platform secret key" });
}

// Strict active-only: reject inactive/deleted projects, not just deleted ones.
if (row.project.status && row.project.status !== "active") {
throw new HTTPException(403, { message: "Project is not active" });
}

if (
row.project.organization &&
row.project.organization.status !== "active"
) {
throw new HTTPException(403, { message: "Organization is not active" });
}

if (!row.project.endUserEnabled) {
throw new HTTPException(403, {
message:
"End-user sessions are not enabled for this project. Enable them in project settings.",
});
}

c.set("platformKey", {
apiKeyId: row.id,
projectId: row.projectId,
organizationId: row.project.organizationId,
createdBy: row.createdBy,
});

await next();
}
77 changes: 77 additions & 0 deletions apps/api/src/routes/activity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,83 @@ describe("activity endpoint", () => {
expect(Array.isArray(data.activity)).toBe(true);
});

test("GET /activity should include end-user customer keys in api key breakdown", async () => {
const today = new Date();

await db.insert(tables.endCustomer).values({
id: "test-end-customer-id",
organizationId: "test-org-id",
projectId: "test-project-id",
externalId: "customer-a",
});

await db.insert(tables.wallet).values({
id: "test-wallet-id",
endCustomerId: "test-end-customer-id",
projectId: "test-project-id",
organizationId: "test-org-id",
});

await db.insert(tables.apiKey).values({
id: "test-end-user-customer-key-id",
token: "euck_test-token",
projectId: "test-project-id",
description: "Embedded end-user: customer-a",
keyType: "end_user_customer",
endCustomerWalletId: "test-wallet-id",
createdBy: "test-user-id",
});

await db.insert(tables.log).values({
id: "end-user-customer-log",
requestId: "end-user-customer-log",
createdAt: today,
updatedAt: today,
organizationId: "test-org-id",
projectId: "test-project-id",
apiKeyId: "test-end-user-customer-key-id",
endCustomerWalletId: "test-wallet-id",
endCustomerId: "test-end-customer-id",
duration: 100,
requestedModel: "gpt-4",
requestedProvider: "openai",
usedModel: "gpt-4",
usedProvider: "openai",
responseSize: 1000,
promptTokens: "11",
completionTokens: "22",
totalTokens: "33",
cost: 0.12,
messages: JSON.stringify([{ role: "user", content: "Hello" }]),
mode: "credits",
usedMode: "credits",
});

await aggregateLogsForTesting();

const res = await app.request("/activity?days=7&groupBy=apiKey", {
headers: {
Cookie: token,
},
});

expect(res.status).toBe(200);
const data = await res.json();
const breakdowns = data.activity.flatMap(
(row: { apiKeyBreakdown: Array<{ id: string; description: string }> }) =>
row.apiKeyBreakdown,
);

expect(breakdowns).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "test-end-user-customer-key-id",
description: "Embedded end-user: customer-a",
}),
]),
);
});

test("GET /activity should require authentication", async () => {
const res = await app.request("/activity?days=7");
expect(res.status).toBe(401);
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,7 @@ activity.openapi(getActivity, async (c) => {
.where(
and(
inArray(apiKeyHourlyStats.projectId, projectIds),
inArray(apiKey.keyType, ["user", "end_user_customer"]),
gte(apiKeyHourlyStats.hourTimestamp, startDate),
lte(apiKeyHourlyStats.hourTimestamp, endDate),
),
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/routes/keys-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,9 @@ export async function createApiKeyForProject(
where: {
projectId: { eq: projectId },
status: { ne: "deleted" },
// Only count developer keys toward the per-project cap; platform and
// hidden embeddable-SDK aggregate keys are excluded.
keyType: { eq: "user" },
},
});

Expand Down Expand Up @@ -734,6 +737,9 @@ keysApi.openapi(list, async (c) => {
projectId: {
in: projectId ? [projectId] : projectIds,
},
// Hide platform and embeddable-SDK aggregate keys from the dashboard —
// only show developer-created keys.
keyType: { eq: "user" },
...(shouldFilterByCreator && {
createdBy: {
eq: user.id,
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/routes/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ const transactionSchema = z.object({
"dev_plan_cancel",
"dev_plan_end",
"dev_plan_renewal",
"end_user_topup",
"end_user_margin_accrual",
"end_user_refund",
"end_user_margin_payout",
]),
amount: z.string().nullable(),
creditAmount: z.string().nullable(),
Expand Down
Loading
Loading