feat(CG5-onboard): token-basert invite-system + offentlig onboard-flow

This commit is contained in:
chrischristiansen-glitch 2026-06-15 16:40:14 +02:00
parent 099c1cceab
commit 4f3a802883
2 changed files with 240 additions and 240 deletions

44
agents/invite_store.py Normal file
View File

@ -0,0 +1,44 @@
"""
invite_store.py Enkel Firestore-basert invite-token store.
Brukes av POST /onboard/invite og GET /onboard/invite/{token}.
"""
import secrets
import datetime
from typing import Optional
TOKEN_TTL_HOURS = 72
class InviteStore:
def __init__(self, db):
self.db = db
self._col = "onboard_invites"
def create(self, company: str, email: str, tier: str, created_by: str = "system") -> str:
token = secrets.token_urlsafe(32)
expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=TOKEN_TTL_HOURS)
self.db.collection(self._col).document(token).set({
"company": company,
"email": email,
"tier": tier,
"created_by": created_by,
"created_at": datetime.datetime.utcnow().isoformat(),
"expires_at": expires_at.isoformat(),
"used": False,
})
return token
def get(self, token: str) -> Optional[dict]:
doc = self.db.collection(self._col).document(token).get()
if not doc.exists:
return None
data = doc.to_dict()
if data.get("used"):
return None
expires_at = datetime.datetime.fromisoformat(data["expires_at"])
if datetime.datetime.utcnow() > expires_at:
return None
return data
def mark_used(self, token: str):
self.db.collection(self._col).document(token).update({"used": True})

436
main.py
View File

@ -10,14 +10,15 @@ CG5a: POST /notify/webhook — push til Slack/Teams/Chat/Discord/etc.
CG5b: POST /notify/email SendGrid digest til kunde-e-post.
CG5c: POST /notify/sms Twilio SMS, Guard+-tier, spike-varsler.
CG5-onboard: GET /onboard + POST /onboard/complete klient-onboarding wizard.
CG5-invite: POST /onboard/invite opprett token-link (intern).
GET /onboard/invite/{token} offentlig onboard-inngang (IAP-exempt).
DOCS: GET /docs/{path} proxy til privat GitHub-repo via ADC/PAT.
OQ-29: GET /notify/channels list konfigurerte delivery-kanaler (env-var-basert).
OQ-31: GET /opax/build-status ekte Cloud Build-status via REST API.
CG4-credits: GET /billing/credits kreditt-saldo, burn rate og tom-dato.
Krever GOOGLE_CREDIT_TOTAL_USD i Cloud Run env for runway-beregning.
TERMINAL: POST /terminal/exec whitelisted kommandoer: health, billing, build, logs, help.
TERMINAL: POST /terminal/exec whitelisted kommandoer.
VM: GET /vm/ssh-key henter public key fra Compute Engine metadata.
ML-3a: POST /emma intern agent (Emma Vauger). EMMA_BACKEND=vertex () | local (VM-trigger).
ML-3a: POST /emma intern agent (Emma Vauger). EMMA_BACKEND=vertex|local.
"""
import os
@ -45,7 +46,7 @@ from ml.billing_agent import BillingAgent
from agents.aws_billing_agent import AWSBillingAgent
from ml.anomaly_detector import AnomalyDetector
from auth.token_store import save_token
from agents.invite_store import InviteStore
from google.cloud import firestore
import firebase_admin
@ -61,19 +62,15 @@ from sendgrid.helpers.mail import Mail
AGENT_ID = "osvauco-opax"
PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "propane-will-491900-m5")
BASE_URL = os.environ.get("BASE_URL", "https://opax.vauco.no")
# Guard+-tiers som får tilgang til SMS-varsler
SMS_ALLOWED_TIERS = {"guard", "shield", "enterprise"}
# ── EMMA: backend-flagg ───────────────────────────────────────────────────────
# EMMA_BACKEND=vertex → Vertex AI (nå, bruker eksisterende kreditter)
# EMMA_BACKEND=local → lokal Gemma 4 på g2-standard-4 Spot VM (trigger: første kunde)
# EMMA_LOCAL_URL → http://<VM-IP>:8000/generate (kun brukt når EMMA_BACKEND=local)
EMMA_BACKEND = os.environ.get("EMMA_BACKEND", "vertex")
EMMA_LOCAL_URL = os.environ.get("EMMA_LOCAL_URL", "http://localhost:8000/generate")
EMMA_MODEL = os.environ.get("EMMA_MODEL", "google/gemma-3-12b-it") # Vertex model ID
EMMA_MODEL = os.environ.get("EMMA_MODEL", "google/gemma-3-12b-it")
# Last Emma sitt system-prompt fra world.md ved oppstart
_WORLD_MD_PATH = pathlib.Path(__file__).parent / "docs" / "gemma" / "world.md"
try:
_EMMA_SYSTEM_PROMPT = _WORLD_MD_PATH.read_text(encoding="utf-8")
@ -86,12 +83,16 @@ app = FastAPI(
version="0.1.0",
)
# Paths exempt from IAP enforcement
# IAP-exempt: helsepunkter + offentlig onboarding-inngang
IAP_EXEMPT_PATHS = {"/health", "/healthz", "/readiness", "/liveness", "/onboard"}
IAP_EXEMPT_PREFIXES = ("/onboard/invite/",)
@app.middleware("http")
async def require_iap(request: Request, call_next):
if request.url.path in IAP_EXEMPT_PATHS:
path = request.url.path
if path in IAP_EXEMPT_PATHS:
return await call_next(request)
if any(path.startswith(p) for p in IAP_EXEMPT_PREFIXES):
return await call_next(request)
if not request.headers.get("x-goog-authenticated-user-email"):
return Response(status_code=401, content="Unauthorized")
@ -119,7 +120,6 @@ oauth.register(
client_kwargs={'scope': 'openid email profile'}
)
@app.get('/auth/login')
async def login(request: Request):
redirect_uri = "https://opax.vauco.no/auth/callback"
@ -133,7 +133,6 @@ async def auth(request: Request):
request.session['user'] = dict(user)
return RedirectResponse(url='/static/billing-dashboard.html')
@app.get("/auth/me")
async def me(request: Request):
user = request.session.get("user")
@ -141,7 +140,6 @@ async def auth(request: Request):
raise HTTPException(status_code=401, detail="Not authenticated")
return JSONResponse(user)
@app.get('/auth/logout')
async def logout(request: Request):
request.session.pop('user', None)
@ -153,7 +151,6 @@ ALERT_EMAIL = os.environ.get("ALERT_EMAIL")
def require_auth(func):
"""Decorator to protect endpoints that require authentication."""
@wraps(func)
async def wrapper(request: Request, *args, **kwargs):
user = request.session.get('user')
@ -171,7 +168,7 @@ if _static_dir.is_dir():
app.mount("/static", StaticFiles(directory=str(_static_dir), html=True), name="static")
# ── DOCS PROXY (MD-filer fra privat GitHub-repo) ──────────────────────────────
# ── DOCS PROXY ─────────────────────────────────────────────────────────────────
_GH_REPO = "vauco-saas/OSVauco"
_GH_BRANCH = os.environ.get("DOCS_BRANCH", "main")
@ -188,13 +185,13 @@ async def proxy_doc(path: str):
if resp.status_code == 404:
raise HTTPException(status_code=404, detail=f"Fil ikke funnet: {path}")
if resp.status_code == 401:
raise HTTPException(status_code=503, detail="GitHub auth feilet — sjekk GITHUB_PAT i Secret Manager")
raise HTTPException(status_code=503, detail="GitHub auth feilet")
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail="GitHub feil")
return Response(content=resp.text, media_type="text/plain; charset=utf-8")
# ── OQ-31: Cloud Build status (REST API) ─────────────────────────────────────
# ── OQ-31: Cloud Build status ────────────────────────────────────────────────────
@app.get("/opax/build-status")
async def build_status():
try:
@ -214,7 +211,7 @@ async def build_status():
return JSONResponse({"status": "error", "message": str(e)}, status_code=200)
# ── TERMINAL: POST /terminal/exec ─────────────────────────────────────────────
# ── TERMINAL ───────────────────────────────────────────────────────────────────
TERMINAL_WHITELIST = {"health", "billing", "build", "logs", "help"}
class TerminalExecRequest(BaseModel):
@ -224,32 +221,18 @@ class TerminalExecRequest(BaseModel):
async def terminal_exec(req: TerminalExecRequest):
cmd = req.cmd.strip().lower().split()[0] if req.cmd.strip() else ""
if cmd not in TERMINAL_WHITELIST:
return JSONResponse(
{"output": f"Ukjent kommando: '{req.cmd}'. Lov: {', '.join(sorted(TERMINAL_WHITELIST))}", "exit_code": 1}
)
return JSONResponse({"output": f"Ukjent kommando: '{req.cmd}'. Lov: {', '.join(sorted(TERMINAL_WHITELIST))}", "exit_code": 1})
try:
if cmd == "help":
lines = [
"Tilgjengelige kommandoer:",
" health — sjekk /health",
" billing — hent billing summary",
" build — hent siste build-status",
" logs — siste 20 linjer fra Cloud Logging",
" help — vis denne listen",
]
return {"output": "\n".join(lines), "exit_code": 0}
return {"output": "Tilgjengelige kommandoer:\n health \u2014 sjekk /health\n billing \u2014 hent billing summary\n build \u2014 hent siste build-status\n logs \u2014 siste 20 linjer fra Cloud Logging\n help \u2014 vis denne listen", "exit_code": 0}
if cmd == "health":
return {"output": json.dumps({"status": "ok"}, ensure_ascii=False), "exit_code": 0}
if cmd == "billing":
try:
summary = BillingAgent().get_summary()
return {"output": json.dumps(summary, ensure_ascii=False, indent=2, default=str), "exit_code": 0}
except Exception as e:
return {"output": f"billing feilet: {e}", "exit_code": 1}
if cmd == "build":
try:
creds, project = google.auth.default()
@ -259,34 +242,17 @@ async def terminal_exec(req: TerminalExecRequest):
r = await client.get(url, headers={"Authorization": f"Bearer {creds.token}"})
r.raise_for_status()
builds = r.json().get("builds", [])
result = [{"id": b["id"], "status": b["status"],
"branch": b.get("substitutions", {}).get("BRANCH_NAME", ""),
"createTime": b["createTime"]} for b in builds]
result = [{"id": b["id"], "status": b["status"], "branch": b.get("substitutions", {}).get("BRANCH_NAME", ""), "createTime": b["createTime"]} for b in builds]
return {"output": json.dumps(result, ensure_ascii=False, indent=2, default=str), "exit_code": 0}
except Exception as e:
return {"output": f"build feilet: {e}", "exit_code": 1}
if cmd == "logs":
try:
creds, project = google.auth.default()
creds.refresh(google.auth.transport.requests.Request())
log_filter = (
'resource.type="cloud_run_revision" '
'resource.labels.service_name="osvauco-agent" '
'severity>=DEFAULT'
)
body = {
"resourceNames": [f"projects/{project}"],
"filter": log_filter,
"orderBy": "timestamp desc",
"pageSize": 20,
}
body = {"resourceNames": [f"projects/{project}"], "filter": 'resource.type="cloud_run_revision" resource.labels.service_name="osvauco-agent" severity>=DEFAULT', "orderBy": "timestamp desc", "pageSize": 20}
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.post(
"https://logging.googleapis.com/v2/entries:list",
headers={"Authorization": f"Bearer {creds.token}"},
json=body,
)
r = await client.post("https://logging.googleapis.com/v2/entries:list", headers={"Authorization": f"Bearer {creds.token}"}, json=body)
entries = r.json().get("entries", [])
if not entries:
return {"output": "(ingen logglinjer funnet)", "exit_code": 0}
@ -299,44 +265,33 @@ async def terminal_exec(req: TerminalExecRequest):
return {"output": "\n".join(lines), "exit_code": 0}
except Exception as e:
return {"output": f"logs feilet: {e}", "exit_code": 1}
except Exception as e:
return {"output": f"Intern feil: {e}", "exit_code": 1}
return {"output": "", "exit_code": 0}
# ── VM: GET /vm/ssh-key ────────────────────────────────────────────────────────
# ── VM SSH-KEY ──────────────────────────────────────────────────────────────────
@app.get("/vm/ssh-key")
async def vm_ssh_key(instance: str = "osvauco-dev-vm"):
try:
creds, project = google.auth.default()
creds.refresh(google.auth.transport.requests.Request())
zone = "us-central1-b"
url = (
f"https://compute.googleapis.com/compute/v1/projects/{project}"
f"/zones/{zone}/instances/{instance}"
)
url = f"https://compute.googleapis.com/compute/v1/projects/{project}/zones/{zone}/instances/{instance}"
async with httpx.AsyncClient(timeout=10.0) as client:
r = await client.get(url, headers={"Authorization": f"Bearer {creds.token}"})
if r.status_code == 404:
raise HTTPException(status_code=404, detail=f"Instans ikke funnet: {instance}")
if r.status_code != 200:
raise HTTPException(status_code=r.status_code, detail=f"Compute API feil: {r.text[:200]}")
data = r.json()
metadata_items = data.get("metadata", {}).get("items", [])
ssh_keys_raw = next(
(item["value"] for item in metadata_items if item["key"] == "ssh-keys"),
None
)
ssh_keys_raw = next((item["value"] for item in metadata_items if item["key"] == "ssh-keys"), None)
if not ssh_keys_raw:
return {"public_key": None, "instance": instance, "detail": "Ingen SSH-nøkkel funnet i metadata"}
public_key = ssh_keys_raw.strip()
if ":" in public_key:
public_key = public_key.split(":", 1)[1].strip()
return {"public_key": public_key, "instance": instance}
except HTTPException:
raise
@ -344,36 +299,24 @@ async def vm_ssh_key(instance: str = "osvauco-dev-vm"):
raise HTTPException(status_code=500, detail=f"vm/ssh-key feilet: {e}")
# ── OQ-29: GET /notify/channels ───────────────────────────────────────────────
# ── OQ-29: NOTIFY CHANNELS ───────────────────────────────────────────────────
@app.get("/notify/channels")
async def get_notify_channels():
return {
"channels": [
{"type": "email", "configured": bool(os.getenv("SENDGRID_API_KEY")), "target": os.getenv("NOTIFY_EMAIL_TO", None)},
{"type": "sms", "configured": bool(os.getenv("TWILIO_ACCOUNT_SID")), "target": os.getenv("TWILIO_FROM_NUMBER", None)},
{"type": "webhook", "configured": bool(os.getenv("NOTIFY_WEBHOOK_URL")), "url": os.getenv("NOTIFY_WEBHOOK_URL", None)},
]
}
return {"channels": [
{"type": "email", "configured": bool(os.getenv("SENDGRID_API_KEY")), "target": os.getenv("NOTIFY_EMAIL_TO")},
{"type": "sms", "configured": bool(os.getenv("TWILIO_ACCOUNT_SID")), "target": os.getenv("TWILIO_FROM_NUMBER")},
{"type": "webhook", "configured": bool(os.getenv("NOTIFY_WEBHOOK_URL")), "url": os.getenv("NOTIFY_WEBHOOK_URL")},
]}
# ── EMMA: ML-3a intern agent ──────────────────────────────────────────────────
# ── EMMA: ML-3a ────────────────────────────────────────────────────────────────────
class EmmaRequest(BaseModel):
message: str
session_id: str = "emma-default"
history: Optional[List[Dict[str, str]]] = Field(
default=None,
description="Valgfri samtalehistorikk: [{role: user|model, content: str}]"
)
history: Optional[List[Dict[str, str]]] = Field(default=None)
class EmmaChatService:
"""
Håndterer Emma-kall mot enten:
- Vertex AI (EMMA_BACKEND=vertex) , bruker Vertex-kreditter
- Lokal Gemma 4 VM (EMMA_BACKEND=local) når trigger er nådd
Bytt backend: sett EMMA_BACKEND=local + EMMA_LOCAL_URL=http://<VM-IP>:8000/generate
"""
async def chat(self, message: str, history: Optional[List[Dict]] = None) -> str:
if EMMA_BACKEND == "local":
return await self._local(message, history)
@ -383,19 +326,13 @@ class EmmaChatService:
try:
import vertexai
from vertexai.generative_models import GenerativeModel, Content, Part
vertexai.init(project=PROJECT_ID, location="us-central1")
model = GenerativeModel(
EMMA_MODEL,
system_instruction=_EMMA_SYSTEM_PROMPT,
)
model = GenerativeModel(EMMA_MODEL, system_instruction=_EMMA_SYSTEM_PROMPT)
chat_history = []
for turn in (history or []):
role = turn.get("role", "user")
content = turn.get("content", "")
chat_history.append(Content(role=role, parts=[Part.from_text(content)]))
chat = model.start_chat(history=chat_history)
response = chat.send_message(message)
return response.text
@ -403,22 +340,14 @@ class EmmaChatService:
raise HTTPException(status_code=500, detail=f"Emma (Vertex) feilet: {e}")
async def _local(self, message: str, history: Optional[List[Dict]] = None) -> str:
"""
Kaller lokal Gemma 4 VM via HTTP.
Forventet format: POST {EMMA_LOCAL_URL} med {prompt, system_prompt}
"""
turns = ""
for turn in (history or []):
role = "User" if turn.get("role") == "user" else "Emma"
turns += f"{role}: {turn.get('content', '')}\n"
full_prompt = f"{turns}User: {message}\nEmma:"
try:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
EMMA_LOCAL_URL,
json={"prompt": full_prompt, "system_prompt": _EMMA_SYSTEM_PROMPT, "max_tokens": 1024},
)
resp = await client.post(EMMA_LOCAL_URL, json={"prompt": full_prompt, "system_prompt": _EMMA_SYSTEM_PROMPT, "max_tokens": 1024})
resp.raise_for_status()
data = resp.json()
return data.get("response") or data.get("text") or data.get("generated_text", "")
@ -430,12 +359,7 @@ class EmmaChatService:
_emma = EmmaChatService()
async def _emma_chat(request: Request, req: EmmaRequest):
"""
POST /emma intern agent, kun for Chris (IAP-beskyttet).
Backend: EMMA_BACKEND env-var (vertex | local).
"""
start = time.monotonic()
response_text = await _emma.chat(req.message, req.history)
duration = round(time.monotonic() - start, 3)
@ -443,13 +367,7 @@ async def _emma_chat(request: Request, req: EmmaRequest):
store.push("emma", "last_duration_s", duration)
store.push("emma", "last_backend", EMMA_BACKEND)
store.push("emma", "last_success", True)
return JSONResponse({
"response": response_text,
"backend": EMMA_BACKEND,
"model": EMMA_MODEL,
"duration_s": duration,
"session_id": req.session_id,
})
return JSONResponse({"response": response_text, "backend": EMMA_BACKEND, "model": EMMA_MODEL, "duration_s": duration, "session_id": req.session_id})
app.add_api_route("/emma", endpoint=require_auth(_emma_chat), methods=["POST"])
@ -461,7 +379,6 @@ class RunRequest(BaseModel):
session_id: str = "default"
mode: str = "light"
class DagRequest(BaseModel):
messages: List[str] = Field(...)
user_id: str = "opax"
@ -481,29 +398,29 @@ class BudgetUpdateRequest(BaseModel):
budget: float
# ── NOTIFICATION MODELS (CG5a + CG5b + CG5c) ──────────────────────────────────
# ── NOTIFICATION MODELS ──────────────────────────────────────────────────────────
class WebhookNotifyRequest(BaseModel):
url: str = Field(..., description="Webhook-URL (Slack/Teams/Google Chat/Discord/custom)")
event: str = Field("custom", description="Event-type: spike | digest | budget | anomaly | custom")
title: str = Field("CostGuard varsel", description="Tittel på meldingen")
body: str = Field(..., description="Meldingstekst")
payload: Optional[Dict[str, Any]] = Field(None, description="Valgfri rådata å inkludere")
url: str
event: str = "custom"
title: str = "CostGuard varsel"
body: str
payload: Optional[Dict[str, Any]] = None
class EmailNotifyRequest(BaseModel):
to: Optional[str] = Field(None, description="Mottaker-e-post. Faller tilbake til ALERT_EMAIL hvis tom.")
subject: Optional[str] = Field(None, description="Emne. Auto-generert fra event hvis tom.")
event: str = Field("digest", description="Event-type: spike | digest | budget | anomaly | custom")
body_html: Optional[str] = Field(None, description="HTML-innhold. Auto-generert fra billing-data hvis tom.")
payload: Optional[Dict[str, Any]] = Field(None, description="Valgfri rådata å inkludere i e-post")
to: Optional[str] = None
subject: Optional[str] = None
event: str = "digest"
body_html: Optional[str] = None
payload: Optional[Dict[str, Any]] = None
class SmsNotifyRequest(BaseModel):
to: str = Field(..., description="Mottakers mobilnummer i E.164-format", pattern=r"^\+[1-9]\d{7,14}$")
body: str = Field(..., description="Meldingstekst. Maks 160 tegn anbefalt.", max_length=320)
event: str = Field("spike", description="Event-type: spike | budget | anomaly | custom")
tier: str = Field(..., description="Kundens tier. Må være guard, shield eller enterprise.")
to: str = Field(..., pattern=r"^\+[1-9]\d{7,14}$")
body: str = Field(..., max_length=320)
event: str = "spike"
tier: str
# ── ONBOARDING MODEL (CG5-onboard) ────────────────────────────────────────────
# ── ONBOARDING MODELS ───────────────────────────────────────────────────────────
class OnboardCompleteRequest(BaseModel):
company: str
contact: str
@ -513,9 +430,15 @@ class OnboardCompleteRequest(BaseModel):
channels: List[str]
webhook_url: Optional[str] = None
email_to: Optional[str] = None
invite_token: Optional[str] = None
class InviteCreateRequest(BaseModel):
company: str
email: str
tier: str = "starter"
# ── NOTIFICATION SERVICE (CG5a + CG5b + CG5c) ─────────────────────────────────
# ── NOTIFICATION SERVICE ─────────────────────────────────────────────────────────
class NotificationService:
EVENT_META = {
"spike": {"emoji": "🚨", "label": "Kostnadsspike oppdaget"},
@ -525,39 +448,30 @@ class NotificationService:
"custom": {"emoji": "📢", "label": "CostGuard varsel"},
}
def _event_meta(self, event: str) -> dict:
def _event_meta(self, event):
return self.EVENT_META.get(event, self.EVENT_META["custom"])
def _build_slack_payload(self, req: WebhookNotifyRequest) -> dict:
def _build_slack_payload(self, req):
meta = self._event_meta(req.event)
return {
"text": f"{meta['emoji']} *{req.title}*\n{req.body}",
"attachments": [
{
"color": "#FF6B35" if req.event in ("spike", "budget") else "#4A90D9",
"fields": [
{"title": k, "value": str(v), "short": True}
for k, v in (req.payload or {}).items()
]
}
] if req.payload else []
"attachments": [{"color": "#FF6B35" if req.event in ("spike", "budget") else "#4A90D9", "fields": [{"title": k, "value": str(v), "short": True} for k, v in (req.payload or {}).items()]}] if req.payload else []
}
async def send_webhook(self, req: WebhookNotifyRequest) -> dict:
slack_payload = self._build_slack_payload(req)
async def send_webhook(self, req) -> dict:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(req.url, json=slack_payload, headers={"Content-Type": "application/json"})
resp = await client.post(req.url, json=self._build_slack_payload(req), headers={"Content-Type": "application/json"})
if resp.status_code >= 400:
return {"status": "error", "http_status": resp.status_code, "detail": resp.text[:200]}
return {"status": "ok", "http_status": resp.status_code, "event": req.event}
except httpx.TimeoutException:
return {"status": "error", "detail": "Webhook timeout (>10s)"}
return {"status": "error", "detail": "Webhook timeout"}
except Exception as e:
return {"status": "error", "detail": str(e)}
async def send_email(self, req: EmailNotifyRequest) -> dict:
api_key = os.environ.get("SENDGRID_API_KEY")
async def send_email(self, req) -> dict:
api_key = os.environ.get("SENDGRID_API_KEY")
to_email = req.to or ALERT_EMAIL
if not api_key:
return {"status": "not_configured", "detail": "SENDGRID_API_KEY mangler"}
@ -570,14 +484,13 @@ class NotificationService:
message = Mail(from_email="costguard@osvauco.no", to_emails=to_email, subject=subject, html_content=html_content)
sg = sendgrid.SendGridAPIClient(api_key)
response = sg.send(message)
msg_id = response.headers.get("X-Message-Id", "")
return {"status": "ok", "message_id": msg_id, "to": to_email, "event": req.event}
return {"status": "ok", "message_id": response.headers.get("X-Message-Id", ""), "to": to_email, "event": req.event}
except Exception as e:
return {"status": "error", "detail": str(e)}
async def send_sms(self, req: SmsNotifyRequest) -> dict:
async def send_sms(self, req) -> dict:
if req.tier.lower() not in SMS_ALLOWED_TIERS:
return {"status": "not_allowed", "detail": f"SMS krever Guard+-tier. Aktuell tier: '{req.tier}'."}
return {"status": "not_allowed", "detail": f"SMS krever Guard+-tier."}
account_sid = os.environ.get("TWILIO_ACCOUNT_SID")
auth_token = os.environ.get("TWILIO_AUTH_TOKEN")
from_number = os.environ.get("TWILIO_FROM_NUMBER")
@ -585,68 +498,44 @@ class NotificationService:
return {"status": "not_configured", "detail": "Twilio env-vars mangler"}
meta = self._event_meta(req.event)
sms_body = req.body if req.body.startswith(meta["emoji"]) else f"{meta['emoji']} CostGuard: {req.body}"
twilio_url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(twilio_url, data={"From": from_number, "To": req.to, "Body": sms_body}, auth=(account_sid, auth_token))
resp = await client.post(f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json", data={"From": from_number, "To": req.to, "Body": sms_body}, auth=(account_sid, auth_token))
data = resp.json()
if resp.status_code >= 400:
return {"status": "error", "http_status": resp.status_code, "detail": data.get("message", "")}
return {"status": "ok", "sid": data.get("sid"), "to": req.to, "sms_status": data.get("status"), "event": req.event}
except httpx.TimeoutException:
return {"status": "error", "detail": "Twilio timeout (>15s)"}
except Exception as e:
return {"status": "error", "detail": str(e)}
async def _build_digest_html(self, extra_payload: Optional[dict] = None) -> str:
async def _build_digest_html(self, extra_payload=None):
try:
billing = BillingAgent()
summary = billing.get_summary().get("summary", [])
billing = BillingAgent()
summary = billing.get_summary().get("summary", [])
forecast = billing.get_forecast()
mtd = forecast.get("month_to_date_cost", 0)
daily_avg = forecast.get("daily_average_last_7_days", 0)
top3 = "".join(f"<li>{s['service']}: kr {s.get('total_cost', 0):.2f}</li>" for s in summary[:3])
mtd = forecast.get("month_to_date_cost", 0)
daily = forecast.get("daily_average_last_7_days", 0)
top3 = "".join(f"<li>{s['service']}: kr {s.get('total_cost',0):.2f}</li>" for s in summary[:3])
except Exception:
mtd, daily_avg, top3 = 0, 0, "<li>Data ikke tilgjengelig</li>"
extra_rows = "".join(
f"<tr><td style='padding:4px 8px;color:#666'>{k}</td><td style='padding:4px 8px'><strong>{v}</strong></td></tr>"
for k, v in (extra_payload or {}).items()
)
return f"""
<div style='font-family:sans-serif;max-width:600px;margin:0 auto'>
<div style='background:#1a1a2e;padding:20px;border-radius:8px 8px 0 0'>
<h2 style='color:#fff;margin:0'>📊 CostGuard Daglig oppsummering</h2>
<p style='color:#aaa;margin:4px 0 0'>{datetime.date.today()}</p>
</div>
<div style='background:#f9f9f9;padding:20px;border-radius:0 0 8px 8px'>
<table style='width:100%;border-collapse:collapse'>
<tr><td style='padding:4px 8px;color:#666'>Kostnad MTD</td><td style='padding:4px 8px'><strong>kr {mtd:.2f}</strong></td></tr>
<tr><td style='padding:4px 8px;color:#666'>Daglig snitt (7d)</td><td style='padding:4px 8px'><strong>kr {daily_avg:.2f}</strong></td></tr>
{extra_rows}
</table>
<h4 style='margin-top:16px'>Topp 3 GCP-tjenester</h4>
<ul>{top3}</ul>
<hr style='border:none;border-top:1px solid #ddd;margin:16px 0'>
<p style='color:#999;font-size:12px'>CostGuard by Vauco AS · opax.vauco.no</p>
</div>
</div>
"""
mtd, daily, top3 = 0, 0, "<li>Data ikke tilgjengelig</li>"
extra = "".join(f"<tr><td style='padding:4px 8px;color:#666'>{k}</td><td style='padding:4px 8px'><strong>{v}</strong></td></tr>" for k, v in (extra_payload or {}).items())
return f"<div style='font-family:sans-serif;max-width:600px;margin:0 auto'><div style='background:#1a1a2e;padding:20px;border-radius:8px 8px 0 0'><h2 style='color:#fff;margin:0'>📊 CostGuard — Daglig oppsummering</h2><p style='color:#aaa;margin:4px 0 0'>{datetime.date.today()}</p></div><div style='background:#f9f9f9;padding:20px;border-radius:0 0 8px 8px'><table style='width:100%;border-collapse:collapse'><tr><td style='padding:4px 8px;color:#666'>Kostnad MTD</td><td style='padding:4px 8px'><strong>kr {mtd:.2f}</strong></td></tr><tr><td style='padding:4px 8px;color:#666'>Daglig snitt (7d)</td><td style='padding:4px 8px'><strong>kr {daily:.2f}</strong></td></tr>{extra}</table><h4 style='margin-top:16px'>Topp 3 GCP-tjenester</h4><ul>{top3}</ul><hr style='border:none;border-top:1px solid #ddd;margin:16px 0'><p style='color:#999;font-size:12px'>CostGuard by Vauco AS · opax.vauco.no</p></div></div>"
_notifier = NotificationService()
# ── NOTIFY ENDPOINTS (CG5a + CG5b + CG5c) ─────────────────────────────────────
# ── NOTIFY ENDPOINTS ───────────────────────────────────────────────────────────────
async def _notify_webhook(request: Request, req: WebhookNotifyRequest):
result = await _notifier.send_webhook(req)
return JSONResponse(status_code=200 if result["status"] == "ok" else 502, content=result)
return JSONResponse(status_code=200 if result["status"]=="ok" else 502, content=result)
app.add_api_route("/notify/webhook", endpoint=require_auth(_notify_webhook), methods=["POST"])
async def _notify_email(request: Request, req: EmailNotifyRequest):
result = await _notifier.send_email(req)
if result["status"] == "not_configured":
return JSONResponse(status_code=503, content=result)
return JSONResponse(status_code=200 if result["status"] == "ok" else 500, content=result)
return JSONResponse(status_code=200 if result["status"]=="ok" else 500, content=result)
app.add_api_route("/notify/email", endpoint=require_auth(_notify_email), methods=["POST"])
async def _notify_sms(request: Request, req: SmsNotifyRequest):
@ -655,18 +544,108 @@ async def _notify_sms(request: Request, req: SmsNotifyRequest):
return JSONResponse(status_code=402, content=result)
if result["status"] == "not_configured":
return JSONResponse(status_code=503, content=result)
return JSONResponse(status_code=200 if result["status"] == "ok" else 500, content=result)
return JSONResponse(status_code=200 if result["status"]=="ok" else 500, content=result)
app.add_api_route("/notify/sms", endpoint=require_auth(_notify_sms), methods=["POST"])
# ── ONBOARDING ENDPOINTS (CG5-onboard) ───────────────────────────────────────
# ── ONBOARDING ENDPOINTS ─────────────────────────────────────────────────────────
@app.get("/onboard")
def onboard_wizard():
return FileResponse("static/onboard.html")
f = pathlib.Path(__file__).parent / "static" / "onboard.html"
return FileResponse(str(f))
@app.get("/onboard/invite/{token}")
async def onboard_invite_landing(token: str):
"""
Offentlig URL Jason sender til potensiell kunde.
Validerer token, server onboard.html med prefilled data i meta-tag.
IAP-exempt via IAP_EXEMPT_PREFIXES.
"""
if not db:
raise HTTPException(status_code=503, detail="Databasefeil")
store = InviteStore(db)
invite = store.get(token)
if not invite:
raise HTTPException(status_code=404, detail="Invitasjonen er ugyldig eller utløpt.")
html = (pathlib.Path(__file__).parent / "static" / "onboard.html").read_text(encoding="utf-8")
# Inject invite data as a script block before </head>
inject = f"""
<script>
window.__INVITE__ = {{
token: "{token}",
company: "{invite.get('company', '')}",
email: "{invite.get('email', '')}",
tier: "{invite.get('tier', 'starter')}"
}};
</script>"""
html = html.replace("</head>", inject + "\n</head>", 1)
return Response(content=html, media_type="text/html")
async def _onboard_invite_create(request: Request, req: InviteCreateRequest):
"""
POST /onboard/invite intern, IAP-beskyttet.
Oppretter token og returnerer ferdig URL Chris/Jason kan sende til kunde.
"""
if not db:
raise HTTPException(status_code=503, detail="Firestore ikke tilgjengelig")
user = request.session.get("user", {})
created_by = user.get("email", "internal")
store = InviteStore(db)
token = store.create(company=req.company, email=req.email, tier=req.tier, created_by=created_by)
invite_url = f"{BASE_URL}/onboard/invite/{token}"
# Send invite-e-post til prospekt automatisk
email_result = await _notifier.send_email(EmailNotifyRequest(
to=req.email,
event="custom",
subject=f"🚀 Du er invitert til CostGuard — {req.company}",
body_html=f"""
<div style='font-family:sans-serif;max-width:600px;margin:0 auto'>
<div style='background:#1a1a2e;padding:24px;border-radius:8px 8px 0 0'>
<h2 style='color:#fff;margin:0'>🛡 CostGuard</h2>
<p style='color:#aaa;margin:4px 0 0'>av Vauco AS</p>
</div>
<div style='background:#f9f9f9;padding:24px;border-radius:0 0 8px 8px'>
<p>Hei!</p>
<p>Du er invitert til å aktivere <strong>CostGuard</strong> for <strong>{req.company}</strong>.</p>
<p>Klikk knappen nedenfor for å fullføre oppsett 2 minutter:</p>
<p style='text-align:center;margin:28px 0'>
<a href='{invite_url}' style='background:#7c5cff;color:#fff;padding:14px 32px;border-radius:10px;text-decoration:none;font-weight:700;font-size:15px'>Aktiver CostGuard &rarr;</a>
</p>
<p style='color:#999;font-size:12px'>Lenken er gyldig i 72 timer. Trenger du hjelp? Svar denne e-posten.</p>
<hr style='border:none;border-top:1px solid #ddd;margin:16px 0'>
<p style='color:#bbb;font-size:11px'>CostGuard by Vauco AS · opax.vauco.no</p>
</div>
</div>
""",
))
return JSONResponse({
"status": "ok",
"invite_url": invite_url,
"token": token,
"expires_in": "72 timer",
"email_sent": email_result.get("status") == "ok",
"company": req.company,
"tier": req.tier,
})
app.add_api_route("/onboard/invite", endpoint=require_auth(_onboard_invite_create), methods=["POST"])
@app.post("/onboard/complete")
async def onboard_complete(req: OnboardCompleteRequest):
# Valider og merk invite-token som brukt
if req.invite_token and db:
store = InviteStore(db)
invite = store.get(req.invite_token)
if not invite:
raise HTTPException(status_code=400, detail="Invitasjonstoken ugyldig eller utløpt.")
store.mark_used(req.invite_token)
if db:
try:
db.collection("onboarded_customers").document(req.gcp_project).set({
@ -713,39 +692,35 @@ async def onboard_complete(req: OnboardCompleteRequest):
return {"status": "ok", "customer": req.gcp_project, "company": req.company, "channels": req.channels, "notify": notify_results}
# ── ROOT LANDING PAGE ───────────────────────────────────────────────────
# ── ROOT ──────────────────────────────────────────────────────────────────────────
@app.get("/")
def root():
return FileResponse("static/opax.html")
return FileResponse(str(pathlib.Path(__file__).parent / "static" / "opax.html"))
# ── HEALTH ────────────────────────────────────────────────────────────────────
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/manifest.json", include_in_schema=False)
def manifest():
return FileResponse("static/manifest.json")
return FileResponse(str(pathlib.Path(__file__).parent / "static" / "manifest.json"))
@app.get("/sw.js", include_in_schema=False)
def service_worker():
return FileResponse("static/sw.js")
return FileResponse(str(pathlib.Path(__file__).parent / "static" / "sw.js"))
# ── ADMIN ─────────────────────────────────────────────────────────────────────
# ── ADMIN ────────────────────────────────────────────────────────────────────────
@app.get('/admin')
@require_auth
async def admin_panel(request: Request):
return FileResponse("static/admin.html")
return FileResponse(str(pathlib.Path(__file__).parent / "static" / "admin.html"))
@app.post('/admin/create-customer')
@require_auth
async def create_customer(request: Request):
import subprocess
data = await request.json()
import subprocess, re, shutil
data = await request.json()
customer_name = data.get("customer_name", "").strip()
project_id = data.get("project_id", "").strip()
billing_account_id = data.get("billing_account_id", "").strip()
@ -754,18 +729,15 @@ async def create_customer(request: Request):
region = data.get("region", "europe-north1").strip()
if not all([customer_name, project_id, billing_account_id, alert_email, container_image]):
raise HTTPException(status_code=400, detail="Alle felt er påkrevd")
import re
if not re.match(r'^[a-z0-9_-]+$', customer_name):
raise HTTPException(status_code=400, detail="customer_name kan kun inneholde a-z, 0-9, - og _")
customer_dir = f"infrastructure/terraform/customers/{customer_name}"
template_dir = "infrastructure/terraform/customers/_template"
import shutil
if os.path.exists(customer_dir):
raise HTTPException(status_code=409, detail=f"Kunde {customer_name} eksisterer allerede")
shutil.copytree(template_dir, customer_dir)
tfvars_content = f'''customer_id = "{customer_name}"\nproject_id = "{project_id}"\nregion = "{region}"\nbilling_account_id = "{billing_account_id}"\nalert_email = "{alert_email}"\nbilling_viewer_emails = ["chris.christiansen@vauco.no", "jason.vauger@vauco.no"]\ncontainer_image = "{container_image}"\n'''
with open(f"{customer_dir}/terraform.tfvars", "w") as f:
f.write(tfvars_content)
f.write(f'customer_id = "{customer_name}"\nproject_id = "{project_id}"\nregion = "{region}"\nbilling_account_id = "{billing_account_id}"\nalert_email = "{alert_email}"\nbilling_viewer_emails = ["chris.christiansen@vauco.no", "jason.vauger@vauco.no"]\ncontainer_image = "{container_image}"\n')
try:
init = subprocess.run(["terraform", f"-chdir={customer_dir}", "init", "-no-color"], capture_output=True, text=True, timeout=120)
if init.returncode != 0:
@ -776,10 +748,10 @@ async def create_customer(request: Request):
raise HTTPException(status_code=500, detail=f"terraform apply feilet: {apply.stderr[-500:]}")
return {"status": "ok", "customer": customer_name, "project_id": project_id}
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="Terraform tok for lang tid (>10 min)")
raise HTTPException(status_code=504, detail="Terraform tok for lang tid")
# ── BILLING ENDPOINTS (CG1 + CG2) ────────────────────────────────────────────
# ── BILLING ENDPOINTS ────────────────────────────────────────────────────────────
async def get_budget(request: Request):
if not db:
return JSONResponse(status_code=500, content={"error": "Firestore is not configured"})
@ -801,31 +773,25 @@ async def set_budget(request: Request, payload: BudgetUpdateRequest):
app.add_api_route("/billing/budget", endpoint=require_auth(get_budget), methods=["GET"])
app.add_api_route("/billing/budget", endpoint=require_auth(set_budget), methods=["POST"])
@app.post("/billing/email-report")
async def trigger_email_report():
result = await _notifier.send_email(EmailNotifyRequest(event="digest"))
if result["status"] == "not_configured":
return JSONResponse(status_code=200, content={"status": "not_configured", "error": result["detail"]})
return JSONResponse(status_code=200 if result["status"] == "ok" else 500, content=result)
return JSONResponse(status_code=200 if result["status"]=="ok" else 500, content=result)
@app.post("/billing/snapshot")
async def create_daily_snapshot(request: Request):
await trigger_email_report()
return JSONResponse(content={"status": "ok", "snapshot_id": str(datetime.date.today())})
async def get_history(request: Request):
if not db:
return JSONResponse(status_code=500, content={"error": "Firestore is not configured"})
try:
end_date = datetime.date.today()
start_date = end_date - datetime.timedelta(days=90)
docs = db.collection("daily_snapshots") \
.where("created_at", ">=", start_date.isoformat()) \
.order_by("created_at", direction=firestore.Query.DESCENDING) \
.limit(90).stream()
docs = db.collection("daily_snapshots").where("created_at", ">=", start_date.isoformat()).order_by("created_at", direction=firestore.Query.DESCENDING).limit(90).stream()
history = [{"date": doc.id, **doc.to_dict()} for doc in docs]
history.reverse()
return JSONResponse(content={"history": history})
@ -833,7 +799,6 @@ async def get_history(request: Request):
return JSONResponse(status_code=500, content={"error": str(e)})
app.add_api_route("/billing/history", endpoint=require_auth(get_history), methods=["GET"])
async def authenticated_billing_summary(request: Request):
try:
return BillingAgent().get_summary()
@ -841,7 +806,6 @@ async def authenticated_billing_summary(request: Request):
return JSONResponse(status_code=500, content={"error": str(exc)})
app.add_api_route("/billing/summary", endpoint=require_auth(authenticated_billing_summary), methods=["GET"])
async def authenticated_billing_forecast(request: Request):
try:
return BillingAgent().get_forecast()
@ -849,7 +813,6 @@ async def authenticated_billing_forecast(request: Request):
return JSONResponse(status_code=500, content={"error": str(exc)})
app.add_api_route("/billing/forecast", endpoint=require_auth(authenticated_billing_forecast), methods=["GET"])
async def authenticated_billing_anomalies(request: Request):
try:
return AnomalyDetector().detect_anomalies()
@ -857,7 +820,6 @@ async def authenticated_billing_anomalies(request: Request):
return JSONResponse(status_code=500, content={"error": str(exc)})
app.add_api_route("/billing/anomalies", endpoint=require_auth(authenticated_billing_anomalies), methods=["GET"])
async def authenticated_billing_credits(request: Request, days: int = 90):
try:
return JSONResponse(BillingAgent().get_credits_status(days=days))
@ -865,7 +827,6 @@ async def authenticated_billing_credits(request: Request, days: int = 90):
return JSONResponse(status_code=500, content={"error": str(exc)})
app.add_api_route("/billing/credits", endpoint=require_auth(authenticated_billing_credits), methods=["GET"])
@app.get("/billing-dashboard")
def billing_dashboard_view(request: Request):
return RedirectResponse(url='/static/billing-dashboard.html')
@ -886,23 +847,18 @@ async def budget_webhook(payload: BudgetWebhookPayload):
return {"status": "error", "detail": "Firestore not configured"}
try:
data = base64.b64decode(payload.message.get("data", "")).decode("utf-8")
data_json = json.loads(data)
data_json = json.loads(data)
cost_amount = data_json.get("costAmount", 0)
budget_amount = data_json.get("budgetAmount", 0)
if budget_amount > 0 and (cost_amount / budget_amount) > 0.8:
percent_used = round((cost_amount / budget_amount) * 100)
tokens = [s.id for s in db.collection("push_subscribers").stream()]
if tokens:
notification = messaging.Notification(
title="⚠️ CostGuard Varsel",
body=f"Du har brukt {percent_used}% av budsjett (kr {int(cost_amount)} av kr {int(budget_amount)})"
)
messaging.send_multicast(messaging.MulticastMessage(tokens=tokens, notification=notification))
messaging.send_multicast(messaging.MulticastMessage(tokens=tokens, notification=messaging.Notification(title="⚠️ CostGuard Varsel", body=f"Du har brukt {percent_used}% av budsjett (kr {int(cost_amount)} av kr {int(budget_amount)})")))
return {"status": "processed"}
except Exception as e:
return {"status": "error", "detail": str(e)}
@app.get("/billing/live")
@cached(TTLCache(maxsize=1, ttl=3600))
async def billing_live(request: Request):
@ -971,17 +927,17 @@ def run_dag(req: DagRequest):
raise HTTPException(status_code=400, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e))
def make_agent_fn(msg: str, idx: int):
def _agent_fn(payload: dict):
def make_agent_fn(msg, idx):
def _agent_fn(payload):
return run(message=msg, user_id=payload["user_id"], session_id=f"{payload['session_id']}-dag-{idx}", mode=payload["mode"])
_agent_fn.__name__ = f"opax-dag-{idx}"
return _agent_fn
payload = {"user_id": req.user_id, "session_id": req.session_id, "mode": req.mode}
agent_fns = [make_agent_fn(msg, i) for i, msg in enumerate(req.messages)]
agent_ids = [f"opax-dag-{i}" for i in range(len(req.messages))]
start = time.monotonic()
tasks = build_agent_dag(agent_fns, payload, agent_ids)
results = execute_dag(tasks, scheduler=req.scheduler)
start = time.monotonic()
tasks = build_agent_dag(agent_fns, payload, agent_ids)
results = execute_dag(tasks, scheduler=req.scheduler)
total_dur = round(time.monotonic() - start, 3)
store = get_store()
log_dag_execution(dag_id=req.session_id, agent_results=results, total_duration_s=total_dur)