diff --git a/docker-compose.yml b/docker-compose.yml index 2933cec..6e64263 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,7 +98,7 @@ services: CLERK_SECRET_KEY: ${CLERK_SECRET_KEY} CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY} SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token} - A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key} + A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-} # LLM OPENCODE_API_KEY: ${OPENCODE_API_KEY} LLM_PROVIDER: ${LLM_PROVIDER:-opencode} diff --git a/scripts/user-profile.test.ts b/scripts/user-profile.test.ts index 1e2907e..072eece 100644 --- a/scripts/user-profile.test.ts +++ b/scripts/user-profile.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { config } from "../src/config.js"; +import { resolveTrustedServiceTokens } from "../src/auth/clerk.js"; import { fetchOnboardingReadProfile, fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js"; /** @@ -282,5 +283,24 @@ async function runFetchUserProfileAfterBodyConsumed(): Promise<{ globalThis.fetch = originalFetch; } } +// ── 8. trusted token resolution excludes A2A in production ──────────────── +{ + assert.equal(config.a2aAllowedKey, process.env.A2A_ALLOWED_KEY ?? "", "A2A config must not have a dev fallback"); + const production = resolveTrustedServiceTokens({ + nodeEnv: "production", + serviceToken: "service-token", + a2aAllowedKey: "a2a-token", + }); + assert.equal(production.has("service-token"), true); + assert.equal(production.has("a2a-token"), false, "production must exclude A2A token"); + + const development = resolveTrustedServiceTokens({ + nodeEnv: "development", + serviceToken: "service-token", + a2aAllowedKey: "a2a-token", + }); + assert.equal(development.has("service-token"), true); + assert.equal(development.has("a2a-token"), true, "development must include configured A2A token"); +} console.log("user-profile: all assertions passed"); diff --git a/src/auth/clerk.ts b/src/auth/clerk.ts index eba4706..4cff28e 100644 --- a/src/auth/clerk.ts +++ b/src/auth/clerk.ts @@ -14,6 +14,17 @@ export type AuthContext = { }; }; +/** Resolve inbound service tokens without allowing A2A in production. */ +export function resolveTrustedServiceTokens(input: { + nodeEnv: string; + serviceToken: string; + a2aAllowedKey: string; +}): ReadonlySet { + return new Set( + [input.serviceToken, input.nodeEnv !== "production" ? input.a2aAllowedKey : ""].filter(Boolean), + ); +} + export const clerk = config.clerkSecretKey ? createClerkClient({ secretKey: config.clerkSecretKey }) : null; @@ -28,12 +39,7 @@ export const requireUser = createMiddleware(async (c, next) => { // Service-to-service path (Grow stack calling backend). // Header `x-growqr-user` is REQUIRED so we can scope the call. - const trustedServiceTokens = new Set( - [ - config.serviceToken, - config.nodeEnv !== "production" ? config.a2aAllowedKey : "", - ].filter(Boolean), - ); + const trustedServiceTokens = resolveTrustedServiceTokens(config); if ( token && diff --git a/src/config.ts b/src/config.ts index a192add..ce504d4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -57,7 +57,8 @@ export const config = { clerkPublishableKey: process.env.CLERK_PUBLISHABLE_KEY ?? "", // Optional: lock service-to-service calls (actor → backend). serviceToken: process.env.SERVICE_TOKEN ?? "", - a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key", + // A2A is intentionally unset unless an operator configures it. + a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "", // Service → backend event stream. Redis is optional; HTTP /events/ingest/service is always available. // Explicit opt-in flags: both Redis ingestion paths are default-off. The REST