Metadata-Version: 2.5
Name: enlace
Version: 0.1.58
Summary: Serve all your web apps from a single process — they don't import it, they don't know it's there
Project-URL: Homepage, https://github.com/i2mint/enlace
Project-URL: Repository, https://github.com/i2mint/enlace
Project-URL: Issues, https://github.com/i2mint/enlace/issues
Author: Thor Whalen
License: Apache-2.0
License-File: LICENSE
Keywords: asgi,composition,fastapi,multi-app,platform,web
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: Proxy Servers
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
Requires-Python: >=3.10
Requires-Dist: cw<0.2,>=0.1.1
Requires-Dist: fastapi>=0.100.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: tomli>=1.1; python_version < '3.11'
Requires-Dist: uvicorn[standard]>=0.23.0
Provides-Extra: dev
Requires-Dist: hatchling; extra == 'dev'
Requires-Dist: httpx; extra == 'dev'
Requires-Dist: pillow; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: pytest-asyncio; extra == 'dev'
Provides-Extra: icons
Requires-Dist: pillow; extra == 'icons'
Provides-Extra: process
Requires-Dist: httpx>=0.24.0; extra == 'process'
Description-Content-Type: text/markdown

# enlace

enlace serves all your web apps from one gateway: it discovers them by convention in a directory, mounts Python ASGI apps in-process, runs Node/Go/anything else as supervised child processes behind a reverse proxy, and serves static sites and external services under the same domain. Your apps don't import it, don't know it's there, and keep working standalone.

