Serve call audio through c2-core instead of GCS signed URLs
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Failing after 2m34s

upload_audio() could only sign a URL when GCP_CREDENTIALS_PATH pointed at a
service-account key file. The deployed VM runs on Application Default
Credentials with no key file, so every upload silently took the fallback
branch and returned a bare gs:// URI. That broke two things at once:

  * Browsers cannot fetch a gs:// URI, so no recording was ever playable.
  * _public_url_to_gcs_uri() only matched https://storage.googleapis.com/ and
    returned None for it, so `if gcs_uri:` in the upload path was always false
    and transcription never ran. Nothing was logged, which is why this looked
    like an OpenAI credits problem rather than a storage one.

The fallback also interpolated the client-supplied filename instead of the
call_id-derived safe name, so the URI did not even name the object written.

Calls now store only the canonical gs:// location. A short-lived playback link
is minted per read as an HMAC over (call_id, expiry) keyed by SERVICE_KEY, and
audio is served from the private bucket by the new /media route. An <audio src>
cannot carry an Authorization header, so the link has to be the credential;
that router is therefore public with the check done inline, as enrollment.py
already does. Signing GCS URLs from the VM would have needed a
serviceAccountTokenCreator grant on its own service account — this avoids the
IAM change entirely and keeps the bucket private.

gcs_uri_for_call() reconstructs the object name from call_id, so recordings
made before this fix are reachable again without a data migration.

Frontend rows come straight from Firestore via onSnapshot and never see a
server-minted field, so CallRow fetches the link lazily on expand.

