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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# OpenRouter (for LLM persuasion interpretation)
SCORE_API_KEY=change-me-to-a-long-random-secret
OPENROUTER_API_KEY=
OPENROUTER_MODEL=anthropic/claude-sonnet-4.6
OPENROUTER_TIMEOUT_SECONDS=20
Expand Down
33 changes: 33 additions & 0 deletions app/api/score/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,41 @@ import { platformValues, type Platform } from "@/shared/types";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

function isAuthorized(request: Request): { ok: true } | { ok: false; status: number; error: string } {
// In production, require a shared secret header to protect costly scoring compute.
if (process.env.NODE_ENV !== "production") {
return { ok: true };
}

const configuredApiKey = process.env.SCORE_API_KEY?.trim();
if (!configuredApiKey) {
return {
ok: false,
status: 503,
error: "Server misconfiguration: SCORE_API_KEY is not set.",
};
}

const headerApiKey = request.headers.get("x-api-key")?.trim();
const bearerToken = request.headers
.get("authorization")
?.match(/^Bearer\s+(.+)$/i)?.[1]
?.trim();

if (headerApiKey === configuredApiKey || bearerToken === configuredApiKey) {
return { ok: true };
}

return { ok: false, status: 401, error: "Unauthorized." };
}

export async function POST(request: Request) {
try {
const auth = isAuthorized(request);
if (!auth.ok) {
return Response.json({ error: auth.error }, { status: auth.status });
}

const body = await request.json().catch(() => null);
if (!body || typeof body !== "object") {
return Response.json(
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ services:
NODE_ENV: production
PORT: 3000
TRIBE_SERVICE_URL: http://tribe-service:8090
SCORE_API_KEY: ${SCORE_API_KEY:?SCORE_API_KEY must be set}
depends_on:
tribe-service:
condition: service_healthy
Expand Down
40 changes: 39 additions & 1 deletion tests/api/score.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

// Mock fetch globally
const mockFetch = vi.fn();
Expand All @@ -17,8 +17,46 @@ function makeRequest(body: unknown): Request {
}

describe("POST /api/score", () => {
const originalNodeEnv = process.env.NODE_ENV;
const originalApiKey = process.env.SCORE_API_KEY;

beforeEach(() => {
vi.clearAllMocks();
process.env.NODE_ENV = originalNodeEnv;
process.env.SCORE_API_KEY = originalApiKey;
});

afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
process.env.SCORE_API_KEY = originalApiKey;
});

it("returns 503 in production when SCORE_API_KEY is not configured", async () => {
process.env.NODE_ENV = "production";
delete process.env.SCORE_API_KEY;

const res = await POST(
makeRequest({
message: "Our platform reduces deployment time by 80%",
persona: "CTO at startup, technical",
}),
);

expect(res.status).toBe(503);
});

it("returns 401 in production when API key is missing", async () => {
process.env.NODE_ENV = "production";
process.env.SCORE_API_KEY = "test-key";

const res = await POST(
makeRequest({
message: "Our platform reduces deployment time by 80%",
persona: "CTO at startup, technical",
}),
);

expect(res.status).toBe(401);
});

it("returns 400 when message is missing", async () => {
Expand Down
Loading