Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions app/api/routers/breeze_buddy/widget/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,11 +412,12 @@ async def create_widget_session_handler(
body: CreateWidgetSessionRequest, request: Request
) -> CreateWidgetSessionResponse:
"""Create a widget session backed by a chat_session row."""
cfg = await resolve_widget_config_for_request(
cfg, preview = await resolve_widget_config_for_request(
request=request,
public_widget_key=body.public_widget_key,
rate_bucket="session_create",
rate_limit=None, # use cfg.max_sessions_per_ip_hour default
preview_grant=body.preview_grant,
)

template = await get_template_by_id_cached(cfg.template_id)
Expand Down Expand Up @@ -473,6 +474,7 @@ async def create_widget_session_handler(
widget_session_id=create_resp.session_id,
widget_config_id=cfg.id,
ttl_minutes=DEFAULT_WIDGET_TOKEN_TTL_MINUTES,
preview=preview,
)

surface = _extract_widget_config(template)
Expand Down Expand Up @@ -517,7 +519,8 @@ async def send_widget_message_handler(
):
"""Drive one chat turn. 409 if voice is currently active."""
cfg = await resolve_session_widget_config(
request=request, widget_config_id=ctx.widget_config_id
widget_config_id=ctx.widget_config_id,
preview=ctx.preview,
)

await enforce_widget_ip_limit(
Expand Down Expand Up @@ -582,7 +585,8 @@ async def send_widget_intent_handler(
an intent is a turn, not a side channel.
"""
cfg = await resolve_session_widget_config(
request=request, widget_config_id=ctx.widget_config_id
widget_config_id=ctx.widget_config_id,
preview=ctx.preview,
)

await enforce_widget_ip_limit(
Expand Down Expand Up @@ -641,7 +645,8 @@ async def transcribe_widget_audio_handler(
drops it into the composer for the user to edit and send via ``/message``.
"""
cfg = await resolve_session_widget_config(
request=request, widget_config_id=ctx.widget_config_id
widget_config_id=ctx.widget_config_id,
preview=ctx.preview,
)

# Separate "transcribe" bucket (same per-merchant hourly cap as messages)
Expand Down Expand Up @@ -715,7 +720,8 @@ async def approve_widget_tool_handler(
from ``already_decided`` / ``lock_contended``.
"""
cfg = await resolve_session_widget_config(
request=request, widget_config_id=ctx.widget_config_id
widget_config_id=ctx.widget_config_id,
preview=ctx.preview,
)

await enforce_widget_ip_limit(
Expand Down Expand Up @@ -806,7 +812,8 @@ async def update_widget_context_handler(
client-context keys, so the two never clobber each other.
"""
cfg = await resolve_session_widget_config(
request=request, widget_config_id=ctx.widget_config_id
widget_config_id=ctx.widget_config_id,
preview=ctx.preview,
)

await enforce_widget_ip_limit(
Expand Down Expand Up @@ -910,8 +917,8 @@ async def voice_connect_handler(
+ end_conversation.py for the resume + drain mechanics.
"""
cfg = await resolve_session_widget_config(
request=request,
widget_config_id=ctx.widget_config_id,
preview=ctx.preview,
preview_paused=False,
)

Expand Down Expand Up @@ -1396,8 +1403,8 @@ async def try_on_widget_handler(
history, ui_blocks, session state, or a log line.
"""
cfg = await resolve_session_widget_config(
request=request,
widget_config_id=ctx.widget_config_id,
preview=ctx.preview,
preview_paused=False,
)

Expand Down
70 changes: 36 additions & 34 deletions app/api/routers/breeze_buddy/widget_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@

from __future__ import annotations

from typing import Optional
from typing import Optional, Tuple
from urllib.parse import urlparse

from fastapi import HTTPException, Request, Response, status

from app.core.config.static import WIDGET_CONSOLE_ORIGINS
from app.api.security.breeze_buddy.widget_token import is_preview_grant_for
from app.core.logger import logger
from app.database.accessor.breeze_buddy.widget_config import (
get_widget_config_by_id,
Expand Down Expand Up @@ -139,27 +139,16 @@ async def _enforce_widget_ip_limit(
)


def is_console_origin(request: Request) -> bool:
"""Is this our own console asking, rather than a merchant's storefront?

Our console is the one place an operator looks at their own agent before
anyone else can, so it is exempt from the two rules written for anonymous
visitors: the site allow-list, and the active flag. See
``WIDGET_CONSOLE_ORIGINS`` for what that exemption is and is not worth.
"""
origin = _caller_origin(request)
return bool(origin) and origin.rstrip("/") in WIDGET_CONSOLE_ORIGINS


async def resolve_session_widget_config(
*, request: Request, widget_config_id: str, preview_paused: bool = True
*, widget_config_id: str, preview: bool, preview_paused: bool = True
) -> WidgetConfigResponse:
"""The widget a session token names; 401 if it is gone or switched off.
Our console may still chat with a switched-off widget (preview), unless
``preview_paused=False`` (voice and try-on)."""

A console preview session (``preview`` — the server-signed claim on its
widget token, never a request header) may still chat with a switched-off
widget, unless ``preview_paused=False`` (voice and try-on)."""
cfg = await get_widget_config_by_id(widget_config_id)
preview = preview_paused and is_console_origin(request)
if cfg is None or (not cfg.active and not preview):
if cfg is None or (not cfg.active and not (preview and preview_paused)):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Widget configuration not found or inactive",
Expand All @@ -168,23 +157,27 @@ async def resolve_session_widget_config(
return cfg


def enforce_widget_origin(*, request: Request, cfg: WidgetConfigResponse) -> None:
def enforce_widget_origin(
*, request: Request, cfg: WidgetConfigResponse, preview: bool = False
) -> None:
"""403 unless the caller's Origin (or Referer-derived origin) is in the
row's ``allowed_origins``. Empty list = deny-all. Shared by the
key-resolved session path and the shop-resolved storefront-config path
so the two doors can never drift on origin policy.

Our own console is always permitted: a merchant should not have to add us
to their production allow-list to look at their own agent.
A console preview (``preview``, proven by a preview grant minted behind
the console login) is exempt: a merchant should not have to add us to
their production allow-list to look at their own agent. The Origin header
alone never grants that — any non-browser client can set it (N-WID-3).
"""
if preview:
return
origin = _caller_origin(request)
if origin is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Origin required for widget routes",
)
if origin.rstrip("/") in WIDGET_CONSOLE_ORIGINS:
return
if not cfg.allowed_origins or origin not in cfg.allowed_origins:
logger.warning(
f"widget: origin {origin!r} not in allowed_origins for "
Expand All @@ -202,7 +195,8 @@ async def resolve_widget_config_for_request(
public_widget_key: str,
rate_bucket: str,
rate_limit: Optional[int] = None,
) -> WidgetConfigResponse:
preview_grant: Optional[str] = None,
) -> Tuple[WidgetConfigResponse, bool]:
"""Load the widget_config + enforce origin + per-IP rate limit.

Args:
Expand All @@ -215,22 +209,30 @@ async def resolve_widget_config_for_request(
``None``, the route is expected to pick a value off the
loaded ``WidgetConfigResponse`` itself — used when the row
hadn't been loaded yet.
preview_grant: A console preview grant (``mint_preview_grant``),
if the caller sent one.

Returns:
The config, and whether this is a console preview (a live grant
for this widget).

Behaviour:
- Returns 404 (no echo) when the key is unknown or the row is
inactive — same reconnaissance-hardening as the demo router.
inactive and no preview grant covers it — same
reconnaissance-hardening as the demo router.
- Returns 403 when the caller's origin (or Referer-derived
origin) isn't in ``allowed_origins`` — same status when
``allowed_origins`` is empty (deny-all by default).
``allowed_origins`` is empty (deny-all by default). A preview
is exempt.
- Returns 429 with ``Retry-After`` when the per-IP bucket for
``rate_bucket`` is over ``rate_limit``.
"""
cfg = await get_widget_config_by_public_key(public_widget_key)
# A paused widget still previews for us. Pausing is how a merchant stops
# serving shoppers, and the moment they most want to look at their agent
# is before they have ever unpaused it.
preview = cfg is not None and not cfg.active and is_console_origin(request)
if cfg is not None and preview:
# A paused widget still previews for the console. Pausing is how a
# merchant stops serving shoppers, and the moment they most want to look
# at their agent is before they have ever unpaused it.
preview = cfg is not None and is_preview_grant_for(preview_grant, cfg.id)
if cfg is not None and preview and not cfg.active:
logger.info(
"widget: console preview session for an inactive widget_config "
f"(id={cfg.id})"
Expand All @@ -248,7 +250,7 @@ async def resolve_widget_config_for_request(
detail="Widget configuration not found",
)

enforce_widget_origin(request=request, cfg=cfg)
enforce_widget_origin(request=request, cfg=cfg, preview=preview)

effective_limit = (
rate_limit if rate_limit is not None else cfg.max_sessions_per_ip_hour
Expand All @@ -259,7 +261,7 @@ async def resolve_widget_config_for_request(
limit=effective_limit,
widget_config_id=cfg.id,
)
return cfg
return cfg, preview


async def enforce_widget_ip_limit(
Expand Down
17 changes: 17 additions & 0 deletions app/api/routers/breeze_buddy/widget_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- ``GET /widget-config/{id}`` — get by id
- ``PUT /widget-config/{id}`` — partial update
- ``DELETE /widget-config/{id}`` — delete (admin only)
- ``POST /widget-config/{id}/preview`` — console preview grant

Mirrors the templates router (see ``templates/__init__.py``) — thin
routes that validate auth + RBAC, then delegate to handlers.
Expand All @@ -27,13 +28,15 @@
WidgetConfigListResponse,
WidgetConfigResponse,
WidgetConfigUpdate,
WidgetPreviewGrantResponse,
)

from .handlers import (
create_widget_config_handler,
delete_widget_config_handler,
get_widget_config_by_id_handler,
list_widget_configs_handler,
mint_preview_grant_handler,
update_widget_config_handler,
)

Expand Down Expand Up @@ -95,6 +98,20 @@ async def get_widget_config(
return await get_widget_config_by_id_handler(widget_config_id, current_user)


@router.post(
"/widget-config/{widget_config_id}/preview",
response_model=WidgetPreviewGrantResponse,
)
async def mint_preview_grant(
widget_config_id: str,
current_user: UserInfo = Depends(get_current_user_with_rbac),
) -> WidgetPreviewGrantResponse:
"""Short-lived grant the console hands its preview frame, so the widget
can open a session on a paused widget. RBAC: same access as reading the
widget_config (403 otherwise)."""
return await mint_preview_grant_handler(widget_config_id, current_user)


@router.put("/widget-config/{widget_config_id}", response_model=WidgetConfigResponse)
async def update_widget_config(
widget_config_id: str,
Expand Down
16 changes: 16 additions & 0 deletions app/api/routers/breeze_buddy/widget_config/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
from fastapi import HTTPException, status

from app.api.routers.breeze_buddy.templates.rbac import validate_template_access
from app.api.security.breeze_buddy.widget_token import (
PREVIEW_GRANT_TTL_MINUTES,
mint_preview_grant,
)
from app.core.logger import logger
from app.database.accessor import get_template_by_id
from app.database.accessor.breeze_buddy.widget_config import (
Expand All @@ -34,6 +38,7 @@
WidgetConfigListResponse,
WidgetConfigResponse,
WidgetConfigUpdate,
WidgetPreviewGrantResponse,
)

# 32 url-safe bytes → ~43 chars; well inside the column's varchar(128)
Expand Down Expand Up @@ -197,6 +202,17 @@ async def get_widget_config_by_id_handler(
return cfg


async def mint_preview_grant_handler(
widget_config_id: str, current_user: UserInfo
) -> WidgetPreviewGrantResponse:
"""A console preview grant for a widget the caller may read (N-WID-3)."""
cfg = await get_widget_config_by_id_handler(widget_config_id, current_user)
return WidgetPreviewGrantResponse(
preview_grant=mint_preview_grant(widget_config_id=cfg.id),
ttl_seconds=PREVIEW_GRANT_TTL_MINUTES * 60,
)


async def update_widget_config_handler(
widget_config_id: str,
body: WidgetConfigUpdate,
Expand Down
Loading
Loading