Skip to content

fix(desktop): tray shows correct org name for each host-service#3629

Merged
Kitenite merged 1 commit into
mainfrom
check-host-service-org-in-tray-and-org-names
Apr 21, 2026
Merged

fix(desktop): tray shows correct org name for each host-service#3629
Kitenite merged 1 commit into
mainfrom
check-host-service-org-in-tray-and-org-names

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented Apr 21, 2026

Summary

  • host.info was calling organization.getActiveFromJwt, which resolves to organizationIds[0] on the JWT regardless of which org the host-service is configured for. Users in multiple orgs saw the same (first) membership name for every Host Service entry in the tray submenu.
  • Added organization.getByIdFromJwt({ id }) that verifies the requested id is in the JWT's organizationIds and that the membership row still exists, then returns { id, name, slug }.
  • Host-service host.info now passes ctx.organizationId (the per-process configured org) so each instance reports its own org. Cache key now includes orgId.

Test plan

  • Sign in as a user who belongs to 2+ orgs.
  • Start host-services for each org (create/open workspaces in both).
  • Open the macOS menu-bar tray, hover "Host Service (N)": each submenu entry shows the correct org name, not a duplicate of the first one.
  • Remove user from org B (leaving JWT stale), reopen tray — entry for org B should now fail cleanly instead of returning the wrong name.

Summary by cubic

Fixes the tray showing the wrong organization name for users in multiple orgs. Each Host Service entry now displays its own org, and stale JWTs no longer return a misleading name.

  • Bug Fixes
    • Added organization.getByIdFromJwt({ id }) to validate the org against the JWT and active membership, returning { id, name, slug }.
    • Updated host.info to use ctx.organizationId and fetch via the new method; cache now keys by orgId.
    • When the user no longer belongs to an org, the tray entry fails cleanly instead of showing the first org’s name.

Written for commit 837f8ec. Summary will update on new commits.

Summary by CodeRabbit

  • Improvements
    • Strengthened organization access controls with improved validation and clearer error messaging when organizations are not accessible.

host.info was calling organization.getActiveFromJwt, which resolves to
organizationIds[0] on the JWT regardless of which org the host-service
is configured for. Users in multiple orgs saw the same (first)
membership name for every Host Service tray entry.

Adds organization.getByIdFromJwt and has host.info use ctx.organizationId
so each per-org host-service reports its own org.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 21, 2026

📝 Walkthrough

Walkthrough

The changes refactor organization lookup in the tRPC host service to accept an explicit organizationId parameter instead of relying on implicit JWT-derived "active organization" state. A new getByIdFromJwt procedure is added to verify organization membership and retrieve organization details.

Changes

Cohort / File(s) Summary
Host Service Organization Lookup
packages/host-service/src/trpc/router/host/host.ts
Modified getOrganization to accept organizationId parameter with ID-based caching. Updated info resolver to pass ctx.organizationId explicitly and replaced implicit JWT-based "active organization" lookup with explicit ID verification. Error message updated to reflect explicit lookup behavior.
Organization Router Verification
packages/trpc/src/router/organization/organization.ts
Added new getByIdFromJwt procedure that verifies a requested organization ID exists in ctx.organizationIds and confirms the user has a corresponding membership record, returning organization details { id, name, slug } or null.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 An ID, explicit and bright,
No more guessing which org is right,
JWT checked, membership verified with care,
Hopping through the cache with speed to spare! 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main fix: correcting organization name display in the tray for each host-service instance.
Description check ✅ Passed The description includes a comprehensive summary explaining the bug and solution, but the formal template sections (Related Issues, Type of Change, Testing, Screenshots) are not filled out.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch check-host-service-org-in-tray-and-org-names

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 21, 2026

Greptile Summary

This PR fixes a bug where the macOS tray submenu showed the same (first) organization name for every Host Service entry when a user belonged to multiple orgs. The root cause was host.info calling organization.getActiveFromJwt, which always resolved to organizationIds[0] from the JWT rather than the org the specific host-service process was configured for.

Changes:

  • Added organization.getByIdFromJwt — a new jwtProcedure endpoint that accepts an explicit org id, validates it against the JWT's organizationIds array, confirms live DB membership, and returns the org's { id, name, slug }.
  • Updated host.info to pass ctx.organizationId (the per-process configured org) to getOrganization, so each host-service instance reports its own org correctly.
  • Fixed the in-process org cache key to include organizationId so different orgs are not accidentally served stale data from each other.
  • Removed the unnecessary ctx as { api: ApiClient } type cast.

Confidence Score: 5/5

Safe to merge — the fix is surgically correct, the new endpoint applies proper dual-layer auth (JWT + live DB), and no regressions are introduced.

The bug is clearly identified and fixed at the source. The new getByIdFromJwt procedure follows the exact same security pattern as getActiveFromJwt (JWT array check + DB membership verification), and the cache now correctly scopes its single entry to the process's configured org ID. The only open items are non-blocking style suggestions (single-entry cache documentation and a potential query consolidation), neither of which affects correctness or security.

