HomeDocsArchitecture › 7. `rysh-server`

7. rysh-server — The Cloud Backend

rysh-server (~27K 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 claude-opus-4-5, maxTokens 8192, maxIterations 20, PaneTTL 24h), WhatsApp, Chatbot (enabled true, default model claude-sonnet-4-20250514, 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 no-op placeholder), 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 keysrcb_{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.chatchatbot.message{id,role,content} (skipping role=="visitor"); …llm_prompt_execution.status phase done|error|complete|idle|cancelledchatbot.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.
  • 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.

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() middleware is a no-op placeholder.
  • 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.