# Shared Bouncer Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Implement free-tier shared bouncer, pro-tier dedicated bouncers with gamja sidecar, plan gating, validation webhook, and bidirectional migration.
**Architecture:** soju-shared serves all free users via multi-user mode. Pro users get dedicated SojuBouncer CRs with a gamja sidecar container and edge Route. A ValidatingWebhookConfiguration enforces plan-based quotas. Migration Jobs copy user data between tenant DBs.
**Tech Stack:** Rust (Axum 0.8, kube.rs 3.0, sqlx, tokio-postgres), Kubernetes (OCP 4.20, cert-manager), gamja (nginx), Keycloak 26.1
**Design doc:** `docs/plans/2026-03-07-shared-bouncer-design.md`
---
## Task 1: Deploy soju-shared
Deploy the shared bouncer instance and verify chat.irc.now connects.
**Files:**
- Modify: `deploy/instances/soju-shared.yaml`
- Modify: `chat/nginx.conf`
**Step 1: Apply the soju-shared CR**
The manifest already exists at `deploy/instances/soju-shared.yaml`. It needs the `auth`
field format to match what the operator expects (`oauth2_url` not `oauth2Url`). Verify
the YAML matches the operator's `AuthSpec` struct. The operator will create the
Deployment, Service, ConfigMap, Secret, and tenant DB.
Run:
```bash
oc apply -f deploy/instances/soju-shared.yaml -n irc-josie-cloud
```
Expected: SojuBouncer `soju-shared` created.
**Step 2: Verify resources created**
Run:
```bash
oc get deployment soju-shared -n irc-josie-cloud
oc get svc soju-shared -n irc-josie-cloud
oc get cm soju-shared-config -n irc-josie-cloud
oc get secret soju-shared-db -n irc-josie-cloud
```
Expected: All resources exist and Deployment is 1/1 Ready.
**Step 3: Update chat nginx.conf**
The repo copy of `chat/nginx.conf` currently points to `soju-shared` which is correct.
The running container was manually patched to `josie`. Rebuild chat to pick up the
repo config.
Run:
```bash
oc start-build chat --from-dir=chat/ -n irc-josie-cloud --follow
```
Expected: Build succeeds, chat pod restarts with nginx proxying to `soju-shared:8080`.
**Step 4: Verify chat.irc.now connects**
Open `https://chat.irc.now`, authenticate via Keycloak. Verify connection to soju-shared
and that messages work.
Expected: Connected, nickname is your Keycloak username.
**Step 5: Commit**
```bash
git add deploy/instances/soju-shared.yaml chat/nginx.conf
git commit -m "deploy: apply soju-shared bouncer for free tier"
```
---
## Task 2: Plan gating in web-api
Block bouncer creation for free users. Show different dashboard UI by plan.
**Files:**
- Modify: `crates/web-api/src/routes/bouncer.rs:91` (create handler)
- Modify: `crates/web-api/templates/dashboard.html:44-75` (bouncer section)
- Modify: `crates/web-api/templates/bouncers.html:13-24` (create form)
- Test: `crates/web-api/tests/` (new test file or extend existing)
**Step 1: Write test for plan gating**
Add a test that verifies free users get a 403 when trying to create a bouncer.
Test that pro users succeed. Use the existing test patterns in the crate.
**Step 2: Run test to verify it fails**
Run: `cargo test -p web-api test_bouncer_create_plan_gating`
Expected: FAIL (no plan check exists yet)
**Step 3: Add plan check to create handler**
In `crates/web-api/src/routes/bouncer.rs`, in the `create()` function at line 91,
add a plan check before bouncer creation (before line 114):
```rust
let plan = user.plan.as_deref().unwrap_or("free");
if plan != "pro" {
return Err((StatusCode::FORBIDDEN, "upgrade to pro to create a dedicated bouncer"));
}
```
Use the existing `AuthUser` extractor which provides `user.plan`.
**Step 4: Run test to verify it passes**
Run: `cargo test -p web-api test_bouncer_create_plan_gating`
Expected: PASS
**Step 5: Update dashboard template**
In `crates/web-api/templates/dashboard.html`, modify the bouncer section (lines 44-75).
The dashboard route handler needs to pass `user_plan` to the template context.
For free users (`plan != "pro"`), show:
```html
upgrade to pro
```
For pro users (keep existing bouncer management UI).
**Step 6: Update bouncers page**
In `crates/web-api/templates/bouncers.html`, add a "migrate from shared bouncer"
checkbox to the create form (lines 13-24):
```html
```
**Step 7: Pass plan to dashboard route handler**
In the dashboard route handler (`crates/web-api/src/routes/dashboard.rs`), add
`user_plan` to the template context so the template can conditionally render.
**Step 8: Commit**
```bash
git add crates/web-api/
git commit -m "feat(web-api): gate bouncer creation by plan"
```
---
## Task 3: Gamja sidecar in soju-operator
Add a gamja container and edge Route to dedicated bouncer Deployments.
**Files:**
- Modify: `crates/soju-operator/src/resources/deployment.rs:17` (build_deployment)
- Modify: `crates/soju-operator/src/resources/configmap.rs:7` (build_configmap)
- Create: `crates/soju-operator/src/resources/gamja.rs` (gamja config generation)
- Modify: `crates/soju-operator/src/resources/route.rs:47` (add edge route)
- Modify: `crates/soju-operator/src/controller.rs` (apply edge route)
- Test: `crates/soju-operator/tests/` or inline tests
**Step 1: Write test for gamja sidecar detection**
Test that `build_deployment()` adds a second container when the bouncer name is NOT
`soju-shared`. Test that `soju-shared` gets only the soju container.
**Step 2: Run test to verify it fails**
Run: `cargo test -p soju-operator test_gamja_sidecar`
Expected: FAIL
**Step 3: Create gamja config generator**
Create `crates/soju-operator/src/resources/gamja.rs`:
```rust
pub fn gamja_nginx_config() -> String {
r#"server {
listen 8081;
root /usr/share/nginx/html;
index index.html;
location /socket {
proxy_pass http://localhost:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
location / {
try_files $uri $uri/ /index.html;
}
}"#.to_string()
}
pub fn gamja_config_json(bouncer: &SojuBouncer) -> String {
let oauth2_url = bouncer.spec.auth.as_ref()
.map(|a| a.oauth2_url.as_str())
.unwrap_or("https://auth.irc.now/realms/irc-now");
serde_json::json!({
"server": {
"url": "/socket",
"auth": "oauth2",
"nick": "*"
},
"oauth2": {
"url": oauth2_url,
"client_id": "chat",
"scope": "openid"
}
}).to_string()
}
```
**Step 4: Add gamja data to ConfigMap**
In `build_configmap()` at `crates/soju-operator/src/resources/configmap.rs`, add
gamja nginx config and config.json as additional data keys when the bouncer name
is not `soju-shared`:
```rust
if bouncer.name_any() != "soju-shared" {
data.insert("gamja-nginx".to_string(), gamja_nginx_config());
data.insert("gamja-config".to_string(), gamja_config_json(bouncer));
}
```
**Step 5: Add gamja sidecar to Deployment**
In `build_deployment()` at `crates/soju-operator/src/resources/deployment.rs`, after
the soju container (line ~163), add a gamja sidecar container when the bouncer name
is not `soju-shared`:
```rust
if bouncer.name_any() != "soju-shared" {
containers.push(Container {
name: "gamja".to_string(),
image: Some("image-registry.openshift-image-registry.svc:5000/irc-josie-cloud/chat:latest".to_string()),
image_pull_policy: Some("Always".to_string()),
ports: Some(vec![ContainerPort {
container_port: 8081,
name: Some("http".to_string()),
..Default::default()
}]),
volume_mounts: Some(vec![
VolumeMount {
name: "config".to_string(),
mount_path: "/etc/nginx/conf.d/".to_string(),
sub_path: Some("gamja-nginx".to_string()),
..Default::default()
},
VolumeMount {
name: "config".to_string(),
mount_path: "/usr/share/nginx/html/config.json".to_string(),
sub_path: Some("gamja-config".to_string()),
..Default::default()
},
]),
security_context: Some(SecurityContext {
run_as_non_root: Some(true),
allow_privilege_escalation: Some(false),
..Default::default()
}),
..Default::default()
});
}
```
Also add port 8081 to the Service for dedicated bouncers.
**Step 6: Add edge Route for gamja**
In `crates/soju-operator/src/resources/route.rs`, add a `build_edge_route()` function:
```rust
pub fn build_edge_route(bouncer: &SojuBouncer) -> OcpRoute {
// Similar to build_route() but with:
// - name: "{bouncer_name}-chat"
// - port target: "http" (8081)
// - tls termination: "edge" (not passthrough)
// - insecure_edge_termination_policy: "Redirect"
// - host: bouncer.spec.hostname (e.g., "apple.irc.now")
}
```
**Step 7: Apply edge Route in controller**
In `crates/soju-operator/src/controller.rs`, in the reconcile apply function, add
the edge Route after the existing passthrough Route:
```rust
if bouncer.name_any() != "soju-shared" {
let edge_route = build_edge_route(&bouncer);
apply_resource(&api_route, &edge_route).await?;
}
```
**Step 8: Run tests**
Run: `cargo test -p soju-operator`
Expected: All pass
**Step 9: Build and deploy operator**
```bash
tar czf /tmp/build.tar.gz --exclude='target' --exclude='.git' \
--exclude='irc-now-landing-page' --exclude='status' --exclude='design' \
--exclude='./docs' --exclude='notes' Cargo.toml Cargo.lock crates/
oc start-build soju-operator --from-archive=/tmp/build.tar.gz -n irc-josie-cloud --follow
```
**Step 10: Verify existing bouncers get gamja sidecar**
```bash
oc get pods -l app.kubernetes.io/managed-by=soju-operator -n irc-josie-cloud
# Each dedicated bouncer pod should show 2/2 containers
# soju-shared should show 1/1
```
**Step 11: Commit**
```bash
git add crates/soju-operator/
git commit -m "feat(soju-operator): add gamja sidecar to dedicated bouncers"
```
---
## Task 4: Validation webhook
New crate that rejects bouncer creation for free users and enforces quotas.
**Files:**
- Create: `crates/bouncer-webhook/Cargo.toml`
- Create: `crates/bouncer-webhook/src/main.rs`
- Create: `crates/bouncer-webhook/Containerfile`
- Create: `deploy/bouncer-webhook/` (deployment, service, webhook config)
- Modify: `Cargo.toml` (add workspace member)
- Test: inline tests
**Step 1: Add workspace member**
Add `"crates/bouncer-webhook"` to `[workspace].members` in root `Cargo.toml`.
**Step 2: Create Cargo.toml**
```toml
[package]
name = "bouncer-webhook"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = "1"
sqlx = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
k8s-openapi = { version = "0.27", features = ["latest"] }
kube = { version = "3", features = ["runtime", "client", "derive"] }
```
**Step 3: Write webhook handler**
The webhook receives `AdmissionReview` on POST `/validate`. It:
1. Extracts `irc.now/owner` label from the incoming CR
2. If owner is empty (admin-applied CR like soju-shared), ALLOW
3. Queries the accounts DB: `SELECT plan FROM users WHERE keycloak_sub = $1`
4. If plan != 'pro', DENY with message "upgrade to pro to create a dedicated bouncer"
5. Counts existing bouncers for this owner. If >= 1, DENY with "max 1 dedicated bouncer"
6. Otherwise, ALLOW
```rust
async fn validate(
State(state): State,
Json(review): Json>,
) -> Json> {
let req = review.request.unwrap();
let obj = req.object.unwrap();
let labels = obj.metadata.labels.unwrap_or_default();
let owner = match labels.get("irc.now/owner") {
Some(o) => o,
None => return allow(req.uid), // admin-applied, no owner label
};
let plan: Option = sqlx::query_scalar("SELECT plan FROM users WHERE keycloak_sub = $1")
.bind(owner)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
if plan.as_deref() != Some("pro") {
return deny(req.uid, "upgrade to pro to create a dedicated bouncer");
}
// Count existing bouncers for this owner
let bouncers: Api = Api::namespaced(state.kube.clone(), &state.namespace);
let lp = ListParams::default().labels(&format!("irc.now/owner={owner}"));
let count = bouncers.list(&lp).await.map(|l| l.items.len()).unwrap_or(0);
if count >= 1 {
return deny(req.uid, "max 1 dedicated bouncer per pro account");
}
allow(req.uid)
}
```
**Step 4: Write tests**
Test the three cases: no owner label (allow), free user (deny), pro user with 0
bouncers (allow), pro user with 1 bouncer (deny).
**Step 5: Run tests**
Run: `cargo test -p bouncer-webhook`
Expected: All pass
**Step 6: Create Containerfile**
Same multi-stage pattern as other crates: `rust:1.88` build -> `ubi9-minimal` runtime.
**Step 7: Create deploy manifests**
Create `deploy/bouncer-webhook/`:
- `deployment.yaml`: Deployment with DB and kube access
- `service.yaml`: ClusterIP on port 443 (webhook requires HTTPS)
- `webhook.yaml`: ValidatingWebhookConfiguration targeting SojuBouncer CREATE
The webhook needs a TLS cert. Use a cert-manager Certificate CR or a self-signed
cert in a Secret. The ValidatingWebhookConfiguration references the CA bundle.
**Step 8: Build and deploy**
```bash
oc new-build --binary --name=bouncer-webhook -n irc-josie-cloud
tar czf /tmp/build.tar.gz --exclude='target' --exclude='.git' \
--exclude='irc-now-landing-page' --exclude='status' --exclude='design' \
--exclude='./docs' --exclude='notes' Cargo.toml Cargo.lock crates/
oc start-build bouncer-webhook --from-archive=/tmp/build.tar.gz -n irc-josie-cloud --follow
oc apply -f deploy/bouncer-webhook/ -n irc-josie-cloud
```
**Step 9: Verify webhook blocks free user**
```bash
# Try creating a bouncer with a free user's owner label
cat < Result<(), MigrationError> { ... }
/// Copy a user's data from dedicated DB back to shared DB,
/// keeping only the specified network (free tier = 1 network).
pub async fn reverse_migrate_user(
source: &PgPool,
target: &PgPool,
username: &str,
keep_network: &str,
) -> Result<(), MigrationError> { ... }
/// Remove a user and all their data from a soju DB.
pub async fn remove_user(
pool: &PgPool,
username: &str,
) -> Result<(), MigrationError> { ... }
```
The exact table names and schemas come from soju's Postgres schema. Query the
soju-shared DB to discover the schema:
```bash
oc exec soju-db-1 -n irc-josie-cloud -- psql -U accounts -d soju_shared -c '\dt'
```
**Step 2: Write tests for migration**
Test that `migrate_user` copies all expected tables and `remove_user` cleans up.
Test that `reverse_migrate_user` only copies the selected network.
**Step 3: Run tests**
Run: `cargo test -p soju-operator test_migration`
Expected: PASS
**Step 4: Add migration trigger to bouncer create**
In `crates/web-api/src/routes/bouncer.rs`, extend `CreateForm` to include the
`migrate` checkbox:
```rust
#[derive(Deserialize)]
pub struct CreateForm {
pub name: String,
pub migrate: Option, // "true" if checked
}
```
After the bouncer CR is created and becomes Ready, if `migrate == Some("true")`:
1. Connect to the soju-shared tenant DB
2. Connect to the new bouncer's tenant DB
3. Call `migrate_user(shared_pool, new_pool, &user.email)`
4. Call `remove_user(shared_pool, &user.email)`
This runs synchronously in the create handler for now. A Kubernetes Job can be
added later if the migration takes too long.
**Step 5: Add downgrade migration to bouncer delete**
In the bouncer delete handler, if the user is downgrading (plan changing to free),
show a network picker form first. After selection, run `reverse_migrate_user()`.
**Step 6: Commit**
```bash
git add crates/soju-operator/src/migrate.rs crates/web-api/src/routes/bouncer.rs
git commit -m "feat: add bidirectional soju user migration"
```
---
## Task 6: Network limit enforcement
Enforce 1 upstream network limit for free-tier users on the shared bouncer.
**Files:**
- Modify: `crates/web-api/src/routes/bouncer.rs` (network management for shared users)
- Modify: `crates/web-api/templates/dashboard.html` (network UI for free users)
**Step 1: Write test for network limit**
Test that a free user on the shared bouncer cannot add a second upstream network.
**Step 2: Run test to verify it fails**
Run: `cargo test -p web-api test_network_limit_free`
Expected: FAIL
**Step 3: Add network count check**
When a free user attempts to add an upstream network via the portal, check the
current count. If already at 1, reject with a message to upgrade.
The portal manages networks for shared bouncer users by talking to soju's admin
socket or directly querying the shared bouncer's tenant DB:
```sql
SELECT COUNT(*) FROM "Network" WHERE "user" = (
SELECT id FROM "User" WHERE username = $1
)
```
If count >= 1, return 403 with "free tier is limited to 1 upstream network".
**Step 4: Run test to verify it passes**
Run: `cargo test -p web-api test_network_limit_free`
Expected: PASS
**Step 5: Update dashboard for free users**
Show the single connected network and an upgrade CTA instead of the "add network"
button.
**Step 6: Commit**
```bash
git add crates/web-api/
git commit -m "feat(web-api): enforce 1-network limit for free tier"
```
---
## Task 7: Integration testing and deployment
End-to-end verification of the full flow.
**Step 1: Build and deploy all components**
```bash
# Build web-api
tar czf /tmp/build.tar.gz --exclude='target' --exclude='.git' \
--exclude='irc-now-landing-page' --exclude='status' --exclude='design' \
--exclude='./docs' --exclude='notes' Cargo.toml Cargo.lock crates/
oc start-build web-api --from-archive=/tmp/build.tar.gz -n irc-josie-cloud --follow
# Build soju-operator
oc start-build soju-operator --from-archive=/tmp/build.tar.gz -n irc-josie-cloud --follow
# Build bouncer-webhook
oc start-build bouncer-webhook --from-archive=/tmp/build.tar.gz -n irc-josie-cloud --follow
```
**Step 2: Test free user flow**
1. Log in as a free user at my.irc.now
2. Dashboard shows "shared bouncer" section with chat.irc.now link
3. Navigate to /bouncers -- no create button, shows shared bouncer info
4. Open chat.irc.now -- connects to soju-shared, can join channels
5. Try to apply a SojuBouncer CR with the free user's owner label -- webhook denies
**Step 3: Test pro upgrade flow**
1. Upgrade to pro via Stripe (or manually set plan='pro' in DB)
2. Dashboard now shows bouncer management
3. Create a bouncer with "migrate from shared" checked
4. Verify dedicated bouncer pod has 2 containers (soju + gamja)
5. Verify edge Route at {name}.irc.now serves gamja
6. Verify user data migrated from shared to dedicated
**Step 4: Test pro downgrade flow**
1. Cancel subscription (or manually set plan='free')
2. Delete dedicated bouncer, select network to keep
3. Verify reverse migration to shared bouncer
4. Verify dedicated bouncer CR and resources cleaned up
5. Verify user can connect to chat.irc.now again
**Step 5: Commit any fixes**
```bash
git add -A
git commit -m "fix: integration test fixes for shared bouncer"
```