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:
issueTokensparsesAccessTokenTTL(fallback15m) andRefreshTokenTTL(fallback7×24h), signs an HS256 JWT (Issuer: "rysh-server", claimsuser_id+email), and stores the SHA-256 of a 32-byte (64-hex) refresh token.Refreshrequiresrevoked=false AND expires_at>now, revokes the old token (rotation), and re-issues.Logoutrevokes all. - Firebase social (
LoginWithIDToken): verifies the ID token, finds-or-creates the user byfirebase_uidthenemail(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); onlyKeyHash = SHA-256andKeyPrefix = key[:9]are stored.Validatechecks hash +revoked=false+ expiry and updateslast_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.
AccountManagerandAuthCalloutexist 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.jsis served statically — built by Vite from the React/TS sources inweb/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 fieldsChatbot.WidgetJSPath(default/opt/rysh/chatbot/widget.js, envRYSH_CHATBOT_WIDGET_PATH) andChatbot.CDNURLexist 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 (uniqueshare_id, mode view/control, origin terminal/chatbot,LastHeartbeatAt).ShareSubscriberjoins users to shares. A NATS-driven control plane (SetupNATSHandlerssubscribes wildcardws.*.share.{register,unregister,heartbeat}— identity comes from the payload/subject token, not the wildcard) lets CLI clients register over NATS;Registerupserts on reconnect, andSubscribepublishes aws.{id}.share.{shareID}.subscriberevent so the source replays state.StartStaleShareCleanup(60s, 2min)marks shares disconnected after a 2-minute heartbeat gap (clients heartbeat every 30s).ValidateCommandAccessrequires 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.fsHandlerexposesfs.list/fs.read/fs.statas 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. ANATSMaxPayloadcap guards oversized reads.
- Remote file browse (
- External connections (
internal/channel/,connection_service.go):ExternalConnectionstores per-workspace integrations withEncryptedData(AES-256-GCM, key = SHA-256 ofEncryptionKey). ARegistrymaps connection type → webhook handler + servicer (Slack signature-validated; WhatsAppX-Hub-Signature-256-validated). A generic dispatcher publishes inbound messages torysh.channel.{type}.inbound.{wsID}. TheCredentialsendpoint 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 therysh forgeCLI feature (which turns API specs into agent tools — see doc 06). Four entities:Integration(one row per API per workspace, unique(workspace_id, slug), statusdraft → generated → published,source_type ∈ openapi|graphql|grpc),IntegrationVersion(immutable spec snapshots — raw bytes inline in aBYTEAspec_blob, SHA-256checksum, auto-incrementedversion),GeneratedArtifact(client-producedgo-sdk|ts-sdk|py-sdk|mcp-stdio|mcp-http|rysh-toolpack|docs, bytes inline inblob, unique(version, target); registering the first one flips the integrationdraft → generated), andIntegrationCredential(secret_ref= AES-256-GCM+base64 viautil.Encrypt, the same scheme andDeriveKey(EncryptionKey)asExternalConnection;json:"-", never returned). Generation runs client-side in the CLI — the server only persists and serves. The…/mcpendpoint returns hosted-MCP endpoint metadata (preferring anmcp-httpartifact overmcp-stdio) so a CLI can auto-configure. Gated onEncryptionKey(disabled when empty). Blob storage is inlineBYTEAfor the MVP;*_blob_refcolumns 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 tows.{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).S3ObjectStoretherefore 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
AutoMigrateinmigrate.gois 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
EncryptionKeyset — 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/AuthCalloutare unwired scaffolding. RateLimit()is no longer a placeholder: whencfg.Enabledit builds aservice.NewInvokeRateLimiterover a per-minute window (default 600 req/min per IP whenRequestsPerMin≤ 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.jspath is hardcoded to/opt/rysh/chatbot/widget.js; theChatbot.WidgetJSPathandChatbot.CDNURLconfig 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_integrationslimit enforced yet (aTODO(limits)inCreateIntegration), and never sets thepublishedstatus (only thedraft→generatedauto-transition exists). The hosted-MCP…/mcproute returns metadata only — the server does not host or proxy the MCP endpoint. Schema comes from migration000023; the version/artifact unique constraints live in that SQL (not in GORM tags), andAutoMigrate(still uncalled) gained the four models.