Skip to content

Commit fa719fa

Browse files
committed
feat(web): add branding controls and polish console UX
1 parent 8c3d29b commit fa719fa

50 files changed

Lines changed: 1742 additions & 287 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/standards/CCCC_DAEMON_IPC_V1.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,41 @@ Result:
371371
{ observability: Record<string, unknown> }
372372
```
373373

374+
#### `branding_get`
375+
376+
Args: none
377+
378+
Result:
379+
```ts
380+
{
381+
branding: {
382+
product_name: string
383+
logo_icon_asset_path?: string
384+
favicon_asset_path?: string
385+
updated_at?: string
386+
}
387+
}
388+
```
389+
390+
#### `branding_update`
391+
392+
Args:
393+
```ts
394+
{ by?: "user"; patch: Record<string, unknown> }
395+
```
396+
397+
Result:
398+
```ts
399+
{
400+
branding: {
401+
product_name: string
402+
logo_icon_asset_path?: string
403+
favicon_asset_path?: string
404+
updated_at?: string
405+
}
406+
}
407+
```
408+
374409
#### `debug_snapshot`
375410

376411
Developer-mode diagnostic snapshot (global + optional group context).

src/cccc/daemon/ops/daemon_core_ops.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ def try_handle_daemon_core_op(
2121
get_observability: Callable[[], Dict[str, Any]],
2222
update_observability_settings: Callable[[Dict[str, Any]], Dict[str, Any]],
2323
apply_observability_settings: Callable[[Dict[str, Any]], None],
24+
get_web_branding: Callable[[], Dict[str, Any]],
25+
update_web_branding_settings: Callable[[Dict[str, Any]], Dict[str, Any]],
2426
) -> Optional[Tuple[DaemonResponse, bool]]:
2527
if op == "ping":
2628
return (
@@ -60,4 +62,20 @@ def try_handle_daemon_core_op(
6062
except Exception as e:
6163
return _error("observability_update_failed", str(e)), False
6264

65+
if op == "branding_get":
66+
return DaemonResponse(ok=True, result={"branding": get_web_branding()}), False
67+
68+
if op == "branding_update":
69+
by = str(args.get("by") or "user").strip()
70+
if by and by != "user":
71+
return _error("permission_denied", "only user can update global branding settings"), False
72+
patch = args.get("patch") if isinstance(args.get("patch"), dict) else {}
73+
if not patch:
74+
return DaemonResponse(ok=True, result={"branding": get_web_branding()}), False
75+
try:
76+
updated = update_web_branding_settings(dict(patch))
77+
return DaemonResponse(ok=True, result={"branding": updated}), False
78+
except Exception as e:
79+
return _error("branding_update_failed", str(e)), False
80+
6381
return None

src/cccc/daemon/request_dispatch_ops.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ class RequestDispatchDeps:
4646
get_observability: Callable[[], dict[str, Any]]
4747
update_observability_settings: Callable[..., bool]
4848
apply_observability_settings: Callable[[dict[str, Any]], bool]
49+
get_web_branding: Callable[[], dict[str, Any]]
50+
update_web_branding_settings: Callable[..., bool]
4951
developer_mode_enabled: Callable[[], bool]
5052
effective_runner_kind: Callable[[str], str]
5153
throttle_debug_summary: Callable[[], dict[str, Any]]
@@ -114,6 +116,8 @@ def dispatch_request(
114116
get_observability=deps.get_observability,
115117
update_observability_settings=deps.update_observability_settings,
116118
apply_observability_settings=deps.apply_observability_settings,
119+
get_web_branding=deps.get_web_branding,
120+
update_web_branding_settings=deps.update_web_branding_settings,
117121
)
118122
if daemon_core_resp is not None:
119123
return daemon_core_resp

src/cccc/daemon/server.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919
from ..kernel.actors import find_actor, find_foreman, update_actor, get_effective_role
2020
from ..kernel.blobs import resolve_blob_attachment_path
2121
from ..kernel.ledger_retention import compact as compact_ledger
22-
from ..kernel.settings import get_observability_settings, update_observability_settings
22+
from ..kernel.settings import (
23+
get_observability_settings,
24+
get_web_branding_settings,
25+
update_observability_settings,
26+
update_web_branding_settings,
27+
)
2328
from ..kernel.terminal_transcript import get_terminal_transcript_settings
2429
from ..kernel.messaging import disabled_recipient_actor_ids
2530
from ..paths import ensure_home
@@ -654,6 +659,8 @@ def _request_dispatch_deps() -> RequestDispatchDeps:
654659
get_observability=_get_observability,
655660
update_observability_settings=update_observability_settings,
656661
apply_observability_settings=lambda obs: _apply_observability_settings(ensure_home(), obs),
662+
get_web_branding=get_web_branding_settings,
663+
update_web_branding_settings=update_web_branding_settings,
657664
developer_mode_enabled=_developer_mode_enabled,
658665
effective_runner_kind=_effective_runner_kind,
659666
throttle_debug_summary=THROTTLE.debug_summary,

src/cccc/kernel/settings.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ def _copy_runtime_pool(pool: List[RuntimePoolEntry]) -> List[RuntimePoolEntry]:
158158
}
159159

160160

161+
# ---------------------------------------------------------------------------
162+
# Web branding (global)
163+
# ---------------------------------------------------------------------------
164+
165+
DEFAULT_WEB_BRANDING: Dict[str, Any] = {
166+
"product_name": "CCCC",
167+
"logo_icon_asset_path": "",
168+
"favicon_asset_path": "",
169+
"updated_at": "",
170+
}
171+
172+
161173
def _as_bool(v: Any, default: bool) -> bool:
162174
if isinstance(v, bool):
163175
return v
@@ -252,6 +264,20 @@ def _merge_remote_access(raw: Any) -> Dict[str, Any]:
252264
return base
253265

254266

267+
def _merge_web_branding(raw: Any) -> Dict[str, Any]:
268+
"""Merge/validate web branding settings with defaults."""
269+
base = dict(DEFAULT_WEB_BRANDING)
270+
if not isinstance(raw, dict):
271+
return base
272+
273+
product_name = str(raw.get("product_name") or "").strip()
274+
base["product_name"] = product_name or str(DEFAULT_WEB_BRANDING["product_name"])
275+
base["logo_icon_asset_path"] = str(raw.get("logo_icon_asset_path") or "").strip()
276+
base["favicon_asset_path"] = str(raw.get("favicon_asset_path") or "").strip()
277+
base["updated_at"] = _as_str(raw.get("updated_at"), str(base["updated_at"]))
278+
return base
279+
280+
255281
def get_observability_settings() -> Dict[str, Any]:
256282
"""Get merged observability settings (global)."""
257283
settings = load_settings()
@@ -311,6 +337,12 @@ def get_remote_access_settings() -> Dict[str, Any]:
311337
return _merge_remote_access(settings.get("remote_access"))
312338

313339

340+
def get_web_branding_settings() -> Dict[str, Any]:
341+
"""Get merged web branding settings (global)."""
342+
settings = load_settings()
343+
return _merge_web_branding(settings.get("web_branding"))
344+
345+
314346
def resolve_remote_access_web_binding() -> Dict[str, Any]:
315347
"""Resolve effective Web binding with explicit settings/env/default precedence."""
316348
settings = load_settings()
@@ -420,6 +452,40 @@ def update_remote_access_settings(patch: Dict[str, Any]) -> Dict[str, Any]:
420452
return _merge_remote_access(raw)
421453

422454

455+
def update_web_branding_settings(patch: Dict[str, Any]) -> Dict[str, Any]:
456+
"""Update web branding settings in ~/.cccc/settings.yaml and return merged result."""
457+
settings = load_settings()
458+
current = _merge_web_branding(settings.get("web_branding"))
459+
if not isinstance(patch, dict) or not patch:
460+
return current
461+
462+
raw = dict(settings.get("web_branding") if isinstance(settings.get("web_branding"), dict) else {})
463+
changed = False
464+
465+
if "product_name" in patch:
466+
product_name = str(patch.get("product_name") or "").strip()
467+
raw["product_name"] = product_name or str(DEFAULT_WEB_BRANDING["product_name"])
468+
changed = True
469+
470+
if "logo_icon_asset_path" in patch:
471+
raw["logo_icon_asset_path"] = str(patch.get("logo_icon_asset_path") or "").strip()
472+
changed = True
473+
474+
if "favicon_asset_path" in patch:
475+
raw["favicon_asset_path"] = str(patch.get("favicon_asset_path") or "").strip()
476+
changed = True
477+
478+
if "updated_at" in patch:
479+
raw["updated_at"] = _as_str(patch.get("updated_at"), str(raw.get("updated_at") or ""))
480+
changed = True
481+
elif changed:
482+
raw["updated_at"] = utc_now_iso()
483+
484+
settings["web_branding"] = raw
485+
save_settings(settings)
486+
return _merge_web_branding(raw)
487+
488+
423489
def _settings_path() -> Path:
424490
return ensure_home() / "settings.yaml"
425491

src/cccc/ports/web/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def _web_mode() -> Literal["normal", "exhibit"]:
9696
return "normal"
9797

9898

99-
_PUBLIC_API_PATHS = frozenset({"/api/v1/health"})
99+
_PUBLIC_API_PATHS = frozenset({"/api/v1/health", "/api/v1/branding"})
100100

101101

102102
def _is_public_ui_path(request: Request) -> bool:
@@ -107,7 +107,7 @@ def _is_public_ui_path(request: Request) -> bool:
107107
def _is_public_path(request: Request) -> bool:
108108
"""Routes that bypass token authentication (UI assets + health check)."""
109109
path = str(request.url.path or "")
110-
return _is_public_ui_path(request) or path in _PUBLIC_API_PATHS
110+
return _is_public_ui_path(request) or path in _PUBLIC_API_PATHS or path.startswith("/api/v1/branding/assets/")
111111

112112

113113
def _request_token_parts(request: Request) -> tuple[str, Literal["", "header", "cookie", "query"]]:

src/cccc/ports/web/branding.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import mimetypes
5+
from pathlib import Path
6+
from typing import Any, Dict, Literal
7+
8+
from ...paths import ensure_home
9+
from ...util.fs import atomic_write_bytes
10+
11+
BrandingAssetKind = Literal["logo_icon", "favicon"]
12+
13+
_BRANDING_MAX_BYTES = 2 * 1024 * 1024
14+
_DEFAULT_PRODUCT_NAME = "CCCC"
15+
_DEFAULT_LOGO_ICON_URL = "/ui/logo.svg"
16+
_DEFAULT_FAVICON_URL = "/ui/logo.svg"
17+
18+
_ALLOWED_MIME_TYPES: dict[str, set[str]] = {
19+
"logo_icon": {
20+
"image/svg+xml",
21+
"image/png",
22+
"image/jpeg",
23+
"image/webp",
24+
"image/gif",
25+
"image/avif",
26+
"image/x-icon",
27+
"image/vnd.microsoft.icon",
28+
},
29+
"favicon": {
30+
"image/svg+xml",
31+
"image/png",
32+
"image/x-icon",
33+
"image/vnd.microsoft.icon",
34+
},
35+
}
36+
37+
_EXTENSION_BY_MIME: dict[str, str] = {
38+
"image/svg+xml": ".svg",
39+
"image/png": ".png",
40+
"image/jpeg": ".jpg",
41+
"image/webp": ".webp",
42+
"image/gif": ".gif",
43+
"image/avif": ".avif",
44+
"image/x-icon": ".ico",
45+
"image/vnd.microsoft.icon": ".ico",
46+
}
47+
48+
49+
def normalize_branding_asset_kind(value: str) -> BrandingAssetKind:
50+
normalized = str(value or "").strip().lower()
51+
if normalized in {"logo_icon", "favicon"}:
52+
return normalized # type: ignore[return-value]
53+
raise ValueError("asset kind must be one of: logo_icon, favicon")
54+
55+
56+
def branding_asset_dir() -> Path:
57+
return ensure_home() / "state" / "web_branding"
58+
59+
60+
def _branding_asset_rel_path(filename: str) -> str:
61+
return str(Path("state") / "web_branding" / filename).replace("\\", "/")
62+
63+
64+
def resolve_branding_asset_path(rel_path: str) -> Path:
65+
normalized = str(rel_path or "").strip().replace("\\", "/")
66+
if not normalized:
67+
raise FileNotFoundError("branding asset path is empty")
68+
base = ensure_home().resolve()
69+
target = (base / Path(*Path(normalized).parts)).resolve()
70+
try:
71+
target.relative_to(base)
72+
except ValueError as exc:
73+
raise FileNotFoundError("branding asset path is outside CCCC_HOME") from exc
74+
return target
75+
76+
77+
def store_branding_asset(*, asset_kind: BrandingAssetKind, data: bytes, content_type: str, filename: str = "") -> Dict[str, Any]:
78+
mime_type = str(content_type or "").strip().lower()
79+
if not mime_type:
80+
guessed, _ = mimetypes.guess_type(str(filename or "").strip())
81+
mime_type = str(guessed or "").strip().lower()
82+
if mime_type not in _ALLOWED_MIME_TYPES[asset_kind]:
83+
raise ValueError(f"unsupported {asset_kind} type: {mime_type or 'unknown'}")
84+
if len(data) > _BRANDING_MAX_BYTES:
85+
raise ValueError(f"{asset_kind} file too large")
86+
87+
digest = hashlib.sha256(data).hexdigest()
88+
ext = _EXTENSION_BY_MIME.get(mime_type) or Path(str(filename or "").strip()).suffix.lower() or ".bin"
89+
stored_name = f"{asset_kind}_{digest[:16]}{ext}"
90+
abs_path = branding_asset_dir() / stored_name
91+
atomic_write_bytes(abs_path, data)
92+
return {
93+
"asset_kind": asset_kind,
94+
"mime_type": mime_type,
95+
"bytes": len(data),
96+
"sha256": digest,
97+
"rel_path": _branding_asset_rel_path(stored_name),
98+
"public_url": f"/api/v1/branding/assets/{asset_kind}?v={digest[:16]}",
99+
}
100+
101+
102+
def delete_branding_asset(rel_path: str) -> None:
103+
try:
104+
target = resolve_branding_asset_path(rel_path)
105+
if target.exists():
106+
target.unlink()
107+
except Exception:
108+
pass
109+
110+
111+
def build_branding_payload(raw: Dict[str, Any]) -> Dict[str, Any]:
112+
product_name = str(raw.get("product_name") or "").strip() or _DEFAULT_PRODUCT_NAME
113+
logo_rel_path = str(raw.get("logo_icon_asset_path") or "").strip()
114+
favicon_rel_path = str(raw.get("favicon_asset_path") or "").strip()
115+
updated_at = str(raw.get("updated_at") or "").strip() or None
116+
117+
logo_url = (
118+
f"/api/v1/branding/assets/logo_icon?v={updated_at or 'default'}"
119+
if logo_rel_path
120+
else _DEFAULT_LOGO_ICON_URL
121+
)
122+
favicon_url = (
123+
f"/api/v1/branding/assets/favicon?v={updated_at or 'default'}"
124+
if favicon_rel_path
125+
else (logo_url if logo_rel_path else _DEFAULT_FAVICON_URL)
126+
)
127+
return {
128+
"product_name": product_name,
129+
"logo_icon_url": logo_url,
130+
"favicon_url": favicon_url,
131+
"has_custom_logo_icon": bool(logo_rel_path),
132+
"has_custom_favicon": bool(favicon_rel_path),
133+
"updated_at": updated_at,
134+
}

0 commit comments

Comments
 (0)