# 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` - Add fields to `SojuBouncerSpec`: `listeners: Vec` (default empty, skip_serializing_if empty), `auth: Option` - 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 - `` before `` **`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
{{ text }}
``` **`crates/web-api/templates/partials/bouncer_creating.html`** -- provisioning card with status poll ```html

{{ name }}

{{ name }}.irc.now -- provisioning...

``` **`crates/web-api/templates/partials/bouncer_status_fragment.html`** -- status badge ```html {% if ready %}ready{% else %}not ready{% 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
...
``` **`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 `
` 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