From 10a3cdbfa9e08826113209acb896a1e9016da25c Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Thu, 16 Jul 2026 17:38:25 -0700 Subject: [PATCH] Add testing and validation enhancements for internal tool --- apps/internal-tool/package.json | 1 + .../src/app/api/backend/log-ai-query/route.ts | 2 +- .../backend/update-ai-query-usage/route.ts | 2 +- .../api/mcp-review/backfill-visible/route.ts | 2 +- .../src/lib/server/spacetimedb-token.test.ts | 189 ++++++++++++++++++ apps/internal-tool/vitest.config.ts | 7 + 6 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 apps/internal-tool/src/lib/server/spacetimedb-token.test.ts create mode 100644 apps/internal-tool/vitest.config.ts diff --git a/apps/internal-tool/package.json b/apps/internal-tool/package.json index 5208eaeab..78f8a91c2 100644 --- a/apps/internal-tool/package.json +++ b/apps/internal-tool/package.json @@ -8,6 +8,7 @@ "build": "next build", "start": "next start --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}41", "typecheck": "tsc --noEmit", + "test": "vitest run", "lint": "eslint --ext .ts,.tsx .", "clean": "rimraf .next && rimraf node_modules", "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb", diff --git a/apps/internal-tool/src/app/api/backend/log-ai-query/route.ts b/apps/internal-tool/src/app/api/backend/log-ai-query/route.ts index d6d9b6f22..902fbba15 100644 --- a/apps/internal-tool/src/app/api/backend/log-ai-query/route.ts +++ b/apps/internal-tool/src/app/api/backend/log-ai-query/route.ts @@ -22,7 +22,7 @@ const bodySchema = z.object({ outputTokens: z.number().int().nonnegative().optional(), cachedInputTokens: z.number().int().nonnegative().optional(), cacheCreationTokens: z.number().int().nonnegative().optional(), - costUsd: z.number().optional(), + costUsd: z.number().nonnegative().optional(), cacheDiscountUsd: z.number().optional(), openrouterGenerationId: z.string().optional(), stepCount: z.number().int().nonnegative(), diff --git a/apps/internal-tool/src/app/api/backend/update-ai-query-usage/route.ts b/apps/internal-tool/src/app/api/backend/update-ai-query-usage/route.ts index 35c3322d9..784c29672 100644 --- a/apps/internal-tool/src/app/api/backend/update-ai-query-usage/route.ts +++ b/apps/internal-tool/src/app/api/backend/update-ai-query-usage/route.ts @@ -9,7 +9,7 @@ const bodySchema = z.object({ inputTokens: z.number().int().nonnegative().optional(), outputTokens: z.number().int().nonnegative().optional(), cachedInputTokens: z.number().int().nonnegative().optional(), - costUsd: z.number().optional(), + costUsd: z.number().nonnegative().optional(), cacheDiscountUsd: z.number().optional(), }); diff --git a/apps/internal-tool/src/app/api/mcp-review/backfill-visible/route.ts b/apps/internal-tool/src/app/api/mcp-review/backfill-visible/route.ts index 64fe7ba07..258d2fc72 100644 --- a/apps/internal-tool/src/app/api/mcp-review/backfill-visible/route.ts +++ b/apps/internal-tool/src/app/api/mcp-review/backfill-visible/route.ts @@ -42,11 +42,11 @@ async function runWithConcurrency(items: T[], limit: number, worker: (item: T export async function POST(req: Request): Promise { try { const { user } = await requireInternalAiChatReviewer(req); - const spacetimeToken = await signSpacetimeToken({ subject: user.id }); const { items } = bodySchema.parse(await readJsonBody(req)); after(async () => { await runWithConcurrency(items, REVIEW_CONCURRENCY, async (item) => { try { + const spacetimeToken = await signSpacetimeToken({ subject: user.id }); await reviewMcpCall(spacetimeToken, item); } catch (err) { captureError("internal-tool-mcp-review-backfill-visible-item", err); diff --git a/apps/internal-tool/src/lib/server/spacetimedb-token.test.ts b/apps/internal-tool/src/lib/server/spacetimedb-token.test.ts new file mode 100644 index 000000000..b6837ad58 --- /dev/null +++ b/apps/internal-tool/src/lib/server/spacetimedb-token.test.ts @@ -0,0 +1,189 @@ +import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; +import * as jose from "jose"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { publicJwks, signSpacetimeToken, spacetimeTokenAudience, spacetimeTokenIssuer } from "./spacetimedb-token"; + +// The module under test imports the `server-only` marker package, which throws +// when loaded outside a React Server Components bundler context. +vi.mock("server-only", () => ({})); + +async function generatePrivateEcJwk(): Promise { + const { privateKey } = await jose.generateKeyPair("ES256", { extractable: true }); + return await jose.exportJWK(privateKey); +} + +function stubSigningKey(jwk: object) { + vi.stubEnv("HEXCLAVE_SPACETIMEDB_SIGNING_KEY_JWK", JSON.stringify(jwk)); +} + +function stubDefaultEnv() { + vi.stubEnv("HEXCLAVE_SPACETIMEDB_TOKEN_ISSUER", "https://internal.example.com"); + vi.stubEnv("HEXCLAVE_SPACETIMEDB_EXPECTED_AUDIENCE", ""); + // getEnvVariable also reads the legacy STACK_-prefixed twins; stub them to + // empty (= unset) so a stray var in the runner's environment can't interfere. + vi.stubEnv("STACK_SPACETIMEDB_SIGNING_KEY_JWK", ""); + vi.stubEnv("STACK_SPACETIMEDB_TOKEN_ISSUER", ""); + vi.stubEnv("STACK_SPACETIMEDB_EXPECTED_AUDIENCE", ""); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("publicJwks", () => { + it("serves only the allowlisted public members, never `d` or unknown extras", async () => { + stubDefaultEnv(); + const privateEcJwk = await generatePrivateEcJwk(); + stubSigningKey({ + ...privateEcJwk, + kid: "test-kid", + alg: "ES256", + // Simulate a JWK that carries members we don't know about; they must + // not be reflected on the public endpoint. + key_ops: ["sign"], + unexpected_member: "must-not-leak", + }); + + const jwks = publicJwks(); + expect(jwks.keys).toHaveLength(1); + const key = jwks.keys[0]; + expect(Object.keys(key).sort()).toMatchInlineSnapshot(` + [ + "alg", + "crv", + "kid", + "kty", + "x", + "y", + ] + `); + expect(key.x).toBe(privateEcJwk.x); + expect(key.y).toBe(privateEcJwk.y); + expect(key.kid).toBe("test-kid"); + expect(key.alg).toBe("ES256"); + }); + + it("omits kid/alg entirely when the private JWK doesn't carry them", async () => { + stubDefaultEnv(); + stubSigningKey(await generatePrivateEcJwk()); + + const key = publicJwks().keys[0]; + expect(Object.keys(key).sort()).toMatchInlineSnapshot(` + [ + "crv", + "kty", + "x", + "y", + ] + `); + }); + + it("rejects an RSA private key instead of serving its private members", async () => { + stubDefaultEnv(); + const { privateKey } = await jose.generateKeyPair("RS256", { extractable: true, modulusLength: 2048 }); + const rsaJwk = await jose.exportJWK(privateKey); + // Sanity-check the threat: an RSA private JWK keeps secrets beyond `d`. + expect(rsaJwk.p).toBeDefined(); + stubSigningKey(rsaJwk); + + expect(() => publicJwks()).toThrow(HexclaveAssertionError); + }); + + it("rejects a symmetric (oct) key", () => { + stubDefaultEnv(); + stubSigningKey({ kty: "oct", k: "c2VjcmV0LXN5bW1ldHJpYy1rZXk" }); + + expect(() => publicJwks()).toThrow(HexclaveAssertionError); + }); + + it("rejects an EC key on the wrong curve", async () => { + stubDefaultEnv(); + const { privateKey } = await jose.generateKeyPair("ES384", { extractable: true }); + stubSigningKey(await jose.exportJWK(privateKey)); + + expect(() => publicJwks()).toThrow(HexclaveAssertionError); + }); + + it("throws when the signing key env var is not configured", () => { + stubDefaultEnv(); + vi.stubEnv("HEXCLAVE_SPACETIMEDB_SIGNING_KEY_JWK", ""); + + expect(() => publicJwks()).toThrow("HEXCLAVE_SPACETIMEDB_SIGNING_KEY_JWK is not configured"); + }); +}); + +describe("signSpacetimeToken", () => { + it("mints a token that verifies against the published JWKS", async () => { + stubDefaultEnv(); + stubSigningKey({ ...await generatePrivateEcJwk(), kid: "test-kid" }); + + const token = await signSpacetimeToken({ subject: "user-123", name: "Ada Lovelace" }); + // Verifying with the JWKS output (not the private key) also proves the + // allowlisted public members are sufficient for verification. + const publicKey = await jose.importJWK(publicJwks().keys[0], "ES256"); + const { payload, protectedHeader } = await jose.jwtVerify(token, publicKey, { + issuer: "https://internal.example.com", + audience: "spacetimedb", + }); + + expect(protectedHeader.alg).toBe("ES256"); + expect(protectedHeader.kid).toBe("test-kid"); + expect(payload.sub).toBe("user-123"); + expect(payload.name).toBe("Ada Lovelace"); + expect(payload.iat).toBeDefined(); + expect(payload.exp).toBeDefined(); + // Default TTL is 10 minutes. + expect((payload.exp ?? 0) - (payload.iat ?? 0)).toBe(10 * 60); + }); + + it("omits the name claim when name is not provided or empty", async () => { + stubDefaultEnv(); + stubSigningKey(await generatePrivateEcJwk()); + + for (const options of [{ subject: "user-123" }, { subject: "user-123", name: "" }]) { + const token = await signSpacetimeToken(options); + const payload = jose.decodeJwt(token); + expect("name" in payload).toBe(false); + } + }); + + it("respects a custom expiresIn", async () => { + stubDefaultEnv(); + stubSigningKey(await generatePrivateEcJwk()); + + const token = await signSpacetimeToken({ subject: "svc", expiresIn: "3600s" }); + const payload = jose.decodeJwt(token); + expect((payload.exp ?? 0) - (payload.iat ?? 0)).toBe(3600); + }); +}); + +describe("spacetimeTokenIssuer", () => { + it("strips trailing slashes", () => { + stubDefaultEnv(); + vi.stubEnv("HEXCLAVE_SPACETIMEDB_TOKEN_ISSUER", "https://internal.example.com///"); + + expect(spacetimeTokenIssuer()).toBe("https://internal.example.com"); + }); + + it("throws when unset or blank", () => { + stubDefaultEnv(); + vi.stubEnv("HEXCLAVE_SPACETIMEDB_TOKEN_ISSUER", " "); + + expect(() => spacetimeTokenIssuer()).toThrow("HEXCLAVE_SPACETIMEDB_TOKEN_ISSUER is not configured"); + }); +}); + +describe("spacetimeTokenAudience", () => { + it("defaults to spacetimedb", () => { + stubDefaultEnv(); + + expect(spacetimeTokenAudience()).toBe("spacetimedb"); + }); + + it("respects the env override", () => { + stubDefaultEnv(); + vi.stubEnv("HEXCLAVE_SPACETIMEDB_EXPECTED_AUDIENCE", "custom-audience"); + + expect(spacetimeTokenAudience()).toBe("custom-audience"); + }); +}); diff --git a/apps/internal-tool/vitest.config.ts b/apps/internal-tool/vitest.config.ts new file mode 100644 index 000000000..28cf4d700 --- /dev/null +++ b/apps/internal-tool/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import sharedConfig from '../../vitest.shared'; + +export default mergeConfig( + sharedConfig, + defineConfig({}), +);