Also removes the last long-lived (1 year) signed URL and the log line that
printed it.
This commit is contained in:
Logan Cusano
2026-08-16 16:26:41 -04:00
parent a195563da6
commit a2cd2c57ca
10 changed files with 291 additions and 62 deletions
+10
View File
@@ -68,6 +68,16 @@ class Settings(BaseSettings):
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB. # Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
upload_max_bytes: int = 100 * 1024 * 1024 upload_max_bytes: int = 100 * 1024 * 1024
# Public origin this API is reachable on, e.g. "https://api.drb.example.com".
# Only used to build absolute call-audio playback links: an <audio src> is
# fetched by the browser directly, so a relative path would resolve against
# the frontend origin, not this one.
public_api_url: Optional[str] = None
# How long a minted call-audio playback link stays valid. Long enough for a
# browsing session, short enough that a copied link isn't durable access.
audio_link_ttl_seconds: int = 6 * 60 * 60
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"] # CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
# Defaults to "*" for local development only. # Defaults to "*" for local development only.
cors_origins: list[str] = ["*"] cors_origins: list[str] = ["*"]
+155 -23
View File
@@ -1,9 +1,40 @@
"""
Call-audio storage and playback links.
TWO THINGS THIS MODULE DELIBERATELY DOES NOT DO ANY MORE:
1. It does not return a GCS *signed* URL from the upload path. Signing needs a
service-account private key, and the deployed VM runs on Application Default
Credentials with no key file (see ansible c2-core.env.j2). The old code
silently fell back to returning a bare ``gs://`` URI, which broke two things
at once: browsers can't fetch a gs:// URI, so no recording was ever
playable, and ``_public_url_to_gcs_uri`` in upload.py returned None for it,
so the transcription step was skipped without logging anything at all.
2. It does not store a long-lived URL on the call document. What gets persisted
is the canonical ``gs://`` object location; a short-lived playback link is
minted on read instead. Nothing durable and nothing loggable is a credential.
Playback goes through c2-core's own /media route rather than GCS directly,
because an <audio src> cannot carry an Authorization header — so the link
itself has to be the credential. It is a plain HMAC over (call_id, expiry)
keyed by SERVICE_KEY, which costs no network round-trip, keeps the bucket
fully private, and needs no IAM change on the VM's service account.
"""
import asyncio import asyncio
import datetime import hashlib
from typing import Optional import hmac
import os
import time
from typing import Optional, Tuple
from app.config import settings from app.config import settings
from app.internal.logger import logger from app.internal.logger import logger
# Domain separation: the audio-link key is derived from SERVICE_KEY rather than
# being SERVICE_KEY itself, so a leaked playback link can never be replayed as
# a service-key bearer token against the rest of the API.
_KEY_CONTEXT = b"drb-audio-link-v1"
def _safe_audio_filename(filename: str, call_id: str) -> str: def _safe_audio_filename(filename: str, call_id: str) -> str:
"""Return a safe GCS object name derived from the call_id. """Return a safe GCS object name derived from the call_id.
@@ -12,7 +43,6 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
call_id (which we control) to prevent path traversal via crafted filenames. call_id (which we control) to prevent path traversal via crafted filenames.
The original extension is preserved only if it's a known audio type. The original extension is preserved only if it's a known audio type.
""" """
import os
ext = os.path.splitext(filename)[-1].lower() if filename else "" ext = os.path.splitext(filename)[-1].lower() if filename else ""
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"): if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
ext = ".mp3" ext = ".mp3"
@@ -20,38 +50,140 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]: async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
"""Upload audio bytes to GCS and return a signed URL, or None if disabled.""" """Upload audio bytes to GCS and return the canonical gs:// URI, or None if disabled."""
if not settings.gcs_bucket: if not settings.gcs_bucket:
logger.info("GCS_BUCKET not configured — skipping audio upload.") logger.info("GCS_BUCKET not configured — skipping audio upload.")
return None return None
def _upload() -> str: safe_name = _safe_audio_filename(filename, call_id)
blob_path = f"calls/{safe_name}"
def _upload() -> None:
from google.cloud import storage from google.cloud import storage
from google.oauth2 import service_account as sa
if settings.gcp_credentials_path: if settings.gcp_credentials_path:
client = storage.Client.from_service_account_json(settings.gcp_credentials_path) client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
signing_creds = sa.Credentials.from_service_account_file(settings.gcp_credentials_path)
else: else:
client = storage.Client() client = storage.Client()
signing_creds = None blob = client.bucket(settings.gcs_bucket).blob(blob_path)
bucket = client.bucket(settings.gcs_bucket)
safe_name = _safe_audio_filename(filename, call_id)
blob = bucket.blob(f"calls/{safe_name}")
blob.upload_from_string(data, content_type="audio/mpeg") blob.upload_from_string(data, content_type="audio/mpeg")
if signing_creds:
return blob.generate_signed_url(
version="v2",
expiration=datetime.timedelta(days=365),
method="GET",
credentials=signing_creds,
)
# Fallback: return the gs:// URI (no public access)
return f"gs://{settings.gcs_bucket}/calls/{filename}"
try: try:
url = await asyncio.to_thread(_upload) await asyncio.to_thread(_upload)
logger.info(f"Audio uploaded: {url}")
return url
except Exception as e: except Exception as e:
logger.error(f"GCS upload failed: {e}") logger.error(f"GCS upload failed: {e}")
return None return None
gcs_uri = f"gs://{settings.gcs_bucket}/{blob_path}"
logger.info(f"Audio uploaded: {gcs_uri}")
return gcs_uri
async def download_audio(gcs_uri: str) -> Optional[bytes]:
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
bucket_name, blob_path = split_gcs_uri(gcs_uri)
if not bucket_name:
return None
def _download() -> bytes:
from google.cloud import storage
if settings.gcp_credentials_path:
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
else:
client = storage.Client()
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
try:
return await asyncio.to_thread(_download)
except Exception as e:
logger.warning(f"GCS download failed for {gcs_uri}: {e}")
return None
def split_gcs_uri(gcs_uri: str) -> Tuple[Optional[str], Optional[str]]:
"""``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``."""
if not gcs_uri or not gcs_uri.startswith("gs://"):
return None, None
without_scheme = gcs_uri[len("gs://"):]
if "/" not in without_scheme:
return None, None
bucket_name, blob_path = without_scheme.split("/", 1)
return bucket_name, blob_path
def gcs_uri_for_call(call: dict) -> Optional[str]:
"""Resolve the audio object for a call document.
Prefers the canonical ``audio_gcs_uri`` written by /upload. Falls back to
reconstructing the object name from the call_id for documents written
before this module was fixed: those stored a gs:// URI built from the
*client-supplied* filename, which never matched the object actually
written (always ``calls/{call_id}.mp3``). Reconstructing rather than
trusting the stored value is what makes every pre-existing recording
playable again without a data migration.
"""
uri = call.get("audio_gcs_uri")
if uri:
return uri
call_id = call.get("call_id")
if call.get("audio_url") and call_id and settings.gcs_bucket:
return f"gs://{settings.gcs_bucket}/calls/{call_id}.mp3"
return None
def _link_key() -> Optional[bytes]:
if not settings.service_key:
return None
return hmac.new(settings.service_key.encode("utf-8"), _KEY_CONTEXT, hashlib.sha256).digest()
def sign_audio_link(call_id: str, expires_at: int) -> Optional[str]:
key = _link_key()
if not key:
return None
msg = f"{call_id}:{expires_at}".encode("utf-8")
return hmac.new(key, msg, hashlib.sha256).hexdigest()
def verify_audio_link(call_id: str, expires_at: int, signature: str) -> bool:
if expires_at < int(time.time()):
return False
expected = sign_audio_link(call_id, expires_at)
if not expected:
return False
return hmac.compare_digest(expected, signature)
_warned_no_service_key = False
_warned_no_public_url = False
def playback_url(call: dict) -> Optional[str]:
"""Mint a short-lived playback URL for a call, or None if it has no audio."""
global _warned_no_service_key, _warned_no_public_url
call_id = call.get("call_id")
if not call_id or not gcs_uri_for_call(call):
return None
expires_at = int(time.time()) + settings.audio_link_ttl_seconds
signature = sign_audio_link(call_id, expires_at)
if not signature:
if not _warned_no_service_key:
logger.error("SERVICE_KEY not set — call audio cannot be served.")
_warned_no_service_key = True
return None
# Loud rather than silent: a relative link here would 404 against the
# frontend origin, which is the exact failure mode this module exists to
# stop repeating. Deploy via ansible so c2-core.env.j2 sets PUBLIC_API_URL.
if not settings.public_api_url and not _warned_no_public_url:
logger.error("PUBLIC_API_URL not set — call audio links will be relative and will not resolve.")
_warned_no_public_url = True
base = (settings.public_api_url or "").rstrip("/")
return f"{base}/media/calls/{call_id}/audio?exp={expires_at}&sig={signature}"
def with_playback_url(call: dict) -> dict:
"""Return the call dict with a freshly minted ``audio_url``."""
return {**call, "audio_url": playback_url(call)}
+4 -1
View File
@@ -15,7 +15,7 @@ from app.internal.auth import (
require_node_service_or_firebase_token, require_node_service_or_firebase_token,
) )
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
from app.routers import enrollment from app.routers import enrollment, media
from app.internal import dynsec from app.internal import dynsec
from app.internal import firestore as fstore from app.internal import firestore as fstore
@@ -101,6 +101,9 @@ app.include_router(admin.router) # auth is per-endpoint (read: firebase, wri
app.include_router(users.router) # auth: admin only app.include_router(users.router) # auth: admin only
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key) app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline
# public by necessity — an <audio src> can't send a bearer token, so the
# short-lived HMAC in the URL is the credential. Checked inline in media.py.
app.include_router(media.router)
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP # NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
# backend for the mosquitto-go-auth plugin). That plugin's upstream project # backend for the mosquitto-go-auth plugin). That plugin's upstream project
# is archived (no CVE patches) and was rejected for an internet-facing # is archived (no CVE patches) and was rejected for an internet-facing
+2 -1
View File
@@ -62,7 +62,8 @@ class CallRecord(BaseModel):
srcaddr: Optional[str] = None srcaddr: Optional[str] = None
started_at: datetime started_at: datetime
ended_at: Optional[datetime] = None ended_at: Optional[datetime] = None
audio_url: Optional[str] = None audio_gcs_uri: Optional[str] = None # canonical gs:// object location
audio_url: Optional[str] = None # NOT stored — minted per read, see internal/storage.py
transcript: Optional[str] = None # populated later by STT transcript: Optional[str] = None # populated later by STT
incident_ids: List[str] = [] # one per scene detected in the recording incident_ids: List[str] = [] # one per scene detected in the recording
location: Optional[Dict[str, float]] = None # {lat, lng} location: Optional[Dict[str, float]] = None # {lat, lng}
+7 -5
View File
@@ -4,6 +4,7 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.auth import require_admin_token from app.internal.auth import require_admin_token
from app.internal.storage import gcs_uri_for_call, with_playback_url
class TranscriptUpdate(BaseModel): class TranscriptUpdate(BaseModel):
@@ -25,7 +26,9 @@ async def list_calls(
filters["status"] = status filters["status"] = status
if system_id: if system_id:
filters["system_id"] = system_id filters["system_id"] = system_id
return await fstore.collection_list("calls", **filters) calls = await fstore.collection_list("calls", **filters)
# audio_url is not stored — it's a short-lived signed link minted per read.
return [with_playback_url(c) for c in calls]
@router.get("/{call_id}") @router.get("/{call_id}")
@@ -33,7 +36,7 @@ async def get_call(call_id: str):
call = await fstore.doc_get("calls", call_id) call = await fstore.doc_get("calls", call_id)
if not call: if not call:
raise HTTPException(404, f"Call '{call_id}' not found.") raise HTTPException(404, f"Call '{call_id}' not found.")
return call return with_playback_url(call)
@router.post("/{call_id}/reprocess") @router.post("/{call_id}/reprocess")
@@ -43,10 +46,9 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
if not call: if not call:
raise HTTPException(404, f"Call '{call_id}' not found.") raise HTTPException(404, f"Call '{call_id}' not found.")
from app.routers.upload import _run_intelligence_pipeline, _public_url_to_gcs_uri from app.routers.upload import _run_intelligence_pipeline
audio_url = call.get("audio_url") gcs_uri = gcs_uri_for_call(call)
gcs_uri = _public_url_to_gcs_uri(audio_url) if audio_url else None
background_tasks.add_task( background_tasks.add_task(
_run_intelligence_pipeline, _run_intelligence_pipeline,
+55
View File
@@ -0,0 +1,55 @@
"""
Call-audio playback.
Public router by necessity: a browser's <audio src="..."> cannot attach an
Authorization header, so the link itself carries the credential — a short-lived
HMAC over (call_id, expiry) minted by app/internal/storage.py. That is why this
router is included in main.py WITHOUT a router-level auth dependency; the check
happens inline below, in the same spirit as routers/enrollment.py.
The bucket stays fully private and c2-core reads the object server-side with
Application Default Credentials, so no GCS signed URL — and therefore no
service-account private key on the VM — is involved anywhere in this path.
"""
from fastapi import APIRouter, HTTPException, Query, Response
from app.internal import firestore as fstore
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio
router = APIRouter(prefix="/media", tags=["media"])
@router.get("/calls/{call_id}/audio")
async def get_call_audio(
call_id: str,
exp: int = Query(..., description="Link expiry, unix seconds."),
sig: str = Query(..., description="HMAC over call_id and expiry."),
):
# Verify before touching Firestore so an invalid link costs nothing.
if not verify_audio_link(call_id, exp, sig):
raise HTTPException(403, "Invalid or expired audio link")
call = await fstore.doc_get("calls", call_id)
if not call:
raise HTTPException(404, f"Call '{call_id}' not found.")
gcs_uri = gcs_uri_for_call(call)
if not gcs_uri:
raise HTTPException(404, "No audio for this call.")
data = await download_audio(gcs_uri)
if not data:
raise HTTPException(404, "Audio object missing from storage.")
return Response(
content=data,
media_type="audio/mpeg",
headers={
"Content-Length": str(len(data)),
# Recordings are small (16 kbps mono — a 30s call is ~60 KB), so the
# whole body is sent at once and the browser seeks within its own
# buffer. Range support would only matter for long files.
"Accept-Ranges": "none",
# Immutable content, but the URL expires — cache privately only.
"Cache-Control": "private, max-age=3600",
},
)
+7 -22
View File
@@ -47,16 +47,15 @@ async def upload_call_audio(
if len(data) > settings.upload_max_bytes: if len(data) > settings.upload_max_bytes:
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).") raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
audio_url = await upload_audio(data, file.filename or "", call_id=call_id) gcs_uri = await upload_audio(data, file.filename or "", call_id=call_id)
if audio_url: if gcs_uri:
try: try:
await fstore.doc_set("calls", call_id, {"audio_url": audio_url}) # Canonical object location only. The playback link is minted per
# read in storage.playback_url() — nothing durable is stored here.
await fstore.doc_set("calls", call_id, {"audio_gcs_uri": gcs_uri})
except Exception as e: except Exception as e:
logger.warning(f"Could not update call {call_id} with audio_url: {e}") logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
# Convert public GCS URL to gs:// URI for Speech-to-Text
gcs_uri = _public_url_to_gcs_uri(audio_url)
background_tasks.add_task( background_tasks.add_task(
_run_intelligence_pipeline, _run_intelligence_pipeline,
@@ -68,21 +67,7 @@ async def upload_call_audio(
gcs_uri=gcs_uri, gcs_uri=gcs_uri,
) )
return {"url": audio_url} return {"url": gcs_uri}
def _public_url_to_gcs_uri(url: str) -> Optional[str]:
"""
Convert a public GCS URL (possibly signed) like
https://storage.googleapis.com/bucket/calls/file.mp3?Expires=...
to a gs:// URI usable by Speech-to-Text.
Returns None if the URL doesn't look like a GCS URL.
"""
prefix = "https://storage.googleapis.com/"
if url and url.startswith(prefix):
path = url[len(prefix):].split("?")[0] # strip signed-URL query params
return "gs://" + path
return None
async def _correlate_with_consensus( async def _correlate_with_consensus(
+31 -5
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import type { CallRecord } from "@/lib/types"; import type { CallRecord } from "@/lib/types";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
@@ -37,11 +37,31 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
: call.incident_id ? [call.incident_id] : []; : call.incident_id ? [call.incident_id] : [];
const isActive = call.status === "active"; const isActive = call.status === "active";
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || call.audio_url; // Rows come straight from Firestore (lib/useCalls.ts), and the doc only holds
// the private gs:// object location — never a playable URL. Presence of audio
// is known from the doc; the actual link is minted by the API on expand.
// audio_url is the legacy field: older docs stored an (unplayable) gs:// URI
// there, so it still signals "this call has a recording".
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || hasAudio;
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript; const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
const hasBoth = !!(call.transcript && call.transcript_corrected); const hasBoth = !!(call.transcript && call.transcript_corrected);
const hasSegments = call.segments && call.segments.length > 1; const hasSegments = call.segments && call.segments.length > 1;
// Fetched lazily on expand: playback links are short-lived, so minting one
// for every row up front would waste most of them and expire the rest.
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [audioError, setAudioError] = useState(false);
useEffect(() => {
if (!expanded || !hasAudio || audioUrl || audioError) return;
let cancelled = false;
c2api.getCall(call.call_id)
.then((full) => { if (!cancelled) setAudioUrl(full.audio_url ?? null); })
.catch(() => { if (!cancelled) setAudioError(true); });
return () => { cancelled = true; };
}, [expanded, hasAudio, audioUrl, audioError, call.call_id]);
function startEdit() { function startEdit() {
setEditText(call.transcript_corrected ?? call.transcript ?? ""); setEditText(call.transcript_corrected ?? call.transcript ?? "");
setEditing(true); setEditing(true);
@@ -89,7 +109,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
)} )}
</td> </td>
<td className="px-4 py-2 text-xs"> <td className="px-4 py-2 text-xs">
{call.audio_url ? ( {hasAudio ? (
<span className="text-blue-400">▶</span> <span className="text-blue-400">▶</span>
) : ( ) : (
<span className="text-gray-700">—</span> <span className="text-gray-700">—</span>
@@ -104,13 +124,19 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
<tr className="bg-gray-900/60 border-b border-gray-800"> <tr className="bg-gray-900/60 border-b border-gray-800">
<td colSpan={7} className="px-6 py-3 space-y-2"> <td colSpan={7} className="px-6 py-3 space-y-2">
{/* Audio player */} {/* Audio player */}
{call.audio_url && ( {hasAudio && (
audioError ? (
<p className="text-xs text-red-400 font-mono">Could not load audio.</p>
) : audioUrl ? (
<audio <audio
controls controls
src={call.audio_url} src={audioUrl}
className="w-full max-w-sm h-8" className="w-full max-w-sm h-8"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
) : (
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
)
)} )}
{/* Tags */} {/* Tags */}
+7
View File
@@ -93,6 +93,13 @@ export interface CallRecord {
freq: number | null; freq: number | null;
started_at: string; started_at: string;
ended_at: string | null; ended_at: string | null;
/** Private gs:// object location. Present on the Firestore doc; not playable. */
audio_gcs_uri?: string | null;
/**
* Short-lived playback link. Minted per read by the API — only populated on
* calls fetched via c2api, never on docs read straight from Firestore.
* On pre-fix docs this holds a legacy (unplayable) gs:// URI instead.
*/
audio_url: string | null; audio_url: string | null;
transcript: string | null; transcript: string | null;
transcript_corrected: string | null; transcript_corrected: string | null;
@@ -12,9 +12,17 @@ MQTT_DYNSEC_ADMIN_PASS={{ vault_mqtt_dynsec_admin_pass }}
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials # No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
# via the GCE metadata server. The Terraform IAM bindings grant the required roles. # via the GCE metadata server. The Terraform IAM bindings grant the required roles.
# NOTE: because there is no service-account key file here, c2-core cannot mint
# GCS *signed* URLs. Call audio is therefore served through c2-core's own
# /media route (app/routers/media.py) rather than direct-from-bucket links.
FIRESTORE_DATABASE={{ vault_firestore_database }} FIRESTORE_DATABASE={{ vault_firestore_database }}
GCS_BUCKET={{ vault_gcs_bucket }} GCS_BUCKET={{ vault_gcs_bucket }}
# Absolute origin for call-audio playback links. The browser fetches <audio src>
# directly, so a relative path would resolve against the frontend origin
# (https://{{ domain }}) instead of the API's.
PUBLIC_API_URL=https://api.{{ domain }}
OPENAI_API_KEY={{ vault_openai_api_key }} OPENAI_API_KEY={{ vault_openai_api_key }}
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }} GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
GEMINI_API_KEY={{ vault_gemini_api_key }} GEMINI_API_KEY={{ vault_gemini_api_key }}