From 4f3a802883de783e9fb086b1638081d85337d104 Mon Sep 17 00:00:00 2001 From: chrischristiansen-glitch Date: Mon, 15 Jun 2026 16:40:14 +0200 Subject: [PATCH] feat(CG5-onboard): token-basert invite-system + offentlig onboard-flow --- agents/invite_store.py | 44 +++++ main.py | 436 ++++++++++++++++++----------------------- 2 files changed, 240 insertions(+), 240 deletions(-) create mode 100644 agents/invite_store.py diff --git a/agents/invite_store.py b/agents/invite_store.py new file mode 100644 index 0000000..e15405a --- /dev/null +++ b/agents/invite_store.py @@ -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}) diff --git a/main.py b/main.py index 9f891c4..519cf09 100644 --- a/main.py +++ b/main.py @@ -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 (nå) | 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://: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) — nå, 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://: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 på 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"
  • {s['service']}: kr {s.get('total_cost', 0):.2f}
  • " 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"
  • {s['service']}: kr {s.get('total_cost',0):.2f}
  • " for s in summary[:3]) except Exception: - mtd, daily_avg, top3 = 0, 0, "
  • Data ikke tilgjengelig
  • " - extra_rows = "".join( - f"{k}{v}" - for k, v in (extra_payload or {}).items() - ) - return f""" -
    -
    -

    📊 CostGuard — Daglig oppsummering

    -

    {datetime.date.today()}

    -
    -
    - - - - {extra_rows} -
    Kostnad MTDkr {mtd:.2f}
    Daglig snitt (7d)kr {daily_avg:.2f}
    -

    Topp 3 GCP-tjenester

    -
      {top3}
    -
    -

    CostGuard by Vauco AS · opax.vauco.no

    -
    -
    - """ + mtd, daily, top3 = 0, 0, "
  • Data ikke tilgjengelig
  • " + extra = "".join(f"{k}{v}" for k, v in (extra_payload or {}).items()) + return f"

    📊 CostGuard — Daglig oppsummering

    {datetime.date.today()}

    {extra}
    Kostnad MTDkr {mtd:.2f}
    Daglig snitt (7d)kr {daily:.2f}

    Topp 3 GCP-tjenester

      {top3}

    CostGuard by Vauco AS · opax.vauco.no

    " _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 + inject = f""" + """ + html = html.replace("", inject + "\n", 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""" +
    +
    +

    🛡️ CostGuard

    +

    av Vauco AS

    +
    +
    +

    Hei!

    +

    Du er invitert til å aktivere CostGuard for {req.company}.

    +

    Klikk på knappen nedenfor for å fullføre oppsett på 2 minutter:

    +

    + Aktiver CostGuard → +

    +

    Lenken er gyldig i 72 timer. Trenger du hjelp? Svar på denne e-posten.

    +
    +

    CostGuard by Vauco AS · opax.vauco.no

    +
    +
    + """, + )) + + 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)