"""routes/canvas_styles.py — Canvas Style System API. GET /api/canvas/styles — list styles (presets + customs) + active id GET /api/canvas/styles/ — full definition POST /api/canvas/styles — create custom (optional clone_from) PUT /api/canvas/styles/ — update custom (presets are read-only) POST /api/canvas/styles//archive — archive custom (nothing is ever deleted) POST /api/canvas/styles//unarchive POST /api/canvas/styles//activate — set tenant-active style, render agent files GET /api/canvas/styles//preview — the style's template/demo page as HTML POST /api/canvas/styles/from-website — spool a brand-review job (host worker) GET /api/canvas/styles/from-website/ — poll that job Store + renderer: services/canvas_styles.py Brand review pipeline: scripts/brand-style/ on the HOST (this container has no browser and no coding agent), reached through the durable tool-job spool. """ import logging import os import re from flask import Blueprint, Response, g, jsonify, request from services import canvas_styles as store from services import tool_jobs logger = logging.getLogger(__name__) canvas_styles_bp = Blueprint('canvas_styles', __name__) @canvas_styles_bp.after_request def _no_store(resp): # Canvas no-cache policy: style state must never be served stale — a cached # /api/canvas/styles response makes the picker show the OLD active style # after switching (looks like the choice didn't save). resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' resp.headers['Pragma'] = 'no-cache' resp.headers['Expires'] = '0' resp.headers['CDN-Cache-Control'] = 'no-store' resp.headers['Cloudflare-CDN-Cache-Control'] = 'no-store' return resp @canvas_styles_bp.get('/api/canvas/styles') def list_styles(): include_archived = request.args.get('archived') == '1' return jsonify({ 'styles': store.list_styles(include_archived=include_archived), 'active': store.get_active(), }) @canvas_styles_bp.get('/api/canvas/styles/') def get_style(style_id): style = store.get_style(style_id) if style is None: return jsonify({'error': 'style not found'}), 404 return jsonify(style) @canvas_styles_bp.post('/api/canvas/styles') def create_style(): data = request.get_json(silent=True) or {} clone_from = data.pop('clone_from', None) if clone_from: source = store.get_style(clone_from) if source is None: return jsonify({'error': f'clone_from style not found: {clone_from}'}), 404 merged = {k: v for k, v in source.items() if k not in ('preset', 'created_at', 'updated_at', 'archived')} # Deep-merge tokens so a clone can override just a few colors. tokens = dict(merged.get('tokens', {})) tokens.update(data.get('tokens', {}) or {}) merged.update({k: v for k, v in data.items() if k != 'tokens'}) merged['tokens'] = tokens merged['cloned_from'] = clone_from data = merged err = store._validate(data) if err: return jsonify({'error': err}), 400 if store.get_style(data['id']) is not None: return jsonify({'error': f"style id '{data['id']}' already exists"}), 409 store.save_custom(data) store.clear_base_css_cache() return jsonify({'ok': True, 'id': data['id']}), 201 @canvas_styles_bp.put('/api/canvas/styles/') def update_style(style_id): existing = store.load_customs().get(style_id) if existing is None: if store.load_presets().get(style_id) is not None: return jsonify({'error': 'presets are read-only — clone it first'}), 403 return jsonify({'error': 'style not found'}), 404 data = request.get_json(silent=True) or {} data['id'] = style_id # id is immutable merged = dict(existing) tokens = dict(existing.get('tokens', {})) tokens.update(data.get('tokens', {}) or {}) merged.update({k: v for k, v in data.items() if k != 'tokens'}) merged['tokens'] = tokens err = store._validate(merged) if err: return jsonify({'error': err}), 400 store.save_custom(merged) store.clear_base_css_cache() # Keep the agent-facing files fresh if this is the active style. if store.get_active_style_id() == style_id: store.activate(style_id) return jsonify({'ok': True, 'id': style_id}) def _set_archived(style_id, archived): existing = store.load_customs().get(style_id) if existing is None: if store.load_presets().get(style_id) is not None: return jsonify({'error': 'presets cannot be archived'}), 403 return jsonify({'error': 'style not found'}), 404 if archived and store.get_active_style_id() == style_id: return jsonify({'error': 'cannot archive the active style — activate another first'}), 409 existing['archived'] = archived store.save_custom(existing) return jsonify({'ok': True, 'id': style_id, 'archived': archived}) @canvas_styles_bp.post('/api/canvas/styles//archive') def archive_style(style_id): return _set_archived(style_id, True) @canvas_styles_bp.post('/api/canvas/styles//unarchive') def unarchive_style(style_id): return _set_archived(style_id, False) @canvas_styles_bp.post('/api/canvas/styles//activate') def activate_style(style_id): style = store.activate(style_id) if style is None: return jsonify({'error': 'style not found'}), 404 store.clear_base_css_cache() logger.info('[canvas-styles] activated %s', style_id) return jsonify({'ok': True, 'active': store.get_active(), 'agent_file': str(store.ACTIVE_STYLE_MD)}) @canvas_styles_bp.get('/api/canvas/lint/') def lint_page(page_id): """Deterministic page QA: emoji-as-icons, banned colors, CDN frameworks, missing viewport/page-icon, active-style mismatch. Agents run this after every page write and fix what it reports.""" from services.paths import CANVAS_PAGES_DIR safe = ''.join(c for c in page_id if c.isalnum() or c in '-_') path = CANVAS_PAGES_DIR / f'{safe}.html' if not path.exists(): return jsonify({'error': 'page not found'}), 404 try: issues = store.lint_page_html(path.read_text(encoding='utf-8', errors='replace')) except Exception as exc: logger.error('lint failed for %s: %s', page_id, exc) return jsonify({'error': 'lint failed'}), 500 return jsonify({'page_id': safe, 'ok': not issues, 'issues': issues}) _URL_SHAPE_RE = re.compile( r'^(?:https?://)?' r'(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}' r'(?::\d{2,5})?(?:[/?#]\S*)?$', re.I, ) @canvas_styles_bp.post('/api/canvas/styles/from-website') def style_from_website(): """Ask the host to review a website and build a matching style. This container cannot do the work: it has no browser to render the site and no coding agent to judge what it sees. So the request is spooled to the same durable job bus the voice tools use, and the host worker (scripts/tool-job-worker.py -> handle_brand_style) executes it. The shape check here is a courtesy so the user gets an instant, readable error for a typo. It is NOT the security boundary — the host worker does the real SSRF check (public_url_or_reason) against every resolved address, since that is the process that will actually open the URL. """ data = request.get_json(silent=True) or {} url = (data.get('url') or '').strip() if not url: return jsonify({'error': 'a website URL is required'}), 400 if len(url) > 500: return jsonify({'error': 'that URL is too long'}), 400 if not _URL_SHAPE_RE.match(url): return jsonify({'error': "that doesn't look like a website address — " 'try something like example.com'}), 400 # Provenance only — the worker derives the real tenant from the job file's # own path (/mnt/clients//...), which is authoritative and cannot be # wrong even on tenants that never set these env vars. tenant = (os.getenv('JAMBOT_TENANT') or os.getenv('TENANT_NAME') or os.getenv('CLIENT_NAME') or None) job = tool_jobs.create( 'brand_style', {'url': url}, tenant=tenant, user_id=getattr(g, 'clerk_user_id', None), ) logger.info('brand style requested: job=%s url=%s tenant=%s', job['id'], url, tenant) return jsonify({ 'ok': True, 'job_id': job['id'], 'status': job['status'], 'message': 'Reviewing the site — this takes a few minutes.', }), 202 @canvas_styles_bp.get('/api/canvas/styles/from-website') def style_from_website_latest(): """The most recent brand-review job, so the picker can resume after a reload. The browser stores NOTHING (root CLAUDE.md: the VPS is the database). A user who refreshes mid-review must still see it running, and the only way to know that without client storage is to ask the server. """ jobs = [j for j in tool_jobs.list_jobs() if j.get('tool') == 'brand_style'] if not jobs: return jsonify({'job': None}) return jsonify({'job': _brand_job_view(jobs[0])}) def _brand_job_view(job: dict) -> dict: out = { 'job_id': job['id'], 'status': job.get('status'), 'url': (job.get('params') or {}).get('url'), 'progress': job.get('progress'), 'error': job.get('error'), 'created_at': job.get('created_at'), } result = job.get('result') or {} style_id = job.get('style_id') if not style_id: for art in result.get('artifacts') or []: if art.get('type') == 'canvas_style': style_id = art.get('id') break if style_id: # Report the style only if it is REALLY in the store. A finished job # claiming a style that the picker cannot then show is worse than a # failure, because it looks like success. exists = store.get_style(style_id) is not None out['style_id'] = style_id if exists else None out['style_missing'] = not exists if exists: out['preview_url'] = f'/api/canvas/styles/{style_id}/preview' if result.get('summary'): out['summary'] = result['summary'] return out @canvas_styles_bp.get('/api/canvas/styles/from-website/') def style_from_website_status(job_id): job = tool_jobs.get(job_id) if not job: return jsonify({'error': 'job not found'}), 404 if job.get('tool') != 'brand_style': return jsonify({'error': 'not a brand-style job'}), 400 return jsonify(_brand_job_view(job)) @canvas_styles_bp.get('/api/canvas/styles//preview') def preview_style(style_id): style = store.get_style(style_id) if style is None: return Response('style not found', status=404) html = store.rendered_template(style) # Mirror the /pages/ serve environment: the canvas proxy injects 25px body # padding into every real page, and templates are authored assuming it. # Without this, spec-compliant templates (no own body padding) preview # edge-to-edge. _pad = ('') if '' in html: html = html.replace('', _pad + '', 1) else: html = _pad + html resp = Response(html, mimetype='text/html') resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' # Same inline-friendly CSP as canvas pages so previews render fully. resp.headers['Content-Security-Policy'] = ( "default-src 'none'; " "script-src 'unsafe-inline'; " "style-src 'unsafe-inline' https://fonts.googleapis.com; " "img-src 'self' data: blob: https:; " "font-src 'self' https://fonts.gstatic.com;" ) return resp