Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,32 @@ jobs:
path: apps/code/playwright-report/
retention-days: 7

# Web host e2e: a plain SPA over the Vite dev server (Playwright starts it),
# no Electron packaging. Reuses the cached Chromium above. Runs even if the
# desktop suite failed so both hosts report independently.
- name: Run web E2E smoke tests
if: ${{ !cancelled() }}
run: pnpm run test:e2e:web
env:
CI: true

- name: Upload web test results to Trunk
if: ${{ !cancelled() }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: "apps/web/tests/e2e/junit.xml"
org-slug: posthog-inc
token: ${{ secrets.TRUNK_API_TOKEN }}

- name: Upload web Playwright report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: web-playwright-report
path: apps/web/playwright-report/
retention-days: 7

e2e:
# Live-model e2e for the @posthog/agent adapters (claude + codex). Runs only
# after the unit + integration jobs pass — a red tree never reaches the
Expand Down
177 changes: 177 additions & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# @posthog/web

Browser host for PostHog Code. Boots the same `@posthog/ui` shell and
`@posthog/core` services as the desktop app, with web platform adapters —
no Electron, no local workspace-server. Scope today: auth + cloud tasks
(local workspaces, terminal, and local git need a workspace backend and are
out of scope for the web host's first iteration).

## Run

```bash
pnpm --filter @posthog/web dev # Vite on http://localhost:5273
```

No separate backend process: the host router slice runs in the browser
(`web-host-router.ts`), served over tRPC's `unstable_localLink`
(`web-trpc.ts`). `auth`, `cloudTask`, and `analytics` are the real routers
backed by in-browser `AuthService` / `CloudTaskService` (both are
host-agnostic core code); the rest are stubs that return benign empties.
Procedures outside the slice fail with NOT_FOUND at call time — that is the
to-do list for widening the web surface.

## Testing

```bash
pnpm --filter @posthog/web test:e2e # Playwright: happy-path browser e2e
```

- **`tests/e2e/`** drives stock Chromium against the Vite dev server (Playwright
starts it). Scope is the hermetic happy path up to the OAuth wall — boot,
container wiring, onboarding → sign-in card, and the `/callback` relay — since
real login needs PostHog cloud and a popup IdP. Runs in CI (`test.yml`),
reusing the desktop suite's cached Chromium.
- The boot spec doubles as the host-capability guard: `web-container.ts` runs
`assertHostCapabilities(REQUIRED_HOST_CAPABILITIES)` at container load, so an
unbound capability throws before the app mounts and fails the boot spec. (A
jsdom unit test that imported the whole composition root was tried but dropped
— evaluating the entire app graph took ~30s+ per run, and the e2e already
covers it.) The capability *mechanism* is unit-tested in
`@posthog/di` (`hostCapabilities.test.ts`).

## Auth

`WebOAuthFlowService` (`web-oauth-flow.ts`) implements the core
`IAuthOAuthFlowService` with a browser PKCE flow: popup to
`{cloud}/oauth/authorize`, redirect back to `{origin}/callback`, code relayed
to the opener tab over a BroadcastChannel, token exchange via fetch. Session
persistence is localStorage (`web-auth-adapters.ts`); the refresh token is
encrypted at rest with AES-GCM under a **non-extractable** Web Crypto key held
in IndexedDB (`webAuthTokenCipher`). The key round-trips through structured
clone but its raw bytes are never exposed to JS, so a stolen localStorage dump
can't be decrypted offline — matching the bar the desktop host's machine-bound
cipher sets. A live XSS payload can still ask the key to decrypt while it runs
on the page (httpOnly cookies would need server-side sessions the cloud host
doesn't have). Tokens written by the earlier plaintext build fail to decrypt
and are cleared, forcing a clean re-auth.

## Hosting

Build a static bundle and serve it:

```bash
pnpm --filter @posthog/web build # Vite output: apps/web/dist
```

Serve `dist/` as a single-page app with a fallback to `index.html` for unknown
paths. The OAuth popup lands on the real path `/callback` (`OAUTH_CALLBACK_PATH`
in `web-oauth-flow.ts`, dispatched in `main.tsx`), which must load the SPA to
relay the code back to the opener tab. The app's own routes use hash history, so
`/callback` is the only real path that needs the fallback.

The build is code-split: vendor libraries into cacheable groups (`manualChunks`
in `vite.config.ts`) and each route's component into its own lazy chunk (the
TanStack Router plugin's `autoCodeSplitting`, mirroring `apps/code`), so a screen
downloads only when navigated to. All emitted assets are content-hashed — serve
`dist/assets/` with a long-lived immutable `Cache-Control` and only `index.html`
short-lived, so returning users re-download just the chunks that changed.

The steps below are one-time setup for a deployed origin. None block the app
from booting, but auth, attachments, and integrations each need one.

### Environment variables

All are Vite build-time vars (`import.meta.env.*`), baked in at build time.

| Var | Required | Purpose |
| --- | --- | --- |
| `VITE_POSTHOG_API_KEY` | Recommended | Real `phc_…` project key. Enables posthog-js analytics, error/rejection capture, session recording, and real feature flags. The guard in `main.tsx` requires the `phc_` prefix; without it posthog stays uninitialized and the tracker/analytics service no-op, leaving only the host-forced `SYNC_CLOUD_TASKS_FLAG` on (every other flag reads `false`). |
| `VITE_POSTHOG_API_HOST` | No | posthog-js ingestion host. Default `https://internal-c.posthog.com`. |
| `VITE_POSTHOG_UI_HOST` | No | posthog-js UI host. Default `https://us.i.posthog.com`. |
| `VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE` | No | Dev/test only: a static access token that bypasses the OAuth flow (`AUTH_TOKEN_OVERRIDE`). Leave unset in production. |

Session recording turns on whenever posthog-js initializes (any build with a
real key); automatic unhandled-error/rejection/console capture is additionally
gated to non-dev (production) builds (`capture_exceptions` in
`posthogAnalyticsImpl.ts`).

### OAuth redirect URI registration — required for sign-in

The web host reuses the Code ("Array") OAuth application client ids
(`packages/shared/src/oauth.ts`). Each region stores its app's `redirect_uris`
as database rows (Django admin → OAuth applications), and they must include:

- `https://<web-origin>/callback` for the deployed host. `http` is rejected for
non-loopback hosts by `OAuthApplication` redirect validation, so the origin
must be HTTPS.
- `http://localhost/callback` (portless) for local dev — PostHog's authorize
view extends RFC 8252 §7.3 loopback port flexibility to `localhost`
(`posthog/api/oauth/views.py: validate_redirect_uri`), so the portless form
matches the Vite dev server on any port. If desktop dev builds can already
sign in to the region, check whether the registered localhost URI is portless
or pinned to `:8237`.

A CIMD client (the `raycast_metadata.py` / `wizard_metadata.py` pattern in
`posthog/api/oauth/`) is NOT suitable: CIMD registrations are capped to
unprivileged scopes, and Code requires `scope=*` like the desktop app.

### S3 artifact-bucket CORS — required for attachment uploads

Composer attachments upload straight from the browser via an S3 presigned POST
(`.../artifacts/prepare_upload/` returns the presigned post, then a `POST` to
`s3.<region>.amazonaws.com/<bucket>`). The bucket
(`posthog-cloud-prod-us-east-1-app-assets` for US) must return
`Access-Control-Allow-Origin` for the web origin or the browser blocks the
response — the POST itself returns `204` (it succeeds server-side) but `fetch`
rejects with a bare `NetworkError`. Desktop (Electron/Node `fetch`) is not
subject to CORS, so this is web-only.

Add the deployed web origin (and `http://localhost:5273` for dev) with the
`POST` method to the bucket's CORS config. Until then, attaching + preview work
but sending a task with an attachment fails at the upload step.

### `posthog_web` integration connect origin — required for Slack / GitHub connect

PostHog brokers the Slack/GitHub OAuth server-side and, on completion, redirects
to a target chosen by the `connect_from` value (`posthog_code` →
`posthog-code://…`, `posthog_mobile` → `posthog://…`) — a per-known-client
mapping, not an open redirect. The web host's `startFlow` opens
`.../integrations/authorize/?kind={slack|github}&next=…&connect_from=posthog_web`
in a tab; the backend needs a `posthog_web` mapping for the flow to complete. No
callback relay is required because the integration is created server-side and the
connect hooks refetch `getIntegrations()` on window-focus. See Known gaps for
what remains once the mapping exists.

### CORS — no action needed

Verified against `us.posthog.com`: `/oauth/token` answers preflight with the
request origin allowed, and `/api/*` responds `access-control-allow-origin: *`
with `authorization` in the allowed headers. The agent-proxy stream service has
a CORS origin allowlist (`TASKS_AGENT_PROXY_CORS_ORIGINS` in
`PostHog/posthog/services/agent-proxy`), and `CloudTaskService` falls back to
the CORS-open Django stream leg regardless.

## Known gaps

Limitations that remain even after the hosting setup above — each needs a code
or backend change, not just configuration.

- **Slack / GitHub connect** is client-wired (`startFlow` opens the authorize
URL; the connection is detected via a window-focus refetch of
`getIntegrations()`, not a callback relay) but gated on backend support for the
`posthog_web` connect origin (see Hosting). Even with that mapping, the GitHub
*user* flow (`POST /api/users/@me/integrations/github/start/`) hardcodes
`connect_from: "posthog_code"` in `@posthog/api-client` and returns **400** on
web — it needs a host-parameterized `connect_from`. Where a project already has
the integration connected, the settings pages render the connected/manage state
correctly.
- **Per-device stores** (cloud workspaces, archive, pins, browser tabs) are
localStorage-only — not durable across devices or a site-data clear. Desktop
persists these in SQLite; the browser host needs server-side state to match.
- **Skill dependency expansion** is a passthrough: a skill that declares
`dependencies:` on other skills won't pull them in automatically (pick them
explicitly). This is a pipeline gap, not just a web gap — `exportSkill` strips
SKILL.md frontmatter and the team-skills API has no `dependencies` field, so
the dependency list never reaches any client (desktop only expands local
on-disk skills). Needs `dependencies` carried end-to-end through
export → publish → the LlmSkill API (backend) → `fetchSkillForInstall`.
68 changes: 0 additions & 68 deletions apps/web/dev-server.mjs

This file was deleted.

14 changes: 12 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,37 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test:e2e": "playwright test --config=tests/e2e/playwright.config.ts",
"test:e2e:ui": "playwright test --config=tests/e2e/playwright.config.ts --ui"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.22.1",
"@inversifyjs/strongly-typed": "2.2.0",
"@pierre/diffs": "^1.2.10",
"@posthog/agent": "workspace:*",
"@posthog/core": "workspace:*",
"@posthog/di": "workspace:*",
"@posthog/host-router": "workspace:*",
"@posthog/host-trpc": "workspace:*",
"@posthog/platform": "workspace:*",
"@posthog/shared": "workspace:*",
"@posthog/ui": "workspace:*",
"@posthog/workspace-client": "workspace:*",
"@tanstack/react-query": "^5.100.14",
"@trpc/client": "^11.17.0",
"@trpc/server": "^11.17.0",
"@trpc/tanstack-react-query": "^11.17.0",
"inversify": "^7.10.6",
"react": "19.2.6",
"react-dom": "19.2.6",
"reflect-metadata": "^0.2.2"
"reflect-metadata": "^0.2.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/router-plugin": "catalog:",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.2.1",
Expand Down
16 changes: 13 additions & 3 deletions apps/web/src/Providers.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { HostTRPCProvider } from "@posthog/host-router/react";
import { ThemeWrapper } from "@posthog/ui/primitives/ThemeWrapper";
import { WorkspaceClientProvider } from "@posthog/workspace-client/provider";
import { QueryClientProvider } from "@tanstack/react-query";
import type React from "react";
import { HotkeysProvider } from "react-hotkeys-hook";
import { queryClient } from "./web-container";
import { hostTrpcClient } from "./web-trpc";

// Web transport wiring — the per-host counterpart of apps/code's Providers.tsx.
// @posthog/ui consumes the HOST router context (useHostTRPCClient), so web only
// needs HostTRPCProvider over the HTTP client. No electron TrpcRouter context.
// @posthog/ui consumes the HOST router context (useHostTRPCClient), so web needs
// HostTRPCProvider over the in-process client. No electron TrpcRouter context.
//
// It also mounts WorkspaceClientProvider with connection={null}: several
// task-detail components (useTaskData, FileTreePanel, staging) call
// useWorkspaceTRPC() unconditionally, so the context must exist or they throw.
// There is no workspace-server on the web host, so the provider points at its
// UNAVAILABLE dead URL — those local-only calls reject as failed queries (and
// are gated on a repoPath that cloud tasks never have, so they don't fire).

export const Providers: React.FC<{ children: React.ReactNode }> = ({
children,
Expand All @@ -17,7 +25,9 @@ export const Providers: React.FC<{ children: React.ReactNode }> = ({
<HotkeysProvider>
<QueryClientProvider client={queryClient}>
<HostTRPCProvider trpcClient={hostTrpcClient} queryClient={queryClient}>
<ThemeWrapper>{children}</ThemeWrapper>
<WorkspaceClientProvider connection={null} queryClient={queryClient}>
<ThemeWrapper>{children}</ThemeWrapper>
</WorkspaceClientProvider>
</HostTRPCProvider>
</QueryClientProvider>
</HotkeysProvider>
Expand Down
Loading
Loading