Skip to content

Fix private REST inbox teammate lastSeenAt context#157

Open
seemayr wants to merge 1 commit intocossistantcom:mainfrom
seemayr:fix/rest-inbox-actor-last-seen
Open

Fix private REST inbox teammate lastSeenAt context#157
seemayr wants to merge 1 commit intocossistantcom:mainfrom
seemayr:fix/rest-inbox-actor-last-seen

Conversation

@seemayr
Copy link
Copy Markdown
Contributor

@seemayr seemayr commented Apr 11, 2026

Summary

  • make the private REST /conversations/inbox route resolve the acting teammate when available
  • pass that teammate userId into listConversationsHeaders(...) so dashboard inbox items can surface teammate-specific lastSeenAt
  • keep actorless private-key integrations working by falling back to userId: null
  • add regression tests for both the actor-aware and actorless inbox cases

Problem

The backend already models two distinct seen concepts:

  • visitor seen state via visitorLastSeenAt
  • dashboard teammate read state via conversation_seen.userId -> lastSeenAt

The shared dashboard header query already supports teammate-specific lastSeenAt, and the tRPC dashboard path passes the acting user.id into that query.

However, the private REST inbox route currently hardcodes userId: null when calling listConversationsHeaders(...). That means REST dashboard clients never receive teammate-specific lastSeenAt from inbox payloads, even when the private key resolves to a valid actor user.

Reasoning

This PR assumes the private REST inbox is intended to behave like the dashboard surface, not like the public/widget surface.

Why this looks like a bug rather than an intentional design:

  • the inbox response schema documents lastSeenAt as user-specific when available
  • listConversationsHeaders(...) computes teammate lastSeenAt already
  • the tRPC dashboard path passes user.id into that same query
  • POST /conversations/{id}/read explicitly writes read state for the acting teammate
  • only the private REST inbox route drops actor context by forcing userId: null

So the narrow fix here is to let the private REST inbox use the resolved actor user when one exists.

Assumptions

This PR is only correct if these assumptions are correct:

  • lastSeenAt on private inbox items is teammate-specific, not visitor-specific
  • private REST inbox is intended to support the same unread/read semantics as the dashboard tRPC path
  • linked private keys or explicit actor-user headers should influence inbox lastSeenAt
  • actorless private-key integrations should still be allowed and should keep receiving lastSeenAt: null
  • public/widget routes should continue to use visitor seen state and are intentionally out of scope here

If any of those assumptions are wrong, the change should be reconsidered.

Scope

This PR intentionally does not:

  • change public/widget conversation routes
  • change the REST markRead response shape
  • change realtime behavior
  • change frontend client logic directly

It only fixes actor propagation for the private REST inbox route.

Testing

  • bun test apps/api/src/rest/routers/conversation-auth.test.ts
  • added coverage for:
    • private inbox with a resolved actor user
    • private inbox without teammate context

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 11, 2026

@seemayr is attempting to deploy a commit to the cossistant Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 11, 2026

Greptile Summary

This PR fixes the private REST /conversations/inbox route by resolving the acting teammate and passing their userId into listConversationsHeaders, so dashboard clients receive teammate-specific lastSeenAt values just like the tRPC path does.

  • Potential regression: requirePrivateConversationActor(required: false) only returns null when there is no candidateUserId. If the private key has a linkedUserId set but the website has teamId: null/undefined, the function still throws a 403 unconditionally (line 54-58 of private-api-key-actor.ts). Linked private keys on teamless websites would break on the inbox endpoint, whereas they previously always returned userId: null.

Confidence Score: 4/5

Not safe to merge as-is — the required: false guard has a gap that can 403 linked private keys on teamless websites.

There is one P1 finding: the inbox route can now return 403 for previously-valid linked private keys when the website has no teamId, because resolvePrivateApiKeyActorUser only short-circuits to null when candidateUserId is absent. The fix is narrowly scoped and well-tested for the happy path, but this edge case is neither tested nor guarded against.

