Home › Docs › Architecture › 7. `rysh-server`

7. rysh-server — The Cloud Backend

rysh-server (~31K LOC Go) is the multi-tenant control plane. A single Go binary (cmd/rysh-server/main.go) bundles a Gin HTTP/WebSocket API, an embedded NATS server (the cross-machine messaging backbone), PostgreSQL via GORM, server-side agentic panes, Stripe billing, Firebase auth, and external channel integrations. Its defining job is multi-tenant isolation.


7.1 Startup & wiring (cmd/rysh-server/main.go)

graph TD
    Cfg["1. Load TOML config
(RYSH_SERVER_CONFIG)"] --> DB["2. Connect Postgres (GORM)
(schema migrated externally by golang-migrate)"] DB --> NATS["3. Start embedded NATS server
(+ JetStream, WS listener)"] NATS --> Svc["4. Construct services"] Svc --> Share["5. Wire share NATS handlers
+ stale-share cleanup goroutine"] Share --> Cond["6. Conditionally enable:
Firebase, WhatsApp, connections,
browser panes, chatbot panes"] Cond --> Router["7. api.NewRouter → router.Run(addr)"] Router --> Block["block on SIGTERM/SIGINT"]

Many features are optional and conditional: Firebase (if enabled), WhatsApp (if access token set), external connections and the Integration Catalog / Rysh Forge (if EncryptionKey set — both share that gate), browser panes (if Agent.APIKey set), chatbot panes (if chatbot enabled + agent key + browser-pane service). A second binary, cmd/rysh-e2e/main.go, is an end-to-end harness validating share registration, output forwarding, command routing/ack, and namespace isolation. (A small third main.go also exists at docs/cmd/geomtest/ — a standalone lipgloss/termenv TrueColor terminal-rendering test tool, not part of the server runtime.)

Configuration (internal/config/config.go)

TOML with env overrides (env always wins; Load always applies applyEnvOverrides even when the file is missing/unparseable). Sections: Server (default 0.0.0.0:8080), Database (DSN builder; default localhost:5432/rysh_server, user/password both rysh, sslmode disable), Auth (JWT secret change-me-in-production, access TTL 15m, refresh TTL 168h, bcrypt cost 12), Firebase, NATS (port 4222, WS 9222, MaxPayload 8388608 = 8MB for base64 screenshots, monitoring HTTP hardcoded to 8222), Stripe, Shares (workspace name default default, DisableSubjectACL escape hatch), Agent (browser-pane LLM; default model sharedprovider.DefaultClaudeModel — the engine's own default, currently claude-opus-4-8, referenced rather than copied so it cannot rot the way the previous hardcoded claude-opus-4-5 did; maxTokens 8192, maxIterations 20, PaneTTL 24h), WhatsApp, Chatbot (enabled true, default model claude-sonnet-4-6, maxTokens 4096, maxIterations 10, session timeout 30min, MaxMessageLength 10000), and a top-level EncryptionKey for connection secrets. Env keys follow RYSH_<SECTION>_<FIELD> (e.g. RYSH_AGENT_API_KEY, RYSH_NATS_WS_PORT, RYSH_ENCRYPTION_KEY).


7.2 Web framework & routing (internal/api/)

Gin (release mode). Global middleware: Recovery, RequestLogger, RateLimit (a real per-IP limiter — see §7.9), permissive CORS (AllowOrigins: *). The static React frontend is served via NoRoute → ./web/dist/index.html.

Authentication middleware

Middleware Validates Sets
JWTAuth HS256 Bearer token (or ?token= for WS upgrades) user_id, user_email
AdminOnly User.Role == "admin" (after JWTAuth) —
ChatbotKeyAuth rcb_… widget key (X-Chatbot-Key or ?key=) chatbot_config_id, workspace_id
ChatbotOriginCheck per-chatbot allowed origins (fail-closed) —
BrowserPaneAuth JWT or rysh_… server API key JWT branch: user_id, user_email; API-key branch: user_id, workspace_id (so workspace_id is set only on the API-key path)
graph TD
    Req["incoming request"] --> Group{route group}
    Group -->|public| Pub["/health, /api/server-info,
/api/auth/*, /api/billing/{plans,config,webhook}"] Group -->|JWTAuth| Prot["workspaces, members, invitations,
messages, keys, connections, chatbots,
panes, billing, shares"] Group -->|JWTAuth+AdminOnly| Admin["/api/admin/*"] Group -->|BrowserPaneAuth| Mixed["connection credentials,
shares list, browser-panes"] Group -->|ChatbotKey+Origin| Widget["/api/chatbot/* (widget)"] Group -->|API key| Res["/api/resource/* (daemon limits)"] Group -->|proxy| WS["/nats, /workspaces/:id/nats,
/ws/proxy, /ws/shares/:wsID/:shareID"]

See §7.8 for the full endpoint catalog.


7.3 Auth model

Four credential types coexist:

graph TD
    EP["email/password"] -->|bcrypt verify| JWT["issue HS256 JWT
+ rotating refresh token
(SHA-256 hash stored)"] FB["Firebase ID token"] -->|verify via Admin SDK| Find["find/create User
(by firebase_uid then email)
+ auto-provision default workspace"] Find --> JWT JWT --> Access["access protected API"] APIK["rysh_ API key
(SHA-256 hash stored)"] --> NATSauth["NATS proxy auth,
resource checks,
extension/CLI auth"] CBK["rcb_ chatbot key"] --> WidgetAuth["widget endpoints"]
  • Email/password → JWT: issueTokens parses AccessTokenTTL (fallback 15m) and RefreshTokenTTL (fallback 7×24h), signs an HS256 JWT (Issuer: "rysh-server", claims user_id+email), and stores the SHA-256 of a 32-byte (64-hex) refresh token. Refresh requires revoked=false AND expires_at>now, revokes the old token (rotation), and re-issues. Logout revokes all.
  • Firebase social (LoginWithIDToken): verifies the ID token, finds-or-creates the user by firebase_uid then email (linking provider google/github/email), auto-provisions a default workspace for new users, then issues the same rysh JWT+refresh pair.
  • Server API keys — format rysh_ + 32 alphanumerics (GenerateAPIKey); only KeyHash = SHA-256 and KeyPrefix = key[:9] are stored. Validate checks hash + revoked=false + expiry and updates last_used_at. PermissionsForRole: observer→"read", empty→"read,write", else the requested scope.
  • Chatbot keys — rcb_{slug}_{32-hex}, SHA-256 hashed (KeyPrefix = rcb_{slug}_{first8hex}).

All secrets are stored hashed/encrypted: passwords (bcrypt), tokens (SHA-256), connection secrets (AES-256-GCM). Never plaintext. util.Encrypt derives the AES-256 key as sha256.Sum256(passphrase), prepends the random GCM nonce, and base64-encodes; Decrypt errors "ciphertext too short" if shorter than the nonce.


7.4 Multi-tenant NATS isolation (the security boundary)

This is the most important server concept. The embedded NATS server is flat — no native accounts or permissions; all workspaces share one subject space. Isolation is enforced entirely at the WebSocket proxy by a streaming subject-ACL parser.

sequenceDiagram
    participant C as Client (CLI / extension)
    participant P as TransparentWSProxy
    participant ACL as subjectACL (per workspace)
    participant N as Internal NATS (ws://:9222)

    C->>P: WS upgrade + API key (+ optional :wsID)
    P->>P: validate key → workspaceID
    alt route is /workspaces/:wsID/nats and wsID ≠ key's workspace
        P-->>C: 403
    end
    P->>P: CheckSessionLimit (share conns exempt)
    P->>N: dial internal WS, pipe frames
    loop client → NATS frames
        C->>ACL: PUB/SUB/HPUB subject
        ACL->>ACL: parse NATS protocol; skip PUB payload bytes by length
        alt subject ∉ {ws.{workspaceID}., _INBOX.}
            ACL-->>P: violation → close connection
        else allowed
            ACL->>N: forward
        end
    end

subjectACL (internal/nats/subject_acl.go) is a streaming NATS-protocol parser scoped to one workspace. The allowed prefixes are exactly ws.{workspaceID}. and _INBOX.:

func newSubjectACL(workspaceID string) *subjectACL {
    return &subjectACL{prefixes: []string{"ws." + workspaceID + ".", "_INBOX."}}
}

The clever part is inspect — it skips PUB/HPUB payload bytes by their declared count (a.remaining), so a payload that happens to contain "SUB ..." or CRLFs can never be mis-parsed as a protocol command:

func (a *subjectACL) inspect(data []byte) (reason string) {
    a.buf = append(a.buf, data...)
    for {
        if a.remaining > 0 {                       // mid-payload: skip opaque bytes
            if a.remaining >= len(a.buf) { a.remaining -= len(a.buf); a.buf = a.buf[:0]; return "" }
            a.buf = a.buf[a.remaining:]; a.remaining = 0
        }
        idx := bytes.Index(a.buf, []byte("\r\n"))
        if idx < 0 { return "" }                   // incomplete command; wait for more bytes
        line := string(a.buf[:idx]); a.buf = a.buf[idx+2:]
        if r := a.checkLine(line); r != "" { return r }
    }
}

checkLine parses PUB <subject> [reply] <#bytes> / HPUB <subject> [reply] <#hdr> <#total> — arming a.remaining = byteCount + 2 (payload + CRLF) and checking fields[1] — and SUB <subject> [queue] <sid>. A disallowed subject returns e.g. publish to %q denied (outside workspace namespace) and the proxy closes the connection. Tests prove: own-workspace + _INBOX allowed; foreign ws.{other}.… blocked; global wildcards (>, ws.>, *) blocked; payloads with protocol-looking bytes are not mis-parsed. The only escape hatch is [shares] disable_subject_acl.

The ACL only inspects the client→NATS direction; NATS→client is unrestricted. Share connections (?connection_type=share) are exempt from the session-limit check.

The proxies

Proxy File Role
TransparentWSProxy ws_transparent_proxy.go primary client path; raw NATS frames + subject-ACL
JSON-envelope proxy service/nats_proxy_service.go, ws_proxy.go {subject,data} JSON for the web dashboard; validates share-command access
Browser-pane proxy browser_pane_proxy.go trusted; explicit allow-list of pane subjects; the Chrome extension speaks this
Chatbot-pane proxy chatbot_pane_proxy.go translating; the untrusted widget speaks a closed {type,payload} protocol and never sees raw subjects

TransparentWSProxy.Handle keepalive constants: proxyPingInterval = 30s, proxyPongTimeout = 90s, proxyWriteWait = 10s; it disables Nagle on both sockets for low-latency keystrokes and runs three goroutines (client→NATS with ACL, NATS→client unrestricted, a ping ticker). The browser-pane proxy allow-lists exactly these subjects under rysh.pane.{paneID}:

subscribe: .llm_prompt_execution.output, .llm_prompt_execution.status, .approval.request,
           .output.shell, .output.rysh, .output.chat, .share.command.inbound, .browser.request
publish:   .llm_prompt_execution.inbox, .approval.response, .browser.response

The chatbot-pane proxy maps the widget's closed protocol: inbound chatbot.visitor_message{content} → HandleVisitorMessage; outbound rysh.pane.{id}.output.chat → chatbot.message{id,role,content} (skipping role=="visitor"); …llm_prompt_execution.status phase done|error|complete|idle|cancelled → chatbot.typing_stop, any other phase → chatbot.typing.

AccountManager and AuthCallout exist as scaffolding for per-workspace NATS accounts but are not wired into the embedded server in this build — the subject-ACL is the real isolation.


7.5 Server-side agentic panes (internal/agentic/)

BrowserPaneService is the shared pane factory. It owns a protoactor system and a rysh-shared agentic provider, and per-pane spawns NewLLMPromptExecutionActor with a curated tool registry:

Mode Tools Used by
PaneToolsBrowser web_search (if Brave key) + web_fetch + page_context + browser_action + list_tools Chrome extension panes
PaneToolsNone empty (text-only) chatbot panes (untrusted third-party sites)

SharePane bridges a pane's agentic output to an upstream workspace share subject so terminal users can ##upstream subscribe <shareID>; inbound commands are forwarded to the extension. Browser-pane shares are in-memory only.

ChatbotPaneService (chatbot_pane_service.go) layers chatbot concerns on the factory: spawns a PaneToolsNone pane with ChatOutputToPane, auto-registers a SharedEntity (origin chatbot, mode view) so operators can observe, persists a session row, and subscribes rysh.pane.{id}.output.chat as the single persistence path. A messageBatcher flushes to AppendMessageBatch when the buffer hits 10 messages or every 500ms. Auto-greet persists the welcome message as seq 0 without publishing (the widget renders it from the HTTP response). It supports human takeover/release (suppresses the LLM while a human replies, posting a system notice), enforces daily quotas, and runs an idle-session reaper every 5 min (closes sessions idle beyond SessionTimeoutMin, default 30, reason idle_timeout).

graph TD
    Ext["Chrome extension"] -->|POST /api/browser-panes| BPS["BrowserPaneService.CreatePaneWithConfig
(PaneToolsBrowser)"] Widget["website widget"] -->|POST /api/chatbot/sessions| CPS["ChatbotPaneService.CreateSession
(PaneToolsNone)"] BPS --> LLM1["LLMPromptExecutionActor"] CPS --> LLM2["LLMPromptExecutionActor"] LLM1 --> Claude["Claude"] LLM2 --> Claude CPS --> Persist["chatbot_sessions / chatbot_messages"]

7.6 Chatbot (embeddable widget)

ChatbotConfig holds the widget config: name, unique slug, hashed rcb_ key, system prompt, welcome message, model/tokens/iterations, theme JSON, AllowedOrigins (fail-closed origin check), rate limit, flags. Related: ChatbotSession (one pane + optional share per visitor), ChatbotMessage (append-only, seq-numbered transcript), ChatbotDailyUsage (per-workspace daily quota).

  • Management (owner, JWT): CRUD chatbots, rotate key, embed snippet (<script> pointing at /chatbot/widget.js), list sessions, operator takeover/release/reply/close, messages, usage.
  • Widget (chatbot-key + origin): create session, session WebSocket, history, close. widget.js is served statically — built by Vite from the React/TS sources in web/widget/src/ (ChatWidget.tsx, ws.ts, types.ts, …).

Caveat — the widget path is hardcoded, and two config fields are dead. The router constructs the widget handler with a hardcoded /opt/rysh/chatbot/widget.js; the config fields Chatbot.WidgetJSPath (default /opt/rysh/chatbot/widget.js, env RYSH_CHATBOT_WIDGET_PATH) and Chatbot.CDNURL exist but are never read by the server.


7.7 Subscriptions / billing (Stripe)

SubscriptionPlan defines limits (MaxWorkspaces/Sessions/Panes/Connections/Chatbots/ChatbotSessions/ChatbotMessagesPerDay/ChatbotHistoryDays; 0 = unlimited) and Stripe price IDs. Subscription ties a user to a plan with Stripe IDs and status. SubscriptionService lazily creates a free-tier subscription, checks workspace/session/resource limits, and integrates Stripe checkout/portal/cancel/resume. The Stripe webhook (signature-verified) handles checkout.session.completed, customer.subscription.updated/deleted, invoice.paid, invoice.payment_failed. The rysh daemon enforces limits pre-creation via /api/resource/*.


7.8 Sharing, channels, admin

  • Sharing (share_service.go): SharedEntity = a shared tab/lane/pane_group/pane (unique share_id, mode view/control, origin terminal/chatbot, LastHeartbeatAt). ShareSubscriber joins users to shares. A NATS-driven control plane (SetupNATSHandlers subscribes wildcard ws.*.share.{register,unregister,heartbeat} — identity comes from the payload/subject token, not the wildcard) lets CLI clients register over NATS; Register upserts on reconnect, and Subscribe publishes a ws.{id}.share.{shareID}.subscriber event so the source replays state. StartStaleShareCleanup(60s, 2min) marks shares disconnected after a 2-minute heartbeat gap (clients heartbeat every 30s). ValidateCommandAccess requires control mode + active + subscriber. Share output streams over WebSocket (incl. a public ?token= variant).
    • Remote file browse (fs/*) (fs_handler.go): a read-only file-browser relay for shared panes / the mobile app. fsHandler exposes fs.list/fs.read/fs.stat as both JWT-protected routes (GET …/shares/:shareID/fs/{list,read,stat}) and public ?token= routes (GET /ws/shares/:wsID/:shareID/fs/{list,read,stat}). The server holds no local filesystem access: it only authorizes the request and relays it over NATS request/reply to the share source, which answers. A NATSMaxPayload cap guards oversized reads.
  • External connections (internal/channel/, connection_service.go): ExternalConnection stores per-workspace integrations with EncryptedData (AES-256-GCM, key = SHA-256 of EncryptionKey). A Registry maps connection type → webhook handler + servicer (Slack signature-validated; WhatsApp X-Hub-Signature-256-validated). A generic dispatcher publishes inbound messages to rysh.channel.{type}.inbound.{wsID}. The Credentials endpoint returns decrypted secrets to trusted CLIs so humanoid adapters can connect.
  • Integration Catalog / Rysh Forge (internal/service/integration.go, internal/model/integration.go, internal/api/integration_handler.go): the server-side registry of external APIs catalogued per workspace for the rysh forge CLI feature (which turns API specs into agent tools — see doc 06). Four entities: Integration (one row per API per workspace, unique (workspace_id, slug), status draft → generated → published, source_type ∈ openapi|graphql|grpc), IntegrationVersion (immutable spec snapshots — raw bytes inline in a BYTEA spec_blob, SHA-256 checksum, auto-incremented version), GeneratedArtifact (client-produced go-sdk|ts-sdk|py-sdk|mcp-stdio|mcp-http|rysh-toolpack|docs, bytes inline in blob, unique (version, target); registering the first one flips the integration draft → generated), and IntegrationCredential (secret_ref = AES-256-GCM+base64 via util.Encrypt, the same scheme and DeriveKey(EncryptionKey) as ExternalConnection; json:"-", never returned). Generation runs client-side in the CLI — the server only persists and serves. The …/mcp endpoint returns hosted-MCP endpoint metadata (preferring an mcp-http artifact over mcp-stdio) so a CLI can auto-configure. Gated on EncryptionKey (disabled when empty). Blob storage is inline BYTEA for the MVP; *_blob_ref columns are forward-compat pointers for a future object-store migration.
  • Channel relay (channel_relay_service.go, channel_durability.go, channel_webhook_handler.go): the server half of WhatsApp relay mode (§6.7). The webhook dispatcher publishes inbound platform traffic to ws.{wsID}.{channelType}.{connectionID}.inbound; this service subscribes the reverse direction, …outbound, and delivers each message using the per-workspace connection's stored credentials. The point is what the local session then does not need: it never holds the platform access token and never needs to be reachable from the public internet. Inbound traffic is backed by a JetStream stream (channelInboundStream) so it survives a disconnected session.
  • Admin (admin_service.go): admin role is set only by direct DB update. Paginated platform-wide queries (users, workspaces, shares, subscriptions, API keys, audit logs), stats, destructive deletes, Stripe plan-price management.

Usage, quotas and prompt budgets

Three separate meters, easy to confuse:

Meter Where Purpose
ChatbotDailyUsage per workspace per day the chatbot message quota enforced before a pane replies
InvokeOpUsage per user per day forged-API operation invocations, upserted op_count += 1 per invoke, read to enforce the plan's max_invoke_ops_per_day
GET /api/workspaces/:wsID/usage?from=&to= aggregate over a period the AI-message consumption feed a reseller (letchat) bills overages from; owner-scoped, also reachable with a workspace-scoped key. Defaults to the current UTC calendar month

BudgetAugmentations (prompt_budget.go) is a different kind of budget — characters, not money. It assembles a prompt from a mandatory base plus ordered augmentation blocks (knowledge, canned answers, agent role, memory), and its two guarantees are what make it safe: the base is always kept verbatim even if it alone exceeds the cap, so an operator-authored system prompt is never silently truncated; and once a block would overflow, that block and every block after it are dropped — callers pass highest-priority first, so what falls off the end is always the least important.

Message history: the three storage tiers (FS-8.1)

Chatbot plans sell a history window (chatbot_history_days). The naive reading of that is "delete anything older", and that is what retention (F11) originally did — which made the window a data-loss boundary rather than a pricing one. FS-8.1 turned it into a tiering boundary instead: history past the window leaves the live table but is never lost, so per-chatbot history is effectively unlimited and stays searchable.

graph LR
    Hot["live
chatbot_messages
(monthly partitions)"] Arch["hot archive
chatbot_messages_archive
(Postgres, searchable)"] Cold["cold
object store
chatbot/{configID}/{YYYY-MM}.json"] Hot -->|"retention sweep, daily
past chatbot_history_days"| Arch Arch -->|"offload sweep, daily
older than OffloadAfterDays (90)"| Cold Arch -.->|SearchArchive| Q(["operator search"]) Cold -.->|SearchColdStorage| Q
Tier Store Written by Searched by
live chatbot_messages, monthly partitions the pane's persistence path normal session queries
hot archive chatbot_messages_archive (migration 000041) ArchiveMessagesOlderThan SearchArchive (SQL LIKE, escaped)
cold any storage.ObjectStore (S3 / MinIO / GCS-S3) RunColdStorageOffload SearchColdStorage (list → fetch → filter)

Retention archives before it drops. RunRetentionCleanup copies expiring rows into the archive tier first, and only then drops whole monthly partitions once they are older than every workspace's window. The ordering is the whole safety property: the partition drop is cheap and irreversible, and it is only safe because the rows in it already live in the cold tier.

The archive row is denormalized on purpose. chatbot_messages carries only SessionID, so ChatbotMessageArchive adds ChatbotConfigID at archive time — otherwise archived history would stop being searchable per-chatbot the moment the originating session row was deleted. It also stamps ArchivedAt.

Cold storage batches by month, deterministically. Archived rows group into one object per (config, period) where period is the created_at YYYY-MM in UTC, keyed chatbot/{configID}/{YYYY-MM}.json. The key is deterministic so a re-offload of the same period overwrites rather than duplicating, and the chatbot/{configID}/ prefix is what scopes a ListObjects search to a single chatbot. Durably-written rows are then pruned from the hot table, so Postgres stays bounded while history does not.

Scheduling and failure posture. StartColdStorageOffload runs once 5 minutes after startup and daily thereafter. A single config's failure is logged and skipped so one bad chatbot never stalls the pass. With no object store configured the scheduler never starts and RunColdStorageOffload returns (0, nil) without touching the DB — the cold tier is a graceful no-op, not a required dependency.

Configuration is [cold_storage] (env RYSH_COLDSTORE_*): enabled, bucket, region (default us-east-1), optional prefix so one bucket can be shared, endpoint + use_path_style for MinIO/GCS, offload_after_days (default 90), and optional static access_key/secret_key — left empty, the AWS default credential chain (environment, shared config, instance role, IRSA) is used, which is the preferred no-static-secrets path.

Testability shape worth copying. All store-facing logic is built from pure helpers — batching, key building, serialize/deserialize, substring filtering — that touch neither the DB nor the store, with only a thin glue layer calling them. The store half is tested against storage.InMemoryObjectStore; only the DB half needs a Postgres integration run (this repo has no sqlite driver). S3ObjectStore therefore swaps in as a wiring change, not a logic change.

Not documented here. The chatbot data plane — analytics, knowledge ingestion (connector / website crawl / URL), feedback, visitor events and memory — is documented as shipped status in chapter 12, which is the authority for that surface. Still undocumented anywhere: the marketing/ops surfaces that share the binary — blog (blog_*.go), the design-partner application flow (design_partner_*.go) and email campaigns (email_campaign_*.go).


7.9 Data model (ER)

All entities use UUID PKs (except SubscriptionPlan = string id, ChatbotDailyUsage = composite key).

erDiagram
    User ||--o{ Workspace : owns
    User ||--o{ RefreshToken : has
    User ||--o{ Subscription : has
    Subscription }o--|| SubscriptionPlan : on
    User ||--o{ UserMessage : "inbox"
    User }o--o{ Workspace : "member (WorkspaceMember/role)"
    Workspace ||--o{ APIKey : has
    Workspace ||--o{ WorkspaceInvitation : has
    Workspace ||--o{ ExternalConnection : has
    Workspace ||--o{ SharedEntity : has
    SharedEntity ||--o{ ShareSubscriber : "subscribed by"
    ShareSubscriber }o--|| User : "is"
    Workspace ||--o{ ChatbotConfig : has
    ChatbotConfig ||--o{ ChatbotSession : has
    ChatbotSession ||--o{ ChatbotMessage : transcript
    Workspace ||--o{ ChatbotDailyUsage : "daily quota"
    Workspace ||--o{ Integration : "forge catalog"
    Integration ||--o{ IntegrationVersion : "spec snapshots"
    Integration ||--o{ IntegrationCredential : "encrypted auth"
    IntegrationVersion ||--o{ GeneratedArtifact : "forge output"
    Integration }o--|| User : "created_by"
    AuditLog }o--|| User : references
    AuditLog }o--|| Workspace : references

The "unique per owner" rule is a GORM composite index — note the wire/REST identifier is always the UUID:

type Workspace struct {
    ID      string `gorm:"type:uuid;primaryKey" json:"id"`
    OwnerID string `gorm:"type:uuid;not null;index;uniqueIndex:idx_workspaces_owner_name,priority:1" json:"owner_id"`
    Name    string `gorm:"not null;uniqueIndex:idx_workspaces_owner_name,priority:2" json:"name"`
    State   string `gorm:"default:'active'" json:"state"`        // active|suspended|deleted
    NATSAccount string `json:"nats_account,omitempty"`             // "ws-"+ID (scaffolding)
    // Owner User; Members []WorkspaceMember; APIKeys []APIKey; ExternalConnections []ExternalConnection
}

Key facts: workspace name is unique per owner (not globally), so the UUID is the canonical identifier used in all NATS subjects (ws.{id}.…) and REST paths. Creating a workspace also creates an owner WorkspaceMember and an initial API key, and sets NATSAccount = "ws-"+ID. WorkspaceMember roles are owner/admin/member/observer with capability helpers (CanManageMembers = owner||admin, CanAttach = +member, CanView = any). Most models implement a BeforeCreate assigning a UUID; ChatbotMessage is append-only (no UpdatedAt, DB-side gen_random_uuid() default) and ChatbotConfig.IsOriginAllowed is fail-closed (empty/malformed allowlist ⇒ no origin permitted). SubscriptionPlan uses string IDs (free/enterprise) and 0-means-unlimited limits. Invitations store only the token hash and are delivered through the in-dashboard UserMessage box.

The GORM AutoMigrate in migrate.go is incomplete and uncalled — schema is migrated externally by golang-migrate.


7.10 REST endpoint catalog

Public: GET /health · GET /api/server-info · POST /api/auth/{register,login,refresh} · GET /api/auth/social/status · POST /api/auth/social/firebase · GET /api/billing/{plans,config} · POST /api/billing/webhook · GET|POST /api/webhook/whatsapp · GET|POST /api/webhook/{type}/:connectionID.

Protected (JWT):

  • GET /api/auth/me · POST /api/auth/logout
  • Workspaces: POST|GET /api/workspaces · GET|PATCH|DELETE /api/workspaces/:wsID · GET …/status
  • Members: GET|POST …/members · PATCH|DELETE …/members/:userID
  • Invitations: POST|GET …/invitations · DELETE …/invitations/:id · GET /api/invitations/:token · POST /api/invitations/:token/accept
  • Messages: GET /api/messages · POST /api/messages/:id/read
  • API keys: POST|GET …/keys · DELETE …/keys/:id · POST …/keys/:id/rotate
  • Connections: GET …/connections/types · GET|POST …/connections · GET|PATCH|DELETE …/connections/:connID · POST …/connections/:connID/test
  • Integrations (Rysh Forge; mutations = owner/admin, reads = member; present only when EncryptionKey set — JWT only, no API-key path): POST|GET …/integrations · GET …/integrations/:id · POST …/integrations/:id/versions · POST …/integrations/:id/artifacts · GET …/integrations/:id/artifacts/:target (streams blob) · POST …/integrations/:id/credentials (secret encrypted at rest) · GET …/integrations/:id/mcp (MCP endpoint metadata)
  • Chatbots: GET …/chatbots/usage · GET|POST …/chatbots · GET|PATCH|DELETE …/chatbots/:id · POST …/chatbots/:id/rotate-key · GET …/chatbots/:id/embed · GET …/chatbots/:id/sessions · POST …/sessions/:sid/{takeover,release,reply,close} · GET …/sessions/:sid/messages
  • Panes: GET …/panes · GET …/panes/:paneID/ws · GET …/panes/:paneID/buffer
  • Billing: GET /api/billing/{subscription,limits} · POST /api/billing/{checkout,portal,cancel,resume}
  • Shares: GET …/shares · GET …/shares/:shareID · GET …/shares/:shareID/ws · GET …/shares/:shareID/buffer · POST|DELETE …/shares/:shareID/subscribe · POST …/shares/:shareID/command · GET …/shares/:shareID/fs/{list,read,stat} (remote file browse, relayed to source over NATS)

Admin (JWT+admin): GET /api/admin/stats · GET /api/admin/users · DELETE …/users/:userID · GET /api/admin/workspaces · DELETE …/workspaces/:workspaceID · GET /api/admin/{shares,subscriptions,api-keys,audit-logs,plans} · PATCH /api/admin/plans/:planID/prices · GET /api/admin/stripe/status.

Mixed auth (JWT or API key): GET /api/workspaces/:wsID/connections/by-type/:type/credentials · GET /api/workspaces/:wsID/shares/list · POST /api/browser-panes · DELETE …/:id · GET …/:id/history · GET …/:id/ws · POST …/:id/context · POST …/:id/share.

Chatbot widget (key + origin): POST /api/chatbot/sessions · GET …/sessions/:id/ws · GET …/sessions/:id/history · POST …/sessions/:id/close · GET /chatbot/widget.js.

Resource (API key): GET /api/resource/limits · POST /api/resource/check-limits · GET /api/resource/check-session-limit.

NATS/WS: GET /nats · GET /workspaces/:wsID/nats (transparent proxy) · GET /ws/proxy (JSON) · GET /ws/shares/:wsID/:shareID (public, ?token=) · GET /ws/shares/:wsID/:shareID/fs/{list,read,stat} (public file browse, ?token=).


7.11 Caveats

  • Isolation depends entirely on the subject-ACL — the embedded NATS server is flat; AccountManager/AuthCallout are unwired scaffolding.
  • RateLimit() is no longer a placeholder: when cfg.Enabled it builds a service.NewInvokeRateLimiter over a per-minute window (default 600 req/min per IP when RequestsPerMin ≤ 0) and evicts idle windows on a 1-minute ticker so the per-IP map cannot grow without bound. Disabled (Enabled == false) it still short-circuits to a pass-through, which is the shape the old placeholder had.
  • DB schema is migrated by golang-migrate, not the incomplete AutoMigrate.
  • Many subsystems are conditional on config (Firebase, WhatsApp, connections, browser/chatbot panes).
  • The chatbot widget.js path is hardcoded to /opt/rysh/chatbot/widget.js; the Chatbot.WidgetJSPath and Chatbot.CDNURL config fields are present but unwired.
  • The remote file-browse (fs/*) endpoints hold no local FS access on the server — every request is relayed over NATS to the share source, which performs the read.
  • The Integration Catalog is storage-only (the CLI generates artifacts; the server never runs the generator), has no max_integrations limit enforced yet (a TODO(limits) in CreateIntegration), and never sets the published status (only the draft→generated auto-transition exists). The hosted-MCP …/mcp route returns metadata only — the server does not host or proxy the MCP endpoint. Schema comes from migration 000023; the version/artifact unique constraints live in that SQL (not in GORM tags), and AutoMigrate (still uncalled) gained the four models.