Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
29edb2d
Protect live presence intake and isolate retention cleanup
tannerlinsley Oct 29, 2026
2074b83
Apply live page eligibility before limiting results
tannerlinsley Oct 29, 2026
57a6fc8
Add company prioritization tools and Scarf history to live dashboard
tannerlinsley Oct 29, 2026
74fb3a7
Add active and background company status filters and column
tannerlinsley Oct 29, 2026
e50e309
Add admin-initiated visitor chat with deferred interface assets
tannerlinsley Oct 29, 2026
34cf27f
Fix visitor chat Worker startup and background polling
tannerlinsley Oct 29, 2026
8c9ae72
Include recently left companies in dashboard breakdowns
tannerlinsley Oct 29, 2026
072159c
Merge branch 'taren/live-company-prioritization' into taren/visitor-chat
tannerlinsley Oct 29, 2026
320af3c
Enable visitor chat for production rollout
tannerlinsley Oct 29, 2026
2b3db74
Dock admin visitor chats in independent bottom tabs
tannerlinsley Oct 29, 2026
1ddcb4b
Fix chat dock dark mode and show minimized inbox by default
tannerlinsley Oct 29, 2026
d1f59b6
Reduce duplicate R2 cache refresh writes
tannerlinsley Oct 29, 2026
bb7357f
Merge branch 'taren/visitor-chat' into taren/reduce-r2-cache-writes
tannerlinsley Oct 29, 2026
b5a7ddc
Add one-click visitor chat self-test for admins
tannerlinsley Oct 29, 2026
58d251f
Merge commit 'b5a7ddc03' into taren/reduce-r2-cache-writes
tannerlinsley Oct 29, 2026
7c5b026
Isolate deferred chat styles from the site layout
tannerlinsley Oct 29, 2026
e412e20
Merge commit '7c5b02668' into taren/reduce-r2-cache-writes
tannerlinsley Oct 29, 2026
94d2e37
Share TanStack identity and add resource-bound Source OAuth
tannerlinsley Oct 29, 2026
7a7748e
ci: apply automated fixes
autofix-ci[bot] Oct 29, 2026
da18827
Isolate self-test conversations and sync shared admin tabs
tannerlinsley Oct 29, 2026
07107c2
Include existing OAuth tables in the migration test boundary
tannerlinsley Oct 29, 2026
af3e071
Combine live shared OAuth issuer with chat and R2 release
tannerlinsley Oct 29, 2026
de47516
fix: load existing conversations in open visitor chat tabs (#1450)
tannerlinsley Oct 29, 2026
f9f8b44
Include existing chat tab correction
tannerlinsley Oct 30, 2026
c825c6f
Keep chat composers focused while sending
tannerlinsley Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 62 additions & 10 deletions docs/live-visitors.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,46 @@ function. Admin queries refresh every three seconds while the page is visible.
The dashboard opens with a company grid. Individual sessions are hidden until
a company is expanded, and each session has its own expandable tabs and activity.
Unmatched traffic stays in one collapsed row at the bottom. Columns support
sorting, and search matches companies, industries, libraries, and page paths.
multi-column sorting with Shift-click, column visibility, row selection,
pagination, compact density, and CSV export. Search matches companies, industries,
libraries, and page paths. Library, industry, size, and funnel-stage filters can
be combined with minimum website views and recent actions. Values within a
filter match with OR, while different filters match with AND.

The overview counts identified companies once per domain. Library breakdowns
count a company once per library, so one company can appear in several bars.
Industry breakdowns exclude unmatched traffic. The company trend records real
polling snapshots in the open dashboard tab, up to five minutes, and resets on
reload. Grid action totals count named interactions in the last five minutes,
excluding page views.
Industry and size breakdowns exclude unmatched traffic. Clicking a breakdown
applies its filter. Size buckets use the lower bound of Scarf's employee range.
The status widget and sortable Status column classify a company as Active when
at least one online tab is visible, Background when all online tabs are hidden,
or Left when no tabs remain online. Each company occupies one status, even when
it has both visible and hidden tabs. Active describes visibility, not confirmed
attention or interaction. Status filters combine with the other company filters.
Grid action totals count named interactions in the last five minutes, excluding
page views. Pause freezes polling, including expanded sessions and history.

## Scarf company history

The server uses the existing `ANALYTICS_SCARF_API_TOKEN` bearer token for Scarf's
`/v3/insights/TanStack/aggregations/export` endpoint. Reporting is separate from
the lookup key and requires Scarf reporting access. Exports cover the selected
7, 30, 90, or 365 UTC calendar days, including today. Growth compares website
pixel views with the preceding equal-length window, so today's totals are
incomplete. These are window totals, not lifetime totals.

Website views include only the site's registered Scarf pixels. Site events
come from the TanStack Site Telemetry package, including browser interactions
and server requests. Events are not added to views. Sources and endpoints are
Scarf's distinct network counts, not people. Company reports join live company
domains exactly, without guessing parent-company relationships. Missing rows or
failed reports show N/A rather than zero. No company data is automatically
unlocked and no Scarf credits are spent on enrichment.

Successful aggregate exports are cached at the edge for 15 minutes and partial
or unavailable responses for one minute. Live presence still refreshes every
three seconds. Sorting and filtering do not request new exports. Only admins
can request reporting data, and source identifiers and credentials are never
returned to the browser.

## Enable the experiment

Expand Down Expand Up @@ -46,9 +78,11 @@ contact, not proof that a person is looking at the page. `pageshow` handles
restoration from the browser's back/forward cache. Sequence numbers prevent a
late heartbeat or departure from overwriting newer tab state.

Each tab retains up to 40 named actions. The admin includes departures from the
last five minutes when requested and returns at most 500 recently seen tabs,
with a visible truncation notice. Sessions disappear from reads at 24 hours and
Each tab retains up to 40 named actions. The company overview aggregates all
eligible tabs without a 500-tab cap. Individual company sessions are fetched
only when expanded, with at most 500 eligible recently seen tabs per company
and a visible truncation notice. The admin includes departures from the last
five minutes when requested. Sessions disappear from reads at 24 hours and
are deleted, with their tabs, on the next minute cleanup.

## Company lookup
Expand Down Expand Up @@ -82,17 +116,35 @@ DNT and GPC prevent collection in both the browser and server. Browser collectio
is disabled in development, staging, and outside `tanstack.com`.

`isLivePage` allows public library content and selected public site routes.
Showcase submission, editing, and individual detail pages are excluded because
detail pages can include owner-only drafts. Existing records with excluded paths
are filtered out of admin responses and expire through normal retention.
Account, admin, auth, chat, private builder projects, shop, and internal endpoints
are excluded. Unknown paths are excluded until added to the public allowlist.
Queries, fragments, clipboard text, form values, event properties, account IDs,
emails, and user agents are not stored. Only explicitly named actions from the
existing analytics hooks are collected; there is no arbitrary click-text capture.

Visitor IPs exist only during the server-side lookup and are not written to the
presence tables or admin responses. Lookup tracing and Sentry breadcrumbs are
Visitor IPs are used transiently for the server-side lookup and Cloudflare edge
rate-limit counters, and are not written to the application database or admin
responses. Lookup tracing and Sentry breadcrumbs are
suppressed to keep IP query parameters out of telemetry. The lookup key stays
server-side. There is no joining sessions by IP, fingerprint, or login.

## Intake limits

Cloudflare edge bindings limit intake to 600 requests per IP per minute and new
company lookups to 30 per IP per minute, per Cloudflare location. These are
approximate abuse limits, not a global billing quota. Intake returns 429 before
reading a body or writing presence when limited, and 503 if the limiter is
unavailable. A lookup limit leaves presence intact with a separate rate-limited
status. Corporate networks share an IP, so these limits can affect shared
networks during large bursts. Raw IPs are not added to the database-backed MCP
rate-limit table. Presence cleanup runs independently of workflow processing, so
a cleanup failure does not prevent scheduled workflow work.

[Cloudflare rate-limit bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/)

## Validation

Focused tests cover privacy signals, intake validation, public-route boundaries,
Expand Down
51 changes: 51 additions & 0 deletions docs/shared-oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Share TanStack login with Source

TanStack.com is the authorization server for Source browser login and MCP clients. Source owns its accounts and repository permissions, and links them to the stable TanStack user ID. An email match never links an existing Source account. A verified GitHub ID shared by both products can connect a TanStack login to the existing Source GitHub account. Conflicting Source account links require a person to resolve them.

## Enable the integration

1. Apply `drizzle/migrations/0044_shared_oauth.sql` with the normal database migration command. It adds resource and refresh-grant fields, and widens OAuth client IDs to text. Existing OAuth records keep their null resource and their current behavior. Do not use `db:push` against production.
2. Generate a random 32-byte secret and configure the same `TANSTACK_SOURCE_INTROSPECTION_SECRET` in TanStack.com and the Source Worker. Keep it out of browser configuration and source control. Only the Source server can introspect Source grants.
3. Deploy TanStack.com before enabling the Source integration. Source also needs its D1 migration `0045_tanstack_identity.sql`.
4. Sign into the existing Source account, then connect TanStack in Settings > Connected accounts. New people choose Continue with TanStack and pick a Source username. No existing account or organization membership moves.
5. Add `https://source.tanstack.com/mcp` to an OAuth-capable MCP client. Discovery points to TanStack.com. Consent identifies the app and its Source permissions.

The shared OAuth SQL was first deployed as 0043_shared_oauth. The combined
history numbers it 0044 while preserving its exact SQL and applied timestamp,
so existing production databases recognize it as already applied.

## Grant boundaries

- `https://tanstack.com/oauth/userinfo`, scope `identity`: returns the stable account ID, name, email and suggested username. The preregistered public client `tanstack-source` has exactly one redirect, `https://source.tanstack.com/auth/tanstack/callback`. PKCE and browser state bind the login. This is OAuth identity lookup, not OpenID Connect.
- `https://source.tanstack.com/mcp`, scope `source`: lets the client act as the linked Source person at `/mcp`. It grants no extra repository permissions, and never works at Source REST, git or browser-session endpoints. The consent page links to Source account setup before approval.
- Existing null-resource `api` grants continue to work. New grants for `https://tanstack.com/mcp` work on the existing TanStack API, with scope `api`.

Codes are single-use and bound to the client, redirect, PKCE challenge and resource. Access tokens expire in one hour. Resource-bound refresh tokens rotate, expire 30 days after the original grant, and revoke the whole grant on replay. Revocation is available through `/oauth/revoke` and TanStack connected apps. Source introspects each request, so revocation takes effect on the next request.

Existing personal API keys, legacy OAuth access tokens and refresh tokens stay valid. Source personal, agent and job tokens keep their existing policy and storage paths.

## Validate without production data

Run the pure tests with:

```sh
pnpm exec vitest run --config vitest.chat.config.ts harness-tests/core/shared-oauth.test.ts
```

The database tests require an isolated local PostgreSQL database named `shared_oauth_test` on `127.0.0.1`. They create, truncate and drop only their fixture tables. Never point them at a real application database.

```sh
OAUTH_TEST_DATABASE_URL=postgres://oauth_test@127.0.0.1:55439/shared_oauth_test pnpm exec vitest run --config vitest.chat.config.ts harness-tests/core/shared-oauth-db.test.ts
```

The implementation follows [MCP authorization](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization), RFC 9728 resource metadata, RFC 8414 discovery, RFC 8707 resource indicators and RFC 9207 issuer responses. Dynamic registration supports existing MCP clients. Client ID metadata documents are not advertised.

## Show the current TanStack account on Source

`GET /oauth/session-hint` uses the existing TanStack browser session and allows credentialed CORS only from `https://source.tanstack.com`. It returns a display name and avatar URL, never an access token, session cookie or account ID. Signed-out requests return no account. Responses are private and never cached.

Source fetches the hint in the browser and shows the avatar with **Continue as [name] on TanStack**. If the hint is unavailable, Source shows **Continue with TanStack**. GitHub remains a separate sign-in choice. Both TanStack buttons begin the same PKCE OAuth flow. The hint grants no access and is not used to link accounts.

The authenticated `/oauth/userinfo` response includes `github_id`, taken from the GitHub account attached to the authenticated TanStack user. Source uses this verified provider ID in browser sign-in and MCP authentication to find the same Source person, including when the emails differ. This field is never taken from the session hint or browser input.

Source resolves the MCP caller from an existing TanStack identity link or an unambiguous verified GitHub identity. Authentication reads do not create links or accounts. Conflicting Source links are refused. If a TanStack account has more than one GitHub identity, no automatic GitHub match is offered.
114 changes: 114 additions & 0 deletions docs/visitor-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Visitor chat

Visitor chat is enabled in production with `VISITOR_CHAT_ENABLED=true`.
Set it to `false` to stop chat requests and invitations.

Admins open a company, choose a browser session, and send the first message.
That message opens a small visitor chat using the sender's display name and
profile image. Existing conversations open their transcript directly. Every
admin with access to the live dashboard shares the same inbox and can reply in
the same thread. Senders are assigned from the authenticated server session,
never from browser-provided names or account IDs. No access is granted to new
users by this feature.

## Loading and delivery

The existing 20-second presence request carries the invitation signal. Its
response stays empty until a conversation has a committed first message.
Only then does the browser import the visitor chat module and stylesheet.
Chat-specific utilities are excluded from the initial site stylesheet and built
into deferred stylesheets for the visitor window and admin inbox. The shared admin
inbox is also a separate lazy module, opened by Chats or a session's chat action.
The bridge adds no always-open chat connection or additional idle polling loop.
A non-secret browser hint restores an existing chat on reload, including when
presence tracking is off or the visitor is on a private route. The API still
requires the private signed cookie, and the hint never grants access to messages.
The root page contains only shared feature flags, so public HTML caching cannot
cause chat resources to load for another visitor. This does not collect private
page paths. Visitors with a page loaded
before this release must reload before chat becomes
available, and the admin action explains that state.

Invitations can take up to 20 seconds to appear. Once loaded, visitor and admin
messages poll every two seconds. The browser acknowledges the messages it
actually received, and only marks them seen while the chat is expanded in a
visible tab. These are browser signals, not proof of attention. The admin's
Chats badge and inbox show conversations whose latest message needs a reply.
Chat updates continue independently of pausing the live-company grid.

Conversation row locks order simultaneous sends. Client message IDs make retries
idempotent. History loads in pages of 100, and older pages remain available.
Messages are plain text. Enter sends and Shift+Enter inserts a line break.
There are no attachments, email delivery, push notifications, or typing indicators
in this version. Leaving the site does not turn a message into an email.

## Identity and retention

A random private proof accompanies the existing anonymous presence session. The
server stores its hash. The proof is never returned in admin responses. It binds
the initial invitation to the intended browser, rather than treating a visible
session ID as permission to read messages.

Once invited, the visitor receives a signed, HttpOnly, Secure, SameSite cookie.
It has a separate signing purpose from account cookies and lasts one year.
Reloads, tabs, and later presence sessions can resume the same conversation.
Clearing cookies, changing browsers, expiration, or signing-key rotation can
remove the visitor's connection to retained history. No IP, fingerprint, or
logged-in visitor account is used to recover it. Conversations target individual
browser sessions, not everyone at the matched company.

Chat history remains until an admin manually deletes it. Closing a conversation
keeps its history and permits reopening. Stop chat hides the visitor interface,
mutes the conversation, and sets a browser opt-out cookie. Admins cannot reopen
a visitor-muted conversation. Minimizing the window persists across reloads and
does not mark new messages seen. The latest unread messages are counted in its
collapsed header.

The chat tables do not depend on the 24-hour lifetime of presence records.
Sender names and avatar snapshots stay with their messages if an admin account
changes or is deleted. The company snapshot describes the network at initiation,
not a verified employer, and is shown only to admins. Manual deletion cascades
to all messages and clears presence references.

## Boundaries

All admin reads and writes check the current admin capability. Visitor APIs use
the signed chat cookie, not a supplied chat ID, and reject cross-site writes.
The new Cloudflare `VISITOR_CHAT_RATE_LIMITER` binding bounds requests per IP
without storing IPs in the application database. Missing or unavailable limits
fail closed. Message size and request body size are bounded.

Chat regions are excluded from clipboard analytics, including selections that
span a chat region. Message bodies, cookies, visitor details, and database
parameters are removed from Sentry events and transactions for these endpoints.
Driver write errors are replaced with a message that contains no SQL parameters.
The chat interface does not send message content to Scarf or GA.

## Release

1. Apply migration `0043_visitor_chat` through the normal migration flow.
2. Deploy the site Worker with the `VISITOR_CHAT_RATE_LIMITER` binding. The existing
`SESSION_SECRET` signs the separately scoped visitor credential.
3. Verify invitation loading, two different admins in one thread, reload recovery,
manual closure/deletion, visitor opt-out, and cold-page chunk loading.
4. Enable `VISITOR_CHAT_ENABLED=true` when ready to invite production visitors.

Local integration tests require a disposable, migrated `tanchat_test` PostgreSQL
database, and refuse a production database URL. The preview used synthetic users
and conversations and did not contact real site visitors.

## Test your own chat

Click **Test chat** in the live dashboard. It opens the public site in a new tab
with a test message from your admin profile, and opens the matching admin tab
labeled **Test chat · You**. Reply in either tab to test both sides. The visitor
tab uses the same window, history, reload recovery, and opt-out as a real chat.
Allow pop-ups for tanstack.com if the browser blocks the new tab.

This action requires admin access. It creates a separate conversation labeled
Test chat, without waiting for Scarf or adding a synthetic presence session.
A separate signed test cookie and per-tab test ID connect the visitor tab to
that conversation. Existing visitor conversations and opt-out choices are
unchanged. Each test creates a new conversation; older test tabs cannot send to
the newer conversation.
Delete the test from the admin tab when finished. Other visitors are unaffected.
Loading
Loading