apps/api/src/rest/routers/conversation.ts — the requirePrivateConversationActor call around line 2046 needs a fallback or guard for the linked-key + teamless-website case.

Important Files Changed

Filename Overview
apps/api/src/rest/routers/conversation.ts Adds requirePrivateConversationActor(required: false) before listConversationsHeaders for the inbox route, but the required: false guard only covers the "no candidateUserId" path — linked keys on teamless websites still throw 403, creating a regression.
apps/api/src/rest/routers/conversation-auth.test.ts Adds two new inbox tests covering actor-aware and actorless paths; mocks are correctly wired via beforeEach defaults, but neither test covers the linked-key-on-teamless-website regression path.

Sequence Diagram

sequenceDiagram
    participant Client
    participant InboxRoute as GET /inbox
    participant ActorResolver as requirePrivateConversationActor
    participant PlanFetcher as getPlanForWebsite
    participant HeadersQuery as listConversationsHeaders

    Client->>InboxRoute: GET /inbox (private key)
    InboxRoute->>InboxRoute: requirePrivateControlContext()
    Note over InboxRoute: NEW: resolve actor before query
    InboxRoute->>ActorResolver: resolvePrivateApiKeyActorUser(required: false)
    alt linked key + no teamId
        ActorResolver-->>InboxRoute: throws 403 (regression)
    else unlinked key / no actor
        ActorResolver-->>InboxRoute: null
    else linked key + teamId present
        ActorResolver-->>InboxRoute: { userId, member, source }
    end
    InboxRoute->>PlanFetcher: getPlanForWebsite()
    InboxRoute->>HeadersQuery: listConversationsHeaders(userId: actor?.userId ?? null)
    HeadersQuery-->>InboxRoute: { items, nextCursor }
    InboxRoute-->>Client: 200 inbox payload
Loading

Reviews (1): Last reviewed commit: "Fix REST inbox teammate last-seen contex..." | Re-trigger Greptile

Comment on lines +2046 to +2053
const actor = await requirePrivateConversationActor({
c,
db: extracted.db,
apiKey: privateContext.apiKey,
organizationId: privateContext.organization.id,
websiteTeamId: privateContext.website.teamId,
required: false,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 required: false doesn't prevent 403 for linked keys on teamless websites

resolvePrivateApiKeyActorUser only short-circuits to null when candidateUserId is falsy (unlinked key + no explicit actor header). When a key has linkedUserId set — or when an X-Actor-User-Id header is present — candidateUserId becomes truthy, and the next check if (!params.websiteTeamId) throws a 403 unconditionally, regardless of required. So any linked private key used against a website with teamId: null | undefined will now get a 403 from the inbox, where previously it always returned 200 with userId: null.

This contradicts the PR's stated goal of keeping actorless / no-team-context integrations working. The relevant path in resolvePrivateApiKeyActorUser (apps/api/src/lib/private-api-key-actor.ts lines 54-58):

if (!params.websiteTeamId) {
    throw new AuthValidationError(403, ...); // no required check here
}

A catch around the call in the inbox handler, or a separate guard, is needed to handle this gracefully.

Comment on lines +2046 to 2063
const actor = await requirePrivateConversationActor({
c,
db: extracted.db,
apiKey: privateContext.apiKey,
organizationId: privateContext.organization.id,
websiteTeamId: privateContext.website.teamId,
required: false,
});

const [planInfo, result] = await Promise.all([
getPlanForWebsite(privateContext.website),
listConversationsHeaders(extracted.db, {
organizationId: privateContext.organization.id,
websiteId: privateContext.website.id,
userId: null,
userId: actor?.userId ?? null,
limit: extracted.query.limit,
cursor: extracted.query.cursor ?? null,
}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Actor resolution and plan fetch could be parallelized

requirePrivateConversationActor is awaited sequentially before the Promise.all, but actor resolution and getPlanForWebsite are fully independent of each other. Parallelizing them — e.g. const [actor, planInfo] = await Promise.all([requirePrivateConversationActor(...), getPlanForWebsite(...)]) — would reduce inbox latency on every request at the cost of zero correctness risk.

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