No files require special attention.

Important Files Changed

Filename Overview
packages/host-service/src/trpc/router/host/host.ts Passes ctx.organizationId to getOrganization and includes it in the cache key; removes the stale type cast on ctx.api. Single-entry cache is correct given per-process org assumption but could be made more explicit.
packages/trpc/src/router/organization/organization.ts Adds getByIdFromJwt jwtProcedure with correct dual-layer validation (JWT organizationIds array + live DB membership check). Two sequential DB queries could be one join but is otherwise correct and safe.

Sequence Diagram

sequenceDiagram
    participant Tray as macOS Tray
    participant HS as Host Service (per org)
    participant API as TRPC API
    participant JWT as JWT Claims
    participant DB as Database

    Tray->>HS: host.info query
    HS->>HS: getOrganization(api, ctx.organizationId)
    alt Cache hit (id matches & not expired)
        HS-->>Tray: cached { id, name, slug }
    else Cache miss
        HS->>API: organization.getByIdFromJwt({ id: organizationId })
        API->>JWT: check organizationIds.includes(id)
        alt id not in JWT
            API-->>HS: null → PRECONDITION_FAILED
        else id in JWT
            API->>DB: members WHERE userId & organizationId
            alt No membership row
                API-->>HS: null → PRECONDITION_FAILED
            else Membership exists
                API->>DB: organizations WHERE id
                API-->>HS: { id, name, slug }
                HS->>HS: update cache (orgId-keyed check)
                HS-->>Tray: { hostId, hostName, organization, … }
            end
        end
    end
Loading

Comments Outside Diff (1)

  1. packages/host-service/src/trpc/router/host/host.ts, line 10-13 (link)

    P2 Single-entry cache may miss in multi-org scenarios

    cachedOrganization is a module-level singleton that holds exactly one entry. The new id === organizationId guard correctly prevents serving the wrong org's data, but it means whenever a request arrives for an org that isn't the currently-cached one, the cache is completely bypassed and a fresh API call is made.

    Per the PR description the host-service is "per-process configured", so in practice only one organizationId ever flows through a given process and the single-entry cache is effectively correct. However, if that assumption ever relaxes (e.g. a single process handles multiple org connections), every org but the last cached one will incur a round-trip on every call.

    Consider either:

    • A Map<string, { data; cachedAt }> keyed by organizationId so each org gets its own TTL slot, or
    • A comment at the cache declaration noting the single-org-per-process assumption so future maintainers don't inadvertently break it.
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: packages/host-service/src/trpc/router/host/host.ts
    Line: 10-13
    
    Comment:
    **Single-entry cache may miss in multi-org scenarios**
    
    `cachedOrganization` is a module-level singleton that holds exactly one entry. The new `id === organizationId` guard correctly prevents serving the wrong org's data, but it means whenever a request arrives for an org that isn't the currently-cached one, the cache is completely bypassed and a fresh API call is made.
    
    Per the PR description the host-service is "per-process configured", so in practice only one `organizationId` ever flows through a given process and the single-entry cache is effectively correct. However, if that assumption ever relaxes (e.g. a single process handles multiple org connections), every org but the last cached one will incur a round-trip on every call.
    
    Consider either:
    - A `Map<string, { data; cachedAt }>` keyed by `organizationId` so each org gets its own TTL slot, or
    - A comment at the cache declaration noting the single-org-per-process assumption so future maintainers don't inadvertently break it.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/host-service/src/trpc/router/host/host.ts
Line: 10-13

Comment:
**Single-entry cache may miss in multi-org scenarios**

`cachedOrganization` is a module-level singleton that holds exactly one entry. The new `id === organizationId` guard correctly prevents serving the wrong org's data, but it means whenever a request arrives for an org that isn't the currently-cached one, the cache is completely bypassed and a fresh API call is made.

Per the PR description the host-service is "per-process configured", so in practice only one `organizationId` ever flows through a given process and the single-entry cache is effectively correct. However, if that assumption ever relaxes (e.g. a single process handles multiple org connections), every org but the last cached one will incur a round-trip on every call.

Consider either:
- A `Map<string, { data; cachedAt }>` keyed by `organizationId` so each org gets its own TTL slot, or
- A comment at the cache declaration noting the single-org-per-process assumption so future maintainers don't inadvertently break it.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/trpc/src/router/organization/organization.ts
Line: 95-113

Comment:
**Two sequential DB round-trips can be collapsed into one**

`getByIdFromJwt` first queries `members` to verify membership exists, then queries `organizations` to fetch the org row. These are two separate database round-trips even though both conditions must be satisfied. A single join query would retrieve the org data while simultaneously confirming membership:

```ts
const result = await db
  .select({ id: organizations.id, name: organizations.name, slug: organizations.slug })
  .from(members)
  .innerJoin(organizations, eq(organizations.id, members.organizationId))
  .where(
    and(
      eq(members.userId, ctx.userId),
      eq(members.organizationId, input.id),
    ),
  )
  .limit(1);

return result[0] ?? null;
```

This mirrors the pattern used in `getActive` on the session-based path and halves the DB round-trips for every tray refresh.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(desktop): tray shows correct org nam..." | Re-trigger Greptile

Comment on lines +95 to +113
getByIdFromJwt: jwtProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
if (!ctx.organizationIds.includes(input.id)) return null;

const membership = await db.query.members.findFirst({
where: and(
eq(members.userId, ctx.userId),
eq(members.organizationId, input.id),
),
});
if (!membership) return null;

const org = await db.query.organizations.findFirst({
where: eq(organizations.id, input.id),
columns: { id: true, name: true, slug: true },
});
return org ?? null;
}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Two sequential DB round-trips can be collapsed into one

getByIdFromJwt first queries members to verify membership exists, then queries organizations to fetch the org row. These are two separate database round-trips even though both conditions must be satisfied. A single join query would retrieve the org data while simultaneously confirming membership:

const result = await db
  .select({ id: organizations.id, name: organizations.name, slug: organizations.slug })
  .from(members)
  .innerJoin(organizations, eq(organizations.id, members.organizationId))
  .where(
    and(
      eq(members.userId, ctx.userId),
      eq(members.organizationId, input.id),
    ),
  )
  .limit(1);

return result[0] ?? null;

This mirrors the pattern used in getActive on the session-based path and halves the DB round-trips for every tray refresh.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/trpc/src/router/organization/organization.ts
Line: 95-113

Comment:
**Two sequential DB round-trips can be collapsed into one**

`getByIdFromJwt` first queries `members` to verify membership exists, then queries `organizations` to fetch the org row. These are two separate database round-trips even though both conditions must be satisfied. A single join query would retrieve the org data while simultaneously confirming membership:

```ts
const result = await db
  .select({ id: organizations.id, name: organizations.name, slug: organizations.slug })
  .from(members)
  .innerJoin(organizations, eq(organizations.id, members.organizationId))
  .where(
    and(
      eq(members.userId, ctx.userId),
      eq(members.organizationId, input.id),
    ),
  )
  .limit(1);

return result[0] ?? null;
```

This mirrors the pattern used in `getActive` on the session-based path and halves the DB round-trips for every tray refresh.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/host-service/src/trpc/router/host/host.ts (1)

15-39: Cache scope is per-process/per-user — fine under current assumptions, worth a brief note.

The module-level cachedOrganization is effectively a 1-entry cache keyed by organizationId. That matches the PR intent because each host-service process has a fixed ctx.organizationId (from config.organizationId in app.ts) and a single user's JWT. Two caveats worth being aware of:

  1. The cache is not keyed by the caller's identity. If this process is ever reached by more than one JWT/user (e.g., future multi-tenant reuse of a host-service process), the first user's org record would be served to subsequent users. Not exploitable today given the one-user-per-host-service model, but brittle if that assumption changes.
  2. If membership for this org is revoked mid-session, stale data will still be returned until the 1h TTL expires (or the process restarts). The PR description mentions the "remove user from org B → reopen tray" flow — that works for a fresh process but not within an already-warm cache window.

Neither is blocking; consider at minimum a short comment documenting the single-user-per-process assumption, or incorporate ctx.userId/JWT hash into the cache key if you want to harden against (1).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/host-service/src/trpc/router/host/host.ts` around lines 15 - 39, The
module-level cachedOrganization in getOrganization is a 1-entry cache keyed only
by organizationId and can serve the wrong org if this process ever handles
multiple JWTs; either document the single-user-per-process assumption with a
brief comment near cachedOrganization/ORGANIZATION_CACHE_TTL_MS, or harden the
cache by including the caller identity in the key (e.g., store
cachedOrganization.key = `${organizationId}:${callerIdOrJwtHash}` and compare
that when validating the cache inside getOrganization) so the cache check uses
both organizationId and the caller identity.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/host-service/src/trpc/router/host/host.ts`:
- Around line 15-39: The module-level cachedOrganization in getOrganization is a
1-entry cache keyed only by organizationId and can serve the wrong org if this
process ever handles multiple JWTs; either document the single-user-per-process
assumption with a brief comment near
cachedOrganization/ORGANIZATION_CACHE_TTL_MS, or harden the cache by including
the caller identity in the key (e.g., store cachedOrganization.key =
`${organizationId}:${callerIdOrJwtHash}` and compare that when validating the
cache inside getOrganization) so the cache check uses both organizationId and
the caller identity.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c9bc13d7-96ed-42f9-8cd1-e62cf8e05fe2

📥 Commits

Reviewing files that changed from the base of the PR and between 1456892 and 837f8ec.

📒 Files selected for processing (2)
  • packages/host-service/src/trpc/router/host/host.ts
  • packages/trpc/src/router/organization/organization.ts

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch
  • ✅ Electric Fly.io app

Thank you for your contribution! 🎉

@Kitenite Kitenite merged commit 1195a48 into main Apr 21, 2026
15 checks passed
@Kitenite Kitenite deleted the check-host-service-org-in-tray-and-org-names branch April 21, 2026 22:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant