# Async Portal Operations + Bouncer Creation Fix

## Context

Bouncer creation from the portal is broken. The web-api creates `SojuBouncer` CRs with zero listeners, causing the soju-operator to build a Kubernetes Service with empty `spec.ports` (422 rejection). Bouncers never reach "ready" state.

Additionally, all portal mutations block the browser with no feedback. We're adding HTMX for async form submissions with inline progress/loading indicators across all mutating operations.

---

## Part 1: Bouncer Creation Bug Fix

### Root cause

1. `crates/web-api/src/k8s.rs:25-31` -- minimal `SojuBouncerSpec` lacks `listeners` and `auth` fields
2. `crates/web-api/src/routes/bouncer.rs:90-94` -- creates spec with no listeners
3. `crates/soju-operator/src/resources/service.rs:44` -- `ports: Some(ports)` passes empty vec to K8s

### Changes

**`crates/soju-operator/src/resources/service.rs`** -- defensive fix
- Line 44: change `ports: Some(ports)` to `ports: if ports.is_empty() { None } else { Some(ports) }`
- Add test for empty listeners case

**`crates/web-api/src/k8s.rs`** -- expand spec types
- Add `Listener` struct: `address: String`, `tls: bool`, `websocket: bool`
- Add `AuthSpec` struct: `oauth2_url: String`, `secret_ref: Option<String>`
- Add fields to `SojuBouncerSpec`: `listeners: Vec<Listener>` (default empty, skip_serializing_if empty), `auth: Option<AuthSpec>`
- Add `default_bouncer_spec(name: &str, owner: &str, issuer_url: &str) -> SojuBouncerSpec` helper:
  - listeners: `[":6667" plain, ":8080" websocket]` (no TLS -- portal bouncers lack per-bouncer certs)
  - auth: `AuthSpec { oauth2_url: issuer_url, secret_ref: Some("oidc-soju") }`
  - hostname: `{name}.irc.now`, owner, hostname_cloak: `{name}.irc.now`

**`crates/web-api/src/routes/bouncer.rs`** -- use default spec
- Line 90-94: replace manual `SojuBouncerSpec` construction with `default_bouncer_spec(&name, &user.sub, &state.oidc.issuer_url)`

---

## Part 2: HTMX Integration

### Infrastructure

**`crates/web-api/static/htmx.min.js`** -- vendor htmx 2.0.4
- Download and self-host (no CDN dependency)

**`crates/web-api/templates/base.html`** -- add script tag
- `<script src="/static/htmx.min.js"></script>` before `</head>`

**`crates/web-api/static/portal.css`** -- indicator styles
```css
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request .htmx-hide-on-request { display: none; }
.btn-fill.htmx-request { opacity: 0.6; pointer-events: none; }
```

**`crates/web-api/src/htmx.rs`** -- new module
```rust
pub fn is_htmx(headers: &HeaderMap) -> bool {
    headers.get("hx-request").is_some()
}

pub fn hx_redirect(url: &str) -> (HeaderName, HeaderValue) { ... }
```

Register in `main.rs`: `mod htmx;`

### Dual-response pattern

Every mutating handler gets this structure:
```rust
pub async fn handler(headers: HeaderMap, ...) -> Response {
    // ... do the work ...
    if htmx::is_htmx(&headers) {
        // Return HTML fragment (200)
    } else {
        // Return redirect + flash cookie (existing behavior)
    }
}
```

Return types change from `Result<(AppendHeaders, Redirect), ...>` to `Response` (or `impl IntoResponse` returning `Response`).

### Fragment templates

**`crates/web-api/templates/partials/flash_fragment.html`** -- standalone flash (no base.html)
```html
<div class="flash {{ css_class }}">{{ text }}</div>
```

**`crates/web-api/templates/partials/bouncer_creating.html`** -- provisioning card with status poll
```html
<a href="/bouncers/{{ name }}" style="text-decoration: none; color: inherit;">
  <div class="card">
    <h2>{{ name }}</h2>
    <p>{{ name }}.irc.now --
      <span hx-get="/bouncers/{{ name }}/status" hx-trigger="every 3s" hx-swap="innerHTML">
        <span style="color: #E08080;">provisioning...</span>
      </span>
    </p>
  </div>
</a>
```

**`crates/web-api/templates/partials/bouncer_status_fragment.html`** -- status badge
```html
{% if ready %}<span style="color: var(--g);">ready</span>{% else %}<span style="color: #E08080;">not ready</span>{% endif %}
```

### Handler changes

**1. Bouncer create** (`routes/bouncer.rs` -- `create()`)
- Accept `headers: HeaderMap` parameter
- `tokio::spawn` the `add_default_upstream()` call instead of awaiting it (for both paths -- the result is non-critical and already logs warnings on failure)
- htmx: return `bouncer_creating.html` fragment (200)
- plain: existing redirect + flash

**2. Bouncer status** (`routes/bouncer.rs` -- new `status()` handler)
- `GET /bouncers/{name}/status`
- Check bouncer CR owner matches user
- Return `bouncer_status_fragment.html` (ready/not-ready)
- When ready, add `hx-swap-oob` or stop polling via response headers
- Register in `main.rs`: `.route("/bouncers/{name}/status", get(routes::bouncer::status))`

**3. Bouncer delete** (`routes/bouncer.rs` -- `delete()`)
- Accept `headers: HeaderMap` parameter
- htmx: return 200 with `HX-Redirect: /bouncers` header
- plain: existing redirect + flash

**4. Profile update** (`routes/profile.rs` -- `update()`)
- Already has headers available (session extractor has access)
- Accept `headers: HeaderMap` parameter
- htmx: return `flash_fragment.html` (success or error)
- plain: existing redirect + flash

**5. Billing checkout** (`routes/billing.rs` -- `checkout()`)
- Accept `headers: HeaderMap` parameter
- htmx: return 200 with `HX-Redirect: {stripe_url}` header
- plain: existing Redirect
- Billing portal (`GET /billing/portal`) stays as-is -- it's a link, not a form

### Template changes

**`templates/bouncers.html`** -- create form
```html
<form method="post" action="/bouncers/create"
      hx-post="/bouncers/create" hx-target="#bouncer-result" hx-swap="innerHTML">
  ...
  <button type="submit" class="btn-fill">
    <span class="htmx-hide-on-request">create bouncer</span>
    <span class="htmx-indicator">creating...</span>
  </button>
</form>
<div id="bouncer-result"></div>
```

**`templates/bouncer_detail.html`** -- delete form + auto-poll status
- Delete form: add `hx-post` + `hx-confirm` (replaces `onsubmit="return confirm()"`)
- Status: when not ready, add `hx-get="/bouncers/{{ name }}/status"` with `hx-trigger="every 3s"` so it auto-refreshes

**`templates/profile.html`** -- save form
- Add `hx-post="/profile/update"` with `hx-target="#profile-flash"` + `hx-swap="innerHTML"`
- Add `<div id="profile-flash"></div>` target for flash fragment

**`templates/billing.html`** -- checkout form
- Add `hx-post="/billing/checkout"` with loading indicator text

### Auth guard + HTMX session expiry

`crates/web-api/src/auth_guard.rs` -- detect HTMX requests and return `HX-Redirect: /auth/login` (200) instead of 302. Without this, HTMX would swap the login page HTML into the target element when the session expires. Check for `hx-request` header in `from_request_parts()` and return an appropriate response.

This requires changing the `Rejection` type from `Redirect` to `Response` so we can return either a 302 or a 200 with HX-Redirect header.

---

## Implementation Order

1. Bug fix: `service.rs` guard + `k8s.rs` types + `bouncer.rs` default spec
2. HTMX infra: vendor `htmx.min.js`, `htmx.rs` module, `base.html` script, `portal.css` styles
3. Auth guard HTMX awareness
4. Fragment templates (partials)
5. Handlers one at a time: bouncer create + status endpoint -> bouncer delete -> profile update -> billing checkout
6. Template updates alongside each handler

---

## Files Modified

| File | Change |
|------|--------|
| `crates/soju-operator/src/resources/service.rs` | Guard empty ports |
| `crates/web-api/src/k8s.rs` | Add Listener, AuthSpec, default_bouncer_spec() |
| `crates/web-api/src/routes/bouncer.rs` | Default spec, htmx dual-response, status endpoint, spawn add_default_upstream |
| `crates/web-api/src/routes/profile.rs` | htmx dual-response |
| `crates/web-api/src/routes/billing.rs` | htmx dual-response (checkout) |
| `crates/web-api/src/main.rs` | Register htmx module, add status route |
| `crates/web-api/src/auth_guard.rs` | HTMX-aware session expiry |
| `crates/web-api/src/htmx.rs` | New: is_htmx(), hx_redirect() |
| `crates/web-api/static/htmx.min.js` | New: vendored htmx 2.0.4 |
| `crates/web-api/static/portal.css` | Add htmx indicator styles |
| `crates/web-api/templates/base.html` | Add htmx script tag |
| `crates/web-api/templates/bouncers.html` | htmx form attributes |
| `crates/web-api/templates/bouncer_detail.html` | htmx delete + status polling |
| `crates/web-api/templates/profile.html` | htmx form attributes |
| `crates/web-api/templates/billing.html` | htmx checkout form |
| `crates/web-api/templates/partials/flash_fragment.html` | New: standalone flash |
| `crates/web-api/templates/partials/bouncer_creating.html` | New: provisioning card |
| `crates/web-api/templates/partials/bouncer_status_fragment.html` | New: status badge |

## Verification

1. `cargo test -p soju-operator` -- service empty ports test passes
2. `cargo test -p irc-now-web-api` -- existing + new tests pass
3. `cargo build --workspace` -- clean build
4. Deploy soju-operator, verify existing broken bouncers (apple, doggy) reconcile to ready
5. Deploy web-api, create a new bouncer from portal -- verify it gets default listeners and reaches ready state
6. Test htmx: create bouncer shows inline "creating..." then provisioning card with auto-polling status
7. Test htmx: delete bouncer shows "deleting..." then redirects
8. Test htmx: profile save shows inline success/error flash
9. Test htmx: billing checkout shows "redirecting to stripe..." then redirects
10. Test fallback: disable JS, verify all forms still work via redirect+flash
