multica-ai/multica · error · Error
Invalid desktop runtime config JSON: ${err instanceof Error
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.
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)
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
- redeemer is not a workspace member
- slack: redeemer is not a workspace member
- vcs: token unauthorized
- wecom: redeemer is not a workspace member
- agent execution context requires MULTICA_TOKEN to be a task-
AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15).
Data as JSON: /api/errors/b37ee7f634a82dc0.
Report an issue: GitHub.