multica-ai/multica · error · Error
Invalid desktop runtime config JSON
Error message
Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : "parse failed"} What it means
HTTP 403 emitted by HandleWebSocket in server/internal/realtime/hub.go:793 during the WebSocket upgrade handshake. It means the caller presented a valid session cookie (authenticateToken succeeded), but the MembershipChecker reported the authenticated user has no membership row in the workspace identified by the workspace_id/workspace_slug query parameter. The production checker (server/cmd/server/router.go:1892 membershipChecker.IsMember) runs GetMemberByUserAndWorkspace and returns true only when the row lookup succeeds with no error, so both 'no such membership' and 'lookup failed' collapse into this same 403.
Solutions
- Confirm the membership actually exists: query the members table for (user_id, workspace_id) with GetMemberByUserAndWorkspace — if no row, re-invite the user or have them accept the pending invite, then reconnect.
- Check exactly what workspace_id the client is sending on the WS URL (server access logs or browser network tab). If it is 'undefined', empty-state garbage, or an id from a different workspace, fix the client to read the current workspace id at connect time.
- If the user just switched workspaces or was re-invited, force a full reload/refetch of the workspace list so the client stops reconnecting with the stale id.
- If the id looks valid and membership should exist, verify it is a real UUID and that the DB is healthy — a non-UUID id or a failing DB query both produce this same 403 via IsMember's err == nil check.
- Hardening (server-side): make membershipChecker.IsMember distinguish pgx.ErrNoRows (true 403) from other DB errors (return 500) and reject non-UUID workspace_id early, so genuine infrastructure failures are not misdiagnosed as authorization failures.
Example fix
// before (server/cmd/server/router.go) — DB errors and non-UUID ids are reported as 'not a member'
func (mc *membershipChecker) IsMember(ctx context.Context, userID, workspaceID string) bool {
_, err := mc.queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: parseUUID(userID),
WorkspaceID: parseUUID(workspaceID),
})
return err == nil
}
// after — only a confirmed missing row counts as 'not a member'
func (mc *membershipChecker) IsMember(ctx context.Context, userID, workspaceID string) bool {
uid, ok := parseUUIDChecked(userID)
wid, ok2 := parseUUIDChecked(workspaceID)
if !ok || !ok2 {
return false
}
_, err := mc.queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
UserID: uid,
WorkspaceID: wid,
})
if errors.Is(err, pgx.ErrNoRows) {
return false
}
if err != nil {
slog.Error("ws auth: membership lookup failed", "error", err)
return false
}
return true
} Defensive patterns
Strategy: validation
Validate before calling
// Before opening the WebSocket, verify the user can see this workspace.
// Assumes a REST endpoint listing the user's workspaces exists.
const res = await fetch(`/api/workspaces`, { credentials: 'include' });
if (!res.ok) throw new Error('cannot list workspaces');
const { workspaces } = await res.json();
const ws = workspaces.find((w) => w.id === workspaceId);
if (!ws) {
// Not a member (or stale id): do not attempt the WS connection.
redirect(`/workspaces/${workspaces[0]?.id ?? ''}`);
} Type guard
// TypeScript: refuse to build the WS URL unless the id is a plausible UUID
// drawn from the server-provided workspace list.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function isConnectableWorkspaceId(
id: string | undefined | null,
known: Array<{ id: string }>,
): id is string {
return typeof id === 'string' && UUID_RE.test(id) && known.some((w) => w.id === id);
} Try / catch
// The cookie-path 403 surfaces as a failed WS upgrade: the browser fires
// 'error' then 'close' with no HTTP status exposed. Treat any close during
// or shortly after handshake on a workspace-scoped socket as an access
// failure, stop retrying with the same workspace_id, and revalidate.
const socket = new WebSocket(url);
let authenticated = false;
socket.addEventListener('open', () => { /* send auth if token-based */ });
socket.addEventListener('message', (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'auth_ack') authenticated = true;
if (msg.error === 'not a member of this workspace') {
socket.close();
onWorkspaceAccessDenied(); // clear stale id, refetch workspaces, redirect
}
});
socket.addEventListener('close', () => {
if (!authenticated) onWorkspaceAccessDenied(); // handshake rejected (incl. HTTP 403)
}); Prevention
- Derive workspace_id from the server-returned workspace list at connect time instead of caching it in long-lived client state, so a revoked membership or workspace switch cannot leave a stale id in the WS URL.
- Validate workspace_id is a real UUID client-side before connecting; 'undefined'/'null' strings from JS state produce this same 403.
- On any WS handshake failure, refetch the workspace list and drop the stale workspace from local state before retrying — never blind-retry the same URL.
- When implementing the server-side MembershipChecker, distinguish pgx.ErrNoRows from other database errors so infrastructure failures return 500 rather than masquerading as 403.
- After accepting an invite or being re-added, force a full page reload (not just a WS reconnect) so cookie-scoped membership checks are re-evaluated.
When it happens
Trigger: Calling GET/WS on the realtime endpoint with a valid auth cookie plus a workspace_id the user is not a member of: (1) user was removed from the workspace (membership row deleted) but a browser tab still holds the old workspace id and auto-reconnects; (2) frontend sends a stale or wrong workspace_id (e.g. 'undefined'/'null' string from JS state, or an id from a different workspace after switching); (3) workspace_id is not a valid UUID, so parseUUID produces a zero/garbage value and GetMemberByUserAndWorkspace errors, which IsMember treats as not-a-member; (4) transient DB failure during the membership lookup returns err != nil and is misreported as a 403 instead of a 500. Note the non-cookie path (first-message token auth) hits the same check at hub.go:824 but delivers the error as a WS auth_error frame, not this HTTP response.
Common situations: Multi-workspace SaaS UIs where the WS client reconnects with a cached workspace id after the user switched workspaces or had their invite revoked; invitation accepted server-side but the client reconnects before the membership commit is visible; dev/staging environments with seeded users whose member rows were never created; frontend bugs where workspace_id is read from an uninitialized Zustand/store value; production DB blips making every WS auth appear as 'not a member'.
Related errors
- agent execution context requires MULTICA_TOKEN to be a…
- CSRF validation failed
- errMsg
- list projects
- list workspaces
AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15).
Data as JSON: /api/errors/b37ee7f634a82dc0.
Report an issue: GitHub.
Appendix: source
Thrown at apps/desktop/src/shared/runtime-config.ts:58
);
return {
schemaVersion: 1,
apiUrl,
wsUrl: env.wsUrl
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
: deriveWsUrl(apiUrl),
appUrl: env.appUrl
? normalizeHttpUrl(env.appUrl, "VITE_APP_URL")
: deriveDevAppUrl(apiUrl),
};
}
export function parseRuntimeConfig(raw: string): RuntimeConfig {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(
`Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : "parse failed"}`,
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Invalid desktop runtime config: expected a JSON object");
}
const obj = parsed as Record<string, unknown>;
if (obj.schemaVersion !== 1) {
throw new Error("Unsupported desktop runtime config schemaVersion: expected 1");
}
const apiUrl = requiredString(obj.apiUrl, "apiUrl");
const appUrl = optionalString(obj.appUrl, "appUrl");
const wsUrl = optionalString(obj.wsUrl, "wsUrl");
const normalizedApiUrl = normalizeHttpUrl(apiUrl, "apiUrl");View on GitHub (pinned to 2c0912b6ec)