Skip to content
Merged
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
197 changes: 197 additions & 0 deletions fastapps/cli/commands/allow_csp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""CSP management command for FastApps projects."""

import json
import sys
from pathlib import Path
from typing import Literal

from rich.console import Console
from rich.prompt import Prompt
from rich.table import Table

console = Console()


def load_csp_config(project_root: Path) -> dict:
"""Load CSP configuration from fastapps.json."""
config_file = project_root / "fastapps.json"

if config_file.exists():
try:
with open(config_file, "r") as f:
return json.load(f)
except json.JSONDecodeError:
console.print("[yellow]Warning: Could not parse fastapps.json[/yellow]")
return {}

return {}


def save_csp_config(project_root: Path, config: dict):
"""Save CSP configuration to fastapps.json."""
config_file = project_root / "fastapps.json"

with open(config_file, "w") as f:
json.dump(config, f, indent=2)

console.print(f"[green]✓ Saved to {config_file}[/green]")


def add_csp_domain(
url: str = None,
domain_type: Literal["resource", "connect"] = None,
):
"""
Add a domain to CSP allowlist.

Args:
url: Domain URL to allow (e.g., https://example.com)
domain_type: Type of domain - "resource" for assets, "connect" for APIs
"""
# Check if we're in a FastApps project
project_root = Path.cwd()
if not (project_root / "server" / "main.py").exists():
console.print("[red]Error: Not in a FastApps project directory[/red]")
console.print(
"[yellow]Run this command from your project root (where server/main.py exists)[/yellow]"
)
return False

# Interactive mode if arguments not provided
if url is None:
console.print("\n[cyan]Add CSP Domain[/cyan]\n")
url = Prompt.ask(
"[bold]Enter domain URL[/bold]",
default="https://example.com"
)

if domain_type is None:
console.print("\n[dim]Domain types:[/dim]")
console.print(" [cyan]resource[/cyan] - For scripts, styles, images, fonts")
console.print(" [cyan]connect[/cyan] - For API calls (fetch, XHR)\n")

domain_type = Prompt.ask(
"[bold]Domain type[/bold]",
choices=["resource", "connect"],
default="resource"
)

# Validate URL
if not url.startswith("https://") and not url.startswith("http://"):
console.print("[red]Error: URL must start with https:// or http://[/red]")
return False

# Load existing config
config = load_csp_config(project_root)

# Initialize CSP section if not exists
if "csp" not in config:
config["csp"] = {
"resource_domains": [],
"connect_domains": []
}

# Add domain to appropriate list
domain_key = f"{domain_type}_domains"
if domain_key not in config["csp"]:
config["csp"][domain_key] = []

if url in config["csp"][domain_key]:
console.print(f"[yellow]Domain already exists in {domain_type}_domains[/yellow]")
return True

config["csp"][domain_key].append(url)

# Save config
save_csp_config(project_root, config)

# Show updated config
console.print(f"\n[green]✓ Added {url} to {domain_type}_domains[/green]\n")
show_csp_config(project_root)

return True


def remove_csp_domain(
url: str = None,
domain_type: Literal["resource", "connect"] = None,
):
"""Remove a domain from CSP allowlist."""
project_root = Path.cwd()
if not (project_root / "server" / "main.py").exists():
console.print("[red]Error: Not in a FastApps project directory[/red]")
return False

# Load existing config
config = load_csp_config(project_root)

if "csp" not in config:
console.print("[yellow]No CSP configuration found[/yellow]")
return False

# Interactive mode if arguments not provided
if url is None or domain_type is None:
show_csp_config(project_root)
console.print()

if url is None:
url = Prompt.ask("[bold]Enter domain URL to remove[/bold]")

if domain_type is None:
domain_type = Prompt.ask(
"[bold]Domain type[/bold]",
choices=["resource", "connect"]
)

# Remove domain
domain_key = f"{domain_type}_domains"
if domain_key in config["csp"] and url in config["csp"][domain_key]:
config["csp"][domain_key].remove(url)
save_csp_config(project_root, config)
console.print(f"\n[green]✓ Removed {url} from {domain_type}_domains[/green]\n")
show_csp_config(project_root)
return True
else:
console.print(f"[yellow]Domain not found in {domain_type}_domains[/yellow]")
return False


def show_csp_config(project_root: Path = None):
"""Display current CSP configuration."""
if project_root is None:
project_root = Path.cwd()

config = load_csp_config(project_root)

if "csp" not in config or (
not config["csp"].get("resource_domains") and
not config["csp"].get("connect_domains")
):
console.print("[yellow]No CSP domains configured[/yellow]")
return

table = Table(title="CSP Configuration", title_style="bold cyan")
table.add_column("Type", style="cyan", no_wrap=True)
table.add_column("Domain", style="white")

for domain in config["csp"].get("resource_domains", []):
table.add_row("resource", domain)

for domain in config["csp"].get("connect_domains", []):
table.add_row("connect", domain)

console.print(table)


def list_csp_domains():
"""List all configured CSP domains."""
project_root = Path.cwd()
if not (project_root / "server" / "main.py").exists():
console.print("[red]Error: Not in a FastApps project directory[/red]")
return False

console.print()
show_csp_config(project_root)
console.print()

return True
60 changes: 58 additions & 2 deletions fastapps/cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,29 @@ def auto_load_tools(build_results):
# Auto-load and register tools
tools = auto_load_tools(build_results)

# Create MCP server
server = WidgetMCPServer(name="my-widgets", widgets=tools)
# Load CSP configuration from fastapps.json
def load_csp_config():
\"\"\"Load CSP configuration from fastapps.json if exists.\"\"\"
config_file = PROJECT_ROOT / "fastapps.json"
if config_file.exists():
try:
import json
with open(config_file, "r") as f:
config = json.load(f)
return config.get("csp", {})
except Exception as e:
print(f"[WARNING] Could not load CSP config: {e}")
return {}

csp_config = load_csp_config()

# Create MCP server with CSP configuration
server = WidgetMCPServer(
name="my-widgets",
widgets=tools,
global_resource_domains=csp_config.get("resource_domains", []),
global_connect_domains=csp_config.get("connect_domains", []),
)

# Optional: Enable OAuth 2.1 authentication
# Uncomment and configure to protect your widgets with OAuth:
Expand Down Expand Up @@ -188,6 +209,26 @@ def get_package_json(project_name: str) -> str:
└── package.json
```

## Content Security Policy (CSP)

Your project includes a default CSP configuration in `fastapps.json` that allows loading images from a safe public CDN. You can manage CSP domains using the CLI:

```bash
# Add external resource domains (images, fonts, styles)
fastapps csp add --url https://your-cdn.com --type resource

# Add API domains (fetch, XHR)
fastapps csp add --url https://api.example.com --type connect

# List all configured domains
fastapps csp list

# Remove a domain
fastapps csp remove --url https://example.com --type resource
```

The default domain (`https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev`) is a safe public CDN used by example widgets. You can remove it if not needed.

## Learn More

- **FastApps Framework**: https://pypi.org/project/fastapps/
Expand Down Expand Up @@ -292,6 +333,21 @@ def init_project(project_name: str):
console.print("Creating .gitignore...")
(project_path / ".gitignore").write_text(GITIGNORE)

# Create fastapps.json with default CSP
console.print("Creating fastapps.json...")
import json
fastapps_config = {
"csp": {
"resource_domains": [
"https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev"
],
"connect_domains": []
}
}
(project_path / "fastapps.json").write_text(
json.dumps(fastapps_config, indent=2)
)

console.print(
f"\n[green][OK] Project '{project_name}' created successfully![/green]"
)
Expand Down
66 changes: 66 additions & 0 deletions fastapps/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from fastapps.core.utils import get_cli_version

from .commands.allow_csp import add_csp_domain, list_csp_domains, remove_csp_domain
from .commands.build import build_command
from .commands.cloud import cloud
from .commands.create import create_widget
Expand Down Expand Up @@ -201,5 +202,70 @@ def use(integration_name):
use_integration(integration_name)


# CSP management command group
@cli.group()
def csp():
"""Manage Content Security Policy (CSP) for widgets.

Configure domains allowed to load external resources (scripts, styles, images)
and make API calls (fetch, XHR) from your widgets.

All CSP configuration is stored in fastapps.json and automatically
loaded when creating WidgetMCPServer.
"""
pass


@csp.command("add")
@click.option("--url", help="Domain URL to allow (e.g., https://example.com)")
@click.option(
"--type",
"domain_type",
type=click.Choice(["resource", "connect"], case_sensitive=False),
help="Domain type: 'resource' for assets, 'connect' for APIs",
)
def csp_add(url, domain_type):
"""Add a domain to CSP allowlist.

Examples:
fastapps csp add --url https://pub-YOUR-ID.r2.dev --type resource
fastapps csp add --url https://api.example.com --type connect
fastapps csp add # Interactive mode

Domain types:
resource - For scripts, styles, images, fonts (e.g., CDN, R2 buckets)
connect - For API calls via fetch/XHR (e.g., external APIs)
"""
add_csp_domain(url=url, domain_type=domain_type)


@csp.command("remove")
@click.option("--url", help="Domain URL to remove")
@click.option(
"--type",
"domain_type",
type=click.Choice(["resource", "connect"], case_sensitive=False),
help="Domain type: 'resource' or 'connect'",
)
def csp_remove(url, domain_type):
"""Remove a domain from CSP allowlist.

Examples:
fastapps csp remove --url https://example.com --type resource
fastapps csp remove # Interactive mode
"""
remove_csp_domain(url=url, domain_type=domain_type)


@csp.command("list")
def csp_list():
"""List all configured CSP domains.

Shows currently configured resource and connect domains
from fastapps.json.
"""
list_csp_domains()


if __name__ == "__main__":
cli()
Loading
Loading