🦖 Writing code by hand? Skip to [For developers with opposable thumbs](#for-developers-with-opposable-thumbs).

## For AI agents

### Skills

enlace ships three agent skills. The real files live in [`enlace/data/skills/`](enlace/data/skills), so they come with `pip install enlace`, and `gh skill` (which looks for any `skills/*/SKILL.md` in a repo) can install them from GitHub.

| Skill | Audience | What it does | Trigger phrases |
|-------|----------|--------------|-----------------|
| **enlace** | users | Create apps, configure `platform.toml`/`app.toml`, understand conventions and modes, serve | "add an app", "serve my apps", "configure enlace" |
| **enlace-diagnose** | users | Analyze an app for compatibility and suggest fixes that preserve standalone operation | "can this be enlaced?", "diagnose this app", "what needs to change?" |
| **enlace-dev** | developers | Modify the enlace package itself: features, bugs, middleware, process supervision | "add X to enlace", "implement Y in enlace" |

Enable them in one of these ways:

```bash
# Cross-agent and versioned, with a GitHub CLI that has `gh skill`:
gh skill install i2mint/enlace enlace

# From the installed package (offline). Links into ~/.claude/skills by default;
# pass --target .claude/skills to link into the current project instead.
pip install enlace skill
skill link-skills "$(python -c 'from enlace import skills_dir; print(skills_dir())')"

# Or symlink by hand into a project:
mkdir -p .claude/skills
ln -s "$(python -c 'from enlace import skills_dir; print(skills_dir())')"/* .claude/skills/
```

Working on enlace itself? Read [`.claude/CLAUDE.md`](.claude/CLAUDE.md) first: it holds the two core principles, the architecture, and the critical rules (pure ASGI middleware only, loud-and-local load failures, CORS on the parent only). In a checkout, `.claude/skills/` already links to the three skills.

### What an agent can do with enlace

Ask in plain language:

```
"Add my_app to the platform"
"Can my_app be enlaced?"
"Diagnose /path/to/my_app and fix what you can"
"List my apps"
"Serve all my apps"
```

- **Onboarding an existing app.** The agent runs `enlace diagnose`, reads the report, and presents findings in three tiers: enlace-side fixes (no app changes), app changes that preserve standalone operation, and warnings. It proposes specific code changes and applies them with your approval.
- **Creating a new app.** The agent scaffolds the directory, writes `server.py` with a FastAPI app, optionally creates `frontend/index.html`, registers it in `platform.toml`, runs `enlace check`, and starts serving.
- **Day-to-day operations.** The agent runs `enlace serve`, `enlace check`, `enlace show-config` and `enlace doctor`, interprets the output, and explains what's happening.

### A minimal runnable example

Discover an app, check it, and call it through the gateway, all in-process (`pip install "enlace[dev]"` provides the test client's `httpx`):

<!-- tested by tests/test_readme_example.py: keep this block self-contained -->
```python
import tempfile
from pathlib import Path

from starlette.testclient import TestClient

from enlace import PlatformConfig, build_backend, diagnose_app, discover_apps

root = Path(tempfile.mkdtemp())
app_dir = root / "apps" / "hello"
app_dir.mkdir(parents=True)
(app_dir / "server.py").write_text(
    "from fastapi import FastAPI\n"
    "app = FastAPI()\n"
    "@app.get('/greet')\n"
    "def greet(name: str = 'world'):\n"
    "    return {'message': f'Hello, {name}!'}\n"
)
(root / "platform.toml").write_text('[platform]\napps_dirs = ["apps"]\n')

assert diagnose_app(app_dir).is_enlaceable
config = discover_apps(PlatformConfig.from_toml(root / "platform.toml"))
client = TestClient(build_backend(config))
print(client.get("/api/hello/greet", params={"name": "agent"}).json())
# {'message': 'Hello, agent!'}
```

## Quick start

```bash
pip install enlace
```

```bash
# Create an app
mkdir -p apps/hello
cat > apps/hello/server.py << 'EOF'
from fastapi import FastAPI
app = FastAPI()

@app.get("/greet")
def greet(name: str = "world"):
    return {"message": f"Hello, {name}!"}
EOF

# Serve it
enlace serve
# → http://localhost:8000/api/hello/greet?name=Thor
```

With no `platform.toml`, enlace serves the apps in `./apps`.

## Philosophy

**Apps don't depend on enlace.** enlace is an operator's tool, not a library your app imports. Your app is a standard Python module with `app = FastAPI()`. It runs standalone with `uvicorn server:app`. enlace just happens to know how to find it, mount it at a route prefix, and serve it alongside other apps. (See [Zero Coupling](https://github.com/i2mint/enlace/blob/main/misc/docs/design_principles__Zero_coupling_and_standalone_preservation_in_multi_app_composition.md#1-zero-coupling-apps-dont-import-enlace) for how enlace provides services like auth and storage without creating a dependency.)

```
enlace (the platform)          your app (Python, Node, Go, ...)
├── fastapi                    ├── fastapi (or express, gin, ...)
├── uvicorn                    ├── pandas (or whatever you need)
├── pydantic                   └── ... your domain libs
├── cw
└── httpx (optional, for proxy)
                               ← no arrow here: your app does NOT import enlace
```

**Two principles in tension:**

1. **Apps should not need to change.** All aggregation logic lives in enlace. When an app is hard to mount, prefer enlace-side config (app.toml, env vars) over app code changes.
2. **Enlaced apps must still work alone.** When changes are suggested, they preserve standalone operation. The pattern: env-var with current value as default — standalone uses the original, enlace overrides at build time. (See [Standalone Preservation](https://github.com/i2mint/enlace/blob/main/misc/docs/design_principles__Zero_coupling_and_standalone_preservation_in_multi_app_composition.md#2-standalone-preservation-enlaced-apps-must-still-work-alone) for the env-var-with-default pattern and fix classification.)

For the full rationale — including how these principles interact, where the balance sits today, and what enlace aspires to handle better — see the [Design Principles](https://github.com/i2mint/enlace/blob/main/misc/docs/design_principles__Zero_coupling_and_standalone_preservation_in_multi_app_composition.md) document.

## Under the hood

The CLI, Python API, and configuration system that the skills use internally.

### CLI

```bash
enlace serve              # Start backend (dev mode, hot reload); --mode prod for 2 workers
enlace show-config        # Resolved config with provenance (--verbose, --json)
enlace check              # Validate config, check route conflicts
enlace list-apps          # Table: name, route, type, access (+ mode when mixed)
enlace app-meta           # Launcher metadata (title, description, icon) per app
enlace build [--app-name NAME]
                          # Run declarative frontend builds (apps with a
                          # [build] section); explicit pre-deploy step,
                          # never builds at request time. --dry-run to preview.
enlace diagnose <dir>     # Analyze an app for enlace compatibility
enlace doctor --base-url http://127.0.0.1:8000
                          # Post-deploy smoke: probe /auth/csrf and every
                          # mounted app; exit nonzero on any failure.
enlace analytics [--app-name kids] [--days 30] [--json]
                          # Page views per day and per path, for apps that
                          # opted in to privacy-first analytics.
```

### Python API

```python
from enlace import build_backend, diagnose_app, discover_apps

# Diagnose an app
report = diagnose_app("/path/to/my_app")
print(report)  # Human-readable report
print(report.is_enlaceable)  # True if no critical blockers

# Discover and compose
config = discover_apps()  # reads ./platform.toml, or defaults to ./apps
app = build_backend(config)  # FastAPI app with all sub-apps mounted
```

### App discovery

enlace discovers apps by filesystem conventions:

```
apps/
├── my_tool/
│   └── server.py          # has `app = FastAPI()` → mounted at /api/my_tool
├── dashboard/
│   ├── server.py           # backend
│   └── frontend/
│       └── index.html      # served at /dashboard/
├── calculator/
│   └── server.py           # typed functions, no `app` → auto-wrapped as routes
├── blog_node/
│   ├── app.toml            # mode = "process", command = ["node", "server.js"]
│   └── server.js           # spawned + proxied at /api/blog_node
└── docs/
    ├── app.toml            # mode = "static", public_dir = "dist"
    └── dist/index.html     # served directly
```

| Convention | Default | Override in `app.toml` |
|-----------|---------|----------------------|
| Serving mode | `asgi` | `mode` (`asgi`, `process`, `external`, `static`) |
| Route prefix | `/api/{dir_name}` | `route` |
| Entry point | First of `server.py`, `app.py`, `main.py` | `entry_point` |
| ASGI app object | Attribute named `app` | `app_attr` |
| Frontend assets | `frontend/index.html` | `frontend_dir` |

Everything enlace infers is inspectable (`enlace show-config --verbose`) and overridable via `app.toml`, `platform.toml`, environment variables, or CLI flags.

#### When an app won't import

Discovering an `asgi`-mode app *imports* its entry module, so a missing dependency — or any exception raised at module scope — is a discovery failure. What happens next is a policy you choose:

```python
discover_apps(config)  # raise (default)
discover_apps(config, on_import_error="record")  # record and carry on
```

The diagnostic verbs (`doctor`, `check`, `list-apps`, `show-config`, `app-meta`) record, so one broken app no longer hides the health of every other one — `enlace doctor --json` still exits nonzero, but now with a parseable `FAIL` naming the app and its exception class, on `AppConfig.import_error`.

The gateway (`create_app`, which gunicorn loads, and `enlace serve`) records too, and is **loud and local**: the broken app's route prefix gets a 500 stub naming the app and the exception class, and every other app serves. A stub is never silent: one ERROR log line with the traceback, `degraded_apps` in `/_meta`, `degraded: true` (and not launchable) in `/_apps`, and a `FAIL` in `enlace doctor` read from the live gateway's `/_meta`. Platform configuration errors still refuse to boot. To refuse to boot on an app's failure too:

```toml
# app.toml — this app would rather be absent than serve a 500
fail_hard = true
```

```toml
# platform.toml — every app, the pre-0.1.50 behaviour
[platform]
app_load_failure = "raise"
```

The decision and its limits are in [ADR-0001](misc/docs/decisions/0001-loud-and-local-app-load-failures.md).

### Configuration

**`platform.toml`** (project root):

```toml
[platform]
apps_dirs = ["apps"]                # Directories containing app subdirs
app_dirs = ["/path/to/standalone"]  # Individual app directories
backend_port = 8000
cors_origins = ["*"]                # default: any origin, never with credentials;
                                    # or list origins ("https://host[:port]") that may
                                    # send credentials (then only those get CORS);
                                    # [] = no CORS. "*" can't be mixed; "null" refused.

[conventions]
entry_points = ["server.py", "app.py", "main.py"]
app_attr = "app"
frontend_dir = "frontend"
```

**`app.toml`** (per-app, in app directory):

```toml
# Python ASGI (default mode — just overrides)
route = "/api/custom-route"
entry_point = "backend/main.py"
access = "public"
display_name = "My App"
```

```toml
# Non-Python or separate process
mode = "process"
command = ["node", "server.js"]
port = 3001
route = "/api/blog"
```

```toml
# External upstream
mode = "external"
upstream_url = "http://192.168.1.50:3000"
```

```toml
# Declarative frontend build (any app with a compiled frontend)
[build]
install = "npm ci"                  # string or argv list
build = "npm run build"
env_vars = ["VITE_API_BASE"]        # vars the build honours; deployers inject values
outputs = ["dist"]                  # optional hint of produced paths
```

`enlace build` runs these as an explicit pre-deploy step (`enlace build --app-name my_app`, or all apps with a `[build]` section). enlace never builds at request time and does not assume a toolchain — it just runs the argv the app declares, in the app directory, with the caller's environment plus any injected overrides (e.g. `VITE_API_BASE`). The app keeps a normal build whether or not enlace serves it, so the "apps don't know about enlace" principle holds.

For process/external modes: `pip install enlace[process]`

**Override precedence** (lowest → highest):

```
defaults → filesystem conventions → app.toml → platform.toml → env vars → CLI flags
```

### App modes

| Mode | Description | How it works |
|------|-------------|-------------|
| `asgi` (default) | Python ASGI apps | Imported + mounted on gateway FastAPI |
| `process` | Any app as a child process | Spawned, health-checked, reverse-proxied |
| `external` | Pre-existing upstream | Proxied, no lifecycle management |
| `static` | Static file directory | Served directly |

The reverse proxy carries HTTP, including server-sent events. It does not carry websockets yet: a websocket to a `process` or `external` app is refused with a close (clients see an HTTP 403).

Within `asgi` mode, apps are further classified:

| Type | How detected | How mounted |
|------|-------------|-------------|
| `asgi_app` | Module has callable `app` attribute | `parent.mount(prefix, sub_app)` |
| `functions` | No `app` attr, has typed public functions | Auto-wrapped as API routes |
| `frontend_only` | No backend entry, has `frontend/index.html` | Static file serving only |

### Auth, sessions & plugins

enlace itself is auth-agnostic. Cross-cutting concerns like authentication, sessions, and per-user storage are added by **plugins** — callables `(parent: FastAPI, config: PlatformConfig) -> None` invoked once after sub-apps are mounted:

```python
from enlace import build_backend, discover_apps

app = build_backend(discover_apps(), plugins=[my_plugin])
```

Plugins can also be loaded by name from the `ENLACE_PLUGINS` env var (comma-separated). The canonical example is `enlace_auth.plugin`, which adds auth, sessions, the admin dashboard, and per-user stores. Plugins reach apps without coupling: services are injected into the ASGI scope, so apps read `request.state.store` rather than importing anything from enlace. Apps can also condition on the `ENLACE_MANAGED=1` env var (set by `build_backend()`), but never have to.

### HTML caching

Every static mount (app frontends, the landing app, `mode="static"` apps, shared assets) sends `Cache-Control: no-cache` on HTML documents — including SPA fallbacks, `304` revalidations and an `html=True` `404.html` page — so browsers revalidate a page (a cheap `304` via its `ETag`) instead of heuristically reusing an old build whose HTML still names the previous asset URLs. Non-HTML assets are untouched, and a `Cache-Control` already set on a response is never overridden. The class is `enlace.frontend.RevalidatingStaticFiles` (`html_cache_control=None` opts out).

### App icons (tab, home screen, launcher)

Every app gets one icon, served in every form a browser or phone asks for: `/_apps/{name}/icon` (as-is), `/_apps/{name}/icon-{32,180,192,512}.png` (square PNGs, needs `pip install enlace[icons]`), and a generated `/_apps/{name}/manifest.webmanifest`. enlace adds the matching `<link rel="icon">`, `<link rel="apple-touch-icon">` and `<link rel="manifest">` to each app's HTML wherever the app's own `<head>` lacks them, so the launcher tile, the browser tab and "Add to Home Screen" (iOS and Android) all show the same image.

The icon is picked once per app: live launcher edit > `[app_meta.apps.<name>].icon` > `<name>.png` in `[app_meta].icons_dir` > the app's own icon (from its `<head>`, manifest or `icon.png`) > a letter monogram. An icon from the platform tiers replaces the page's own icon links; an app's own icon only fills gaps.

```toml
[app_meta]
icons_dir = "static/app_icons"   # one folder of <app>.png, owned by the platform
manifest_display = "browser"     # or "standalone"
```

Home-screen icons must be raster: an app whose icon is only an SVG, emoji or monogram gets a favicon but no PNG. `enlace.app_icons.home_screen_gaps(config)` lists those apps.

### Privacy-first analytics

An app can have page views counted with no cookie, no JavaScript and no third party. It opts in from its own `app.toml`:

```toml
[analytics]
mode = "privacy"
```

The enlace server counts the app's HTML page views as it serves them, and the pages are not changed at all. It stores only daily aggregates per app: views per path (query strings dropped), referrer domain, primary language and device class (mobile/tablet/desktop). Each is kept as a separate count, never crossed with the others. No IP address is read and no visitor identifier is kept. Bots are counted separately. `DNT: 1` and `Sec-GPC: 1` are honoured, and `/_analytics/opt-out` is a page a privacy notice can link to. An app without the table records nothing.

Read the counts on the serving host with `enlace analytics`, or from Python with `enlace.analytics_report()`. Storage defaults to JSON files under `~/.local/share/enlace/analytics`. It is configured in `platform.toml`, and `build_backend(config, analytics_store=...)` takes any `MutableMapping` (e.g. a `dol` store):

```toml
[analytics]
store_path = "~/.local/share/enlace/analytics"
retention_days = 395        # at most 750 (under 25 months)
timezone = "Europe/Paris"   # whose midnight starts a new day
```

The design and how it maps onto the CNIL's consent exemption for audience measurement are in [`misc/docs/privacy_analytics.md`](misc/docs/privacy_analytics.md). That doc also lists what a site operator still has to do: a privacy-notice entry, and handling their own access logs.

### Deploy manifest (`/_meta`)

enlace answers "what is actually deployed?" via an always-on, cheap manifest layer. Every mounted app gets a `/_meta` endpoint (plus a platform-level `/_meta`), and every response carries `X-Deploy-*` headers. A deploy tool snapshots build identity (git SHA, deploy time, externals) at deploy time into `{manifest_dir}/{app}.json`; enlace owns the read paths and schema. This lets you tell whether the server is serving the SHA you think it is and whether the browser rendered the current build or a stale cache.

`enlace_version` is what the deploy recorded (or, when the manifest records none, the running version); `enlace_runtime_version` is always the enlace actually running, added at serve time. They differ after an engine-only upgrade (a new wheel and a restart, no redeploy), and `enlace doctor` warns when they do.

## For developers with opposable thumbs

Everything above works for you too. This section holds what only matters when you change enlace itself.

**Dev setup.**

```bash
git clone https://github.com/i2mint/enlace && cd enlace
pip install -e ".[dev]"                 # dev carries pytest, httpx, Pillow
python -m pytest --doctest-modules      # what CI runs: enlace/ (tests + doctests) and tests/
ruff check . && ruff format --check .
```

**Where the reasoning lives.** [`misc/docs/enlace_spec.md`](misc/docs/enlace_spec.md) is the architecture. The research documents in [`misc/docs/`](misc/docs) explain each subsystem (ASGI composition, auth, discovery, frontend serving, process orchestration), and [`misc/docs/decisions/`](misc/docs/decisions) holds the ADRs. Read the relevant one before changing a subsystem; the table in [`.claude/CLAUDE.md`](.claude/CLAUDE.md) maps modules to documents.

**House rules that bite.** Middleware is pure ASGI, never `BaseHTTPMiddleware`. Sub-apps mount on `FastAPI()` directly, never on an `APIRouter`. Any suggestion to an app author must keep the app runnable on its own (the env-var-with-default pattern). The CLI surface is the `COMMANDS` list at the bottom of `enlace/__main__.py`, and the `cw` dispatcher keeps its default convention on purpose.

**Skills.** Edit the real files under `enlace/data/skills/`. The entries in `.claude/skills/` are symlinks to them, and a test fails if one turns back into a copy.

**Releases.** CI (the reusable wads workflow) runs on every push and publishes to PyPI when a change lands on `main`; it also bumps the version, so don't edit it by hand. Questions and proposals go to [the issue tracker](https://github.com/i2mint/enlace/issues).
