# Shared Bouncer Architecture Design Date: 2026-03-07 ## Overview Free-tier users share a single soju bouncer instance (`soju-shared`). Pro-tier users get dedicated bouncer instances with a gamja web chat sidecar. A validation webhook enforces plan-based quotas. Migration between shared and dedicated is user-initiated and bidirectional. ## Architecture ``` free user pro user | | chat.irc.now apple.irc.now (443) (gamja + nginx) (gamja sidecar) | | WebSocket WebSocket | | soju-shared:8080 apple:8080 (multi-user bouncer) (dedicated bouncer) | | soju_shared DB soju_apple DB (all free user data) (single user data) ``` ### soju-shared A SojuBouncer CR deployed via `deploy/instances/soju-shared.yaml`. Serves all free-tier users with soju's built-in multi-user isolation (`enable-user-on-auth true`). Each user authenticates via SASL OAUTHBEARER and gets their own networks, channels, and message history in the shared tenant database. No Route -- only reachable internally via the chat deployment. ### Dedicated Bouncers Existing SojuBouncer CRs, one per pro user. The soju-operator adds a gamja sidecar container to the Deployment and creates an edge TLS Route at `{name}.irc.now:443` for web chat. The existing passthrough Route handles IRC on port 6697. Same hostname, different ports. ### chat.irc.now Unchanged. gamja + nginx proxying WebSocket to `soju-shared:8080`. Free-tier web chat entry point. ## Plan Tiers | Feature | Free ($0) | Pro ($4/mo) | |----------------------|---------------------|--------------------------| | Bouncer | Shared | Dedicated | | Upstream networks | 1 | Unlimited | | Scrollback | 48h | Unlimited | | Custom hostname | No | Yes | | Web chat | chat.irc.now | {name}.irc.now | | ErgoNetwork creation | No | Yes | ## Quota Enforcement ### Portal level (web-api) - `POST /bouncers`: rejected if `plan == "free"`. Free users use soju-shared automatically. - Dashboard UI: free users see "Shared Bouncer" section with a link to chat.irc.now and an upgrade CTA. Pro users see bouncer management with create/delete. - Network management for free users: portal checks network count before allowing addition. Limited to 1 upstream network. ### Validation webhook (bouncer-webhook crate) A new crate deployed as a Service, registered as a `ValidatingWebhookConfiguration` on `SojuBouncer` CREATE operations. The webhook: 1. Extracts the `irc.now/owner` label from the incoming CR 2. Looks up the user's plan from the accounts DB 3. Rejects creation if plan is `free` 4. For pro users, enforces max bouncer count (1 dedicated bouncer) This is the safety net -- even if someone bypasses the portal and applies a CR directly, the webhook blocks it. ### Soju-level enforcement Free-tier network limit (1 upstream network) enforced at both the portal level and via soju configuration where possible. The portal talks to soju's admin socket to manage networks for shared bouncer users. ## Gamja Sidecar (Dedicated Bouncers) When the soju-operator reconciles a dedicated bouncer (any SojuBouncer CR that is not `soju-shared`), it adds: ### Sidecar container - Image: same chat image (gamja + nginx) - nginx config generated by the operator, proxying `/socket` to `localhost:8080` (soju in the same pod) - Port 8081 for web traffic (avoids conflict with soju's 8080 WebSocket) - gamja `config.json` injected via ConfigMap with the bouncer's OAuth2 settings ### Routing - Passthrough Route: `{name}.irc.now:6697` for IRC (already exists) - Edge Route: `{name}.irc.now:443` for web chat -> gamja sidecar on 8081 - Same hostname, different ports, different TLS modes ## Migration ### Free to Pro (upgrade) User-initiated. Upgrading to pro unlocks the bouncer management UI but does not automatically create a dedicated bouncer. 1. User upgrades via Stripe. Portal shows bouncer management with "Create Bouncer". 2. User creates a bouncer. The create form has a checkbox: "Migrate data from shared bouncer". 3. If unchecked: dedicated bouncer created fresh. 4. If checked: dedicated bouncer created, then a Kubernetes Job migrates user data from `soju_shared` DB to the new tenant DB. Migration Job copies: - User record - Network configurations - Channel join list - Delivery receipts - WebPush subscriptions Message logs are NOT migrated (scrollback resets). The Job removes the user from `soju_shared` DB after successful copy. If the Job fails, the dedicated bouncer CR is deleted and the user stays on shared. Portal shows an error. ### Pro to Free (downgrade) On subscription cancellation or user-initiated bouncer deletion: 1. Portal shows "Migrate to Shared Bouncer" with a network picker if the user has more than 1 upstream network (free tier allows 1). 2. Reverse migration Job copies selected network + channels + receipts back to `soju_shared` DB, within free-tier quota limits. 3. Excess networks and their data are dropped. 4. Message logs not migrated (48h scrollback resets). 5. Dedicated bouncer CR is deleted (operator deprovisions tenant DB). ## Components to Build 1. **Deploy soju-shared** -- apply `deploy/instances/soju-shared.yaml`, verify chat.irc.now connects to it 2. **Soju-operator: gamja sidecar** -- add gamja container + edge Route generation for dedicated bouncers 3. **Web-api: plan gating** -- free users see shared bouncer UI, pro users see bouncer management. Block bouncer creation for free tier. 4. **Web-api: migration UI** -- "Migrate from shared" checkbox on create, "Migrate to shared" on delete/downgrade with network picker 5. **Migration Job** -- copies user data between soju tenant DBs, respects quotas on reverse migration 6. **Validation webhook (bouncer-webhook)** -- new crate, ValidatingWebhookConfiguration. Enforces: no bouncer creation for free, max bouncer count for pro, network limits. 7. **Network limit enforcement** -- portal + soju-level checks for free-tier 1-network limit