{"record":{"id":"b37ee7f634a82dc0","repo":"multica-ai/multica","slug":"invalid-desktop-runtime-config-json-err-instanc","errorCode":null,"errorMessage":"Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : \"parse failed\"}","messagePattern":"Invalid desktop runtime config JSON: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/desktop/src/shared/runtime-config.ts","lineNumber":58,"sourceCode":"  );\n  return {\n    schemaVersion: 1,\n    apiUrl,\n    wsUrl: env.wsUrl\n      ? normalizeWsUrl(env.wsUrl, \"VITE_WS_URL\")\n      : deriveWsUrl(apiUrl),\n    appUrl: env.appUrl\n      ? normalizeHttpUrl(env.appUrl, \"VITE_APP_URL\")\n      : deriveDevAppUrl(apiUrl),\n  };\n}\n\nexport function parseRuntimeConfig(raw: string): RuntimeConfig {\n  let parsed: unknown;\n  try {\n    parsed = JSON.parse(raw);\n  } catch (err) {\n    throw new Error(\n      `Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : \"parse failed\"}`,\n    );\n  }\n\n  if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n    throw new Error(\"Invalid desktop runtime config: expected a JSON object\");\n  }\n\n  const obj = parsed as Record<string, unknown>;\n  if (obj.schemaVersion !== 1) {\n    throw new Error(\"Unsupported desktop runtime config schemaVersion: expected 1\");\n  }\n\n  const apiUrl = requiredString(obj.apiUrl, \"apiUrl\");\n  const appUrl = optionalString(obj.appUrl, \"appUrl\");\n  const wsUrl = optionalString(obj.wsUrl, \"wsUrl\");\n\n  const normalizedApiUrl = normalizeHttpUrl(apiUrl, \"apiUrl\");","sourceCodeStart":40,"sourceCodeEnd":76,"githubUrl":"https://github.com/multica-ai/multica/blob/2c0912b6ec764b373d44eeea1e80f0d9f11ab417/apps/desktop/src/shared/runtime-config.ts#L40-L76","documentation":"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.","triggerScenarios":"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.","commonSituations":"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'.","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."],"exampleFix":"// before (server/cmd/server/router.go) — DB errors and non-UUID ids are reported as 'not a member'\nfunc (mc *membershipChecker) IsMember(ctx context.Context, userID, workspaceID string) bool {\n\t_, err := mc.queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{\n\t\tUserID:      parseUUID(userID),\n\t\tWorkspaceID: parseUUID(workspaceID),\n\t})\n\treturn err == nil\n}\n\n// after — only a confirmed missing row counts as 'not a member'\nfunc (mc *membershipChecker) IsMember(ctx context.Context, userID, workspaceID string) bool {\n\tuid, ok := parseUUIDChecked(userID)\n\twid, ok2 := parseUUIDChecked(workspaceID)\n\tif !ok || !ok2 {\n\t\treturn false\n\t}\n\t_, err := mc.queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{\n\t\tUserID:      uid,\n\t\tWorkspaceID: wid,\n\t})\n\tif errors.Is(err, pgx.ErrNoRows) {\n\t\treturn false\n\t}\n\tif err != nil {\n\t\tslog.Error(\"ws auth: membership lookup failed\", \"error\", err)\n\t\treturn false\n\t}\n\treturn true\n}","handlingStrategy":"validation","validationCode":"// Before opening the WebSocket, verify the user can see this workspace.\n// Assumes a REST endpoint listing the user's workspaces exists.\nconst res = await fetch(`/api/workspaces`, { credentials: 'include' });\nif (!res.ok) throw new Error('cannot list workspaces');\nconst { workspaces } = await res.json();\nconst ws = workspaces.find((w) => w.id === workspaceId);\nif (!ws) {\n  // Not a member (or stale id): do not attempt the WS connection.\n  redirect(`/workspaces/${workspaces[0]?.id ?? ''}`);\n}","typeGuard":"// TypeScript: refuse to build the WS URL unless the id is a plausible UUID\n// drawn from the server-provided workspace list.\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\nfunction isConnectableWorkspaceId(\n  id: string | undefined | null,\n  known: Array<{ id: string }>,\n): id is string {\n  return typeof id === 'string' && UUID_RE.test(id) && known.some((w) => w.id === id);\n}","tryCatchPattern":"// The cookie-path 403 surfaces as a failed WS upgrade: the browser fires\n// 'error' then 'close' with no HTTP status exposed. Treat any close during\n// or shortly after handshake on a workspace-scoped socket as an access\n// failure, stop retrying with the same workspace_id, and revalidate.\nconst socket = new WebSocket(url);\nlet authenticated = false;\nsocket.addEventListener('open', () => { /* send auth if token-based */ });\nsocket.addEventListener('message', (e) => {\n  const msg = JSON.parse(e.data);\n  if (msg.type === 'auth_ack') authenticated = true;\n  if (msg.error === 'not a member of this workspace') {\n    socket.close();\n    onWorkspaceAccessDenied(); // clear stale id, refetch workspaces, redirect\n  }\n});\nsocket.addEventListener('close', () => {\n  if (!authenticated) onWorkspaceAccessDenied(); // handshake rejected (incl. HTTP 403)\n});","preventionTips":["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."],"tags":["websocket","authorization","authentication","go","membership","http-403"],"backgroundTag":null,"analyzedSha":"2c0912b6ec764b373d44eeea1e80f0d9f11ab417","analyzedAt":"2026-08-15T13:25:18.241Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}