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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

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


AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15). Data as JSON: /api/errors/b37ee7f634a82dc0. Report an issue: GitHub.