Skip to content

Commit 7e3e2a2

Browse files
authored
Consolidate runtime secrets (#54)
* Consolidate runtime secrets * Configure production Redis cache * Configure Langfuse in production * Configure LangSmith in production * Suppress Bandit schema key false positive * Fix bundled secret type narrowing * Update vulnerable transitive dependencies
1 parent d1f9773 commit 7e3e2a2

8 files changed

Lines changed: 265 additions & 40 deletions

File tree

cloudrun/service.yaml

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,12 @@ spec:
5454
value: "text-embedding-3-small"
5555
- name: EMBEDDING_DIMENSIONS
5656
value: "1536"
57-
- name: OPENAI_API_KEY
57+
- name: PROVIDER_CONFIG_JSON
5858
valueFrom:
5959
secretKeyRef:
60-
name: openai-api-key
60+
name: provider-config
6161
key: latest
6262
# --- Search ---
63-
- name: TAVILY_API_KEY
64-
valueFrom:
65-
secretKeyRef:
66-
name: tavily-api-key
67-
key: latest
6863
# --- Graph store (Neo4j Aura) ---
6964
# URI and username are not sensitive; only the password lives in Secret Manager.
7065
- name: NEO4J_URI
@@ -93,16 +88,28 @@ spec:
9388
- name: READINESS_REQUIRE_NEO4J
9489
value: "true"
9590
# --- Inngest ---
96-
- name: INNGEST_EVENT_KEY
91+
- name: INNGEST_CONFIG_JSON
9792
valueFrom:
9893
secretKeyRef:
99-
name: inngest-event-key
94+
name: inngest-config
10095
key: latest
101-
- name: INNGEST_SIGNING_KEY
96+
# --- Billing (Stripe) ---
97+
- name: BILLING_CONFIG_JSON
10298
valueFrom:
10399
secretKeyRef:
104-
name: inngest-signing-key
100+
name: billing-config
105101
key: latest
102+
# --- Redis cache ---
103+
# REDIS_URL is bundled into PROVIDER_CONFIG_JSON to stay within the
104+
# Secret Manager free active-version allowance.
105+
- name: REDIS_CACHE_TTL_DB_LIST_SECONDS
106+
value: "30"
107+
- name: STRIPE_SUCCESS_URL
108+
value: "https://cortex-ai-prod.vercel.app/billing/success"
109+
- name: STRIPE_CANCEL_URL
110+
value: "https://cortex-ai-prod.vercel.app/billing/cancel"
111+
- name: STRIPE_PORTAL_RETURN_URL
112+
value: "https://cortex-ai-prod.vercel.app"
106113
# --- CORS ---
107114
- name: CORS_ORIGINS
108115
value: '["https://cortex-ai-prod.vercel.app"]'
@@ -113,9 +120,7 @@ spec:
113120
name: internal-dispatch-secret
114121
key: latest
115122
# --- Observability ---
116-
# ponytail: observability off for the free-tier deploy; re-add
117-
# langsmith-api-key / langfuse-* secrets when tracing matters.
118123
- name: LANGSMITH_TRACING
119124
value: "false"
120125
- name: LANGFUSE_ENABLED
121-
value: "false"
126+
value: "true"

docs/billing.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Cortex uses Stripe Hosted Checkout, signed webhooks, and the customer portal for
44

55
## Configuration
66

7-
Required variables:
7+
Required local-development variables:
88

99
- `STRIPE_SECRET_KEY`
1010
- `STRIPE_WEBHOOK_SECRET`
@@ -13,6 +13,11 @@ Required variables:
1313
- `STRIPE_CANCEL_URL`
1414
- `STRIPE_PORTAL_RETURN_URL`
1515

16+
In production, Cloud Run uses one `BILLING_CONFIG_JSON` Secret Manager value containing
17+
`stripe_secret_key`, `stripe_webhook_secret`, and `stripe_pro_price_id`; the three redirect
18+
URLs remain ordinary environment variables. This keeps the Stripe credentials within one
19+
active secret version.
20+
1621
Store production secret values in Google Secret Manager. See [Production configuration](env-vars-production.md).
1722

1823
## Webhook setup
@@ -46,4 +51,3 @@ flowchart LR
4651
```
4752

4853
Webhook events are the authoritative source for subscription state. The backend verifies the Stripe signature before updating `user_subscriptions`; research and chat quota guards then read the synchronized subscription and usage state.
49-

docs/env-vars-production.md

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,14 @@ All secrets should be stored in **Google Secret Manager** and referenced via `va
88

99
| Env Var | Secret Manager | Notes |
1010
|---|---|---|
11-
| `OPENAI_API_KEY` | yes | Required when `LLM_PROVIDER=openai` |
11+
| `PROVIDER_CONFIG_JSON` | yes | Secret JSON with provider, Redis, Langfuse, and LangSmith settings: `openai_api_key`, `tavily_api_key`, `redis_url`, `langfuse_public_key`, `langfuse_secret_key`, `langfuse_base_url`, `langsmith_api_key`, `langsmith_project`, `langsmith_endpoint`, `langsmith_redaction_mode`, `langsmith_sampling_rate`, and `langsmith_tracing`. Required when `LLM_PROVIDER=openai`. |
1212
| `OPENROUTER_API_KEY` | yes | Required when `LLM_PROVIDER=openrouter` |
1313
| `LLM_PROVIDER` | no | `openai` or `openrouter` |
1414
| `OPENAI_MODEL` | no | e.g. `gpt-4o-mini` |
1515
| `ROUTER_TIMEOUT_SECONDS` | no | Maximum duration of each router LLM request; production uses `10.0` |
1616
| `EMBEDDING_PROVIDER` | no | `openai` |
1717
| `EMBEDDING_MODEL` | no | `text-embedding-3-small` |
1818
| `EMBEDDING_DIMENSIONS` | no | `1536` |
19-
| `TAVILY_API_KEY` | yes | |
2019
| `ALPHA_VANTAGE_API_KEY` | yes | Required when `ASSET_PRICE_PROVIDER=alphavantage_mcp` |
2120
| `ASSET_PRICE_PROVIDER` | no | `alphavantage_mcp` |
2221
| `ALPHA_VANTAGE_MCP_URL` | no | Optional full remote MCP URL override |
@@ -32,12 +31,9 @@ All secrets should be stored in **Google Secret Manager** and referenced via `va
3231
| `READINESS_REQUIRE_SUPABASE` | no | Set to `true` in production; `/ready` returns `503` if Supabase is missing or unavailable |
3332
| `READINESS_REQUIRE_NEO4J` | no | Set to `true` in production; `/ready` returns `503` if Neo4j is missing or unavailable |
3433
| `READINESS_TIMEOUT_SECONDS` | no | Per-dependency probe timeout; defaults to `2.0` seconds |
35-
| `INNGEST_EVENT_KEY` | yes | From Inngest dashboard → Keys |
36-
| `INNGEST_SIGNING_KEY` | yes | From Inngest dashboard → Keys. **Must not be empty in prod.** |
37-
| `REDIS_URL` | yes | Upstash `rediss://` URL |
38-
| `STRIPE_SECRET_KEY` | yes | |
39-
| `STRIPE_WEBHOOK_SECRET` | yes | Update Stripe webhook URL to `https://<cloud-run-url>/api/billing/webhook` after first deploy |
40-
| `STRIPE_PRO_PRICE_ID` | no | |
34+
| `INNGEST_CONFIG_JSON` | yes | Secret JSON with `inngest_event_key` and `inngest_signing_key` from the Inngest dashboard. |
35+
| `REDIS_CACHE_TTL_DB_LIST_SECONDS` | no | Cache TTL for DB-heavy list/read-all responses; production uses `30`. `REDIS_URL` is supplied through `PROVIDER_CONFIG_JSON`. |
36+
| `BILLING_CONFIG_JSON` | yes | Secret JSON with `stripe_secret_key`, `stripe_webhook_secret`, and `stripe_pro_price_id`. This bundles Stripe credentials into one Secret Manager version. |
4137
| `STRIPE_SUCCESS_URL` | no | `https://<frontend>/billing/success` |
4238
| `STRIPE_CANCEL_URL` | no | `https://<frontend>/billing/cancel` |
4339
| `STRIPE_PORTAL_RETURN_URL` | no | `https://<frontend>` |
@@ -49,9 +45,8 @@ All secrets should be stored in **Google Secret Manager** and referenced via `va
4945
| Env Var | Secret Manager | Notes |
5046
|---|---|---|
5147
| `COHERE_API_KEY` | yes | Enables cross-encoder reranking |
52-
| `LANGFUSE_PUBLIC_KEY` | yes | |
53-
| `LANGFUSE_SECRET_KEY` | yes | |
54-
| `LANGFUSE_ENABLED` | no | Set to `true` only when both LangFuse keys are configured; otherwise feedback remains stored locally |
48+
| `LANGFUSE_ENABLED` | no | Production sets this to `true`; Langfuse credentials and base URL are supplied through `PROVIDER_CONFIG_JSON`. |
49+
| `LANGSMITH_*` | no | LangSmith tracing, API key, project, endpoint, redaction mode, and sampling rate are supplied through `PROVIDER_CONFIG_JSON`. |
5550
| `LANGFUSE_ENV` | no | `prod` |
5651
| `LANGFUSE_RELEASE` | no | Git SHA or semver, e.g. `v1.2.3` |
5752
| `SENTRY_DSN` | yes | Error tracking; unset disables it entirely |
@@ -91,19 +86,16 @@ PROJECT=cortex-496709
9186
REGION=your-region
9287

9388
secrets=(
94-
openai-api-key
95-
tavily-api-key
89+
provider-config
9690
cohere-api-key
9791
neo4j-uri
9892
neo4j-username
9993
neo4j-password
10094
supabase-secret-key
10195
supabase-jwt-secret
102-
inngest-event-key
103-
inngest-signing-key
96+
inngest-config
10497
redis-url
105-
stripe-secret-key
106-
stripe-webhook-secret
98+
billing-config
10799
internal-dispatch-secret
108100
langfuse-public-key
109101
langfuse-secret-key

src/config.py

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,33 @@
11
"""Application settings loaded from environment variables."""
22

3-
from pydantic import AliasChoices, Field, field_validator
3+
import json
4+
5+
from pydantic import AliasChoices, Field, field_validator, model_validator
46
from pydantic_settings import BaseSettings, SettingsConfigDict
57

68

9+
def _parse_bundled_secret(
10+
raw: str,
11+
config_name: str,
12+
required: tuple[str, ...],
13+
) -> dict[str, str]:
14+
try:
15+
config = json.loads(raw)
16+
except json.JSONDecodeError as exc:
17+
raise ValueError(f"{config_name} must be valid JSON.") from exc
18+
if not isinstance(config, dict):
19+
raise ValueError(f"{config_name} must be a JSON object.")
20+
21+
values: dict[str, str] = {}
22+
for name in required:
23+
value = config.get(name)
24+
if not isinstance(value, str) or not value:
25+
fields = ", ".join(required)
26+
raise ValueError(f"{config_name} must include non-empty {fields} values.")
27+
values[name] = value
28+
return values
29+
30+
731
class Settings(BaseSettings):
832
model_config = SettingsConfigDict(
933
env_file=".env",
@@ -13,6 +37,10 @@ class Settings(BaseSettings):
1337

1438
# LLM
1539
llm_provider: str = Field(default="openai", description="LLM provider: 'ollama' or 'openai'")
40+
provider_config_json: str = Field(
41+
default="",
42+
description="JSON object containing provider, cache, and observability settings for production.",
43+
)
1644
openai_api_key: str = Field(default="", description="OpenAI API key")
1745
openai_model: str = Field(default="gpt-4o-mini", description="OpenAI model name")
1846
openrouter_api_key: str = Field(default="", description="OpenRouter API key")
@@ -331,6 +359,13 @@ def parse_cors_origins(cls, v: object) -> object:
331359
)
332360

333361
# Billing / Stripe
362+
billing_config_json: str = Field(
363+
default="",
364+
description=(
365+
"JSON object containing Stripe secret_key, webhook_secret, and pro_price_id. "
366+
"Used in production to keep billing credentials in one secret version."
367+
),
368+
)
334369
stripe_secret_key: str = Field(default="", description="Stripe secret key.")
335370
stripe_webhook_secret: str = Field(default="", description="Stripe webhook signing secret.")
336371
stripe_pro_price_id: str = Field(default="", description="Stripe price id for the pro plan.")
@@ -347,6 +382,79 @@ def parse_cors_origins(cls, v: object) -> object:
347382
description="Return URL from Stripe customer portal.",
348383
)
349384

385+
@model_validator(mode="after")
386+
def apply_provider_config(self) -> "Settings":
387+
"""Load bundled provider/cache credentials while preserving local split-variable support."""
388+
if not self.provider_config_json.strip():
389+
return self
390+
values = _parse_bundled_secret(
391+
self.provider_config_json,
392+
"PROVIDER_CONFIG_JSON",
393+
("openai_api_key", "tavily_api_key"),
394+
)
395+
for name, value in values.items():
396+
setattr(self, name, value)
397+
redis_url = json.loads(self.provider_config_json).get("redis_url")
398+
if redis_url is not None:
399+
if not isinstance(redis_url, str) or not redis_url:
400+
raise ValueError("PROVIDER_CONFIG_JSON redis_url must be a non-empty string.")
401+
self.redis_url = redis_url
402+
langfuse = json.loads(self.provider_config_json)
403+
langfuse_fields = {
404+
"langfuse_public_key": "langfuse_public_key",
405+
"langfuse_secret_key": "langfuse_secret_key", # nosec B105 — schema field name, not a credential
406+
"langfuse_base_url": "langfuse_host",
407+
}
408+
for source, target in langfuse_fields.items():
409+
value = langfuse.get(source)
410+
if value is not None:
411+
if not isinstance(value, str) or not value:
412+
raise ValueError(f"PROVIDER_CONFIG_JSON {source} must be a non-empty string.")
413+
setattr(self, target, value)
414+
langsmith_fields = {
415+
"langsmith_api_key": "langsmith_api_key",
416+
"langsmith_project": "langsmith_project",
417+
"langsmith_endpoint": "langsmith_endpoint",
418+
"langsmith_redaction_mode": "langsmith_redaction_mode",
419+
}
420+
for source, target in langsmith_fields.items():
421+
value = langfuse.get(source)
422+
if value is not None:
423+
if not isinstance(value, str) or not value:
424+
raise ValueError(f"PROVIDER_CONFIG_JSON {source} must be a non-empty string.")
425+
setattr(self, target, value)
426+
langsmith_tracing = langfuse.get("langsmith_tracing")
427+
if langsmith_tracing is not None:
428+
if isinstance(langsmith_tracing, bool):
429+
self.langsmith_tracing = langsmith_tracing
430+
elif isinstance(langsmith_tracing, str) and langsmith_tracing.lower() in {"true", "false"}:
431+
self.langsmith_tracing = langsmith_tracing.lower() == "true"
432+
else:
433+
raise ValueError("PROVIDER_CONFIG_JSON langsmith_tracing must be true or false.")
434+
langsmith_sampling_rate = langfuse.get("langsmith_sampling_rate")
435+
if langsmith_sampling_rate is not None:
436+
try:
437+
self.langsmith_sampling_rate = float(langsmith_sampling_rate)
438+
except (TypeError, ValueError) as exc:
439+
raise ValueError(
440+
"PROVIDER_CONFIG_JSON langsmith_sampling_rate must be a number."
441+
) from exc
442+
return self
443+
444+
@model_validator(mode="after")
445+
def apply_billing_config(self) -> "Settings":
446+
"""Load bundled Stripe credentials while preserving local split-variable support."""
447+
if not self.billing_config_json.strip():
448+
return self
449+
values = _parse_bundled_secret(
450+
self.billing_config_json,
451+
"BILLING_CONFIG_JSON",
452+
("stripe_secret_key", "stripe_webhook_secret", "stripe_pro_price_id"),
453+
)
454+
for name, value in values.items():
455+
setattr(self, name, value)
456+
return self
457+
350458
# Redis cache
351459
redis_url: str = Field(
352460
default="",

src/inngest_client.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,34 @@
22

33
from __future__ import annotations
44

5+
import json
56
import os
67
from collections.abc import Mapping
78

89
import inngest
910
import inngest.fast_api
1011

12+
13+
def _apply_bundled_inngest_config() -> None:
14+
"""Expose bundled credentials under the names expected by the Inngest SDK."""
15+
raw = os.environ.get("INNGEST_CONFIG_JSON", "").strip()
16+
if not raw:
17+
return
18+
try:
19+
config = json.loads(raw)
20+
except json.JSONDecodeError as exc:
21+
raise ValueError("INNGEST_CONFIG_JSON must be valid JSON.") from exc
22+
if not isinstance(config, dict):
23+
raise ValueError("INNGEST_CONFIG_JSON must be a JSON object.")
24+
for name in ("inngest_event_key", "inngest_signing_key"):
25+
value = config.get(name)
26+
if not isinstance(value, str) or not value:
27+
raise ValueError(f"INNGEST_CONFIG_JSON must include a non-empty {name} value.")
28+
os.environ[name.upper()] = value
29+
30+
31+
_apply_bundled_inngest_config()
32+
1133
_is_production = os.environ.get("INNGEST_DEV", "").strip() != "1"
1234

1335
inngest_client = inngest.Inngest(

0 commit comments

Comments
 (0)