|
| 1 | +# @posthog/web |
| 2 | + |
| 3 | +Browser host for PostHog Code. Boots the same `@posthog/ui` shell and |
| 4 | +`@posthog/core` services as the desktop app, with web platform adapters — |
| 5 | +no Electron, no local workspace-server. Scope today: auth + cloud tasks |
| 6 | +(local workspaces, terminal, and local git need a workspace backend and are |
| 7 | +out of scope for the web host's first iteration). |
| 8 | + |
| 9 | +## Run |
| 10 | + |
| 11 | +```bash |
| 12 | +pnpm --filter @posthog/web dev # Vite on http://localhost:5273 |
| 13 | +``` |
| 14 | + |
| 15 | +No separate backend process: the host router slice runs in the browser |
| 16 | +(`web-host-router.ts`), served over tRPC's `unstable_localLink` |
| 17 | +(`web-trpc.ts`). `auth`, `cloudTask`, and `analytics` are the real routers |
| 18 | +backed by in-browser `AuthService` / `CloudTaskService` (both are |
| 19 | +host-agnostic core code); the rest are stubs that return benign empties. |
| 20 | +Procedures outside the slice fail with NOT_FOUND at call time — that is the |
| 21 | +to-do list for widening the web surface. |
| 22 | + |
| 23 | +## Testing |
| 24 | + |
| 25 | +```bash |
| 26 | +pnpm --filter @posthog/web test:e2e # Playwright: happy-path browser e2e |
| 27 | +``` |
| 28 | + |
| 29 | +- **`tests/e2e/`** drives stock Chromium against the Vite dev server (Playwright |
| 30 | + starts it). Scope is the hermetic happy path up to the OAuth wall — boot, |
| 31 | + container wiring, onboarding → sign-in card, and the `/callback` relay — since |
| 32 | + real login needs PostHog cloud and a popup IdP. Runs in CI (`test.yml`), |
| 33 | + reusing the desktop suite's cached Chromium. |
| 34 | +- The boot spec doubles as the host-capability guard: `web-container.ts` runs |
| 35 | + `assertHostCapabilities(REQUIRED_HOST_CAPABILITIES)` at container load, so an |
| 36 | + unbound capability throws before the app mounts and fails the boot spec. (A |
| 37 | + jsdom unit test that imported the whole composition root was tried but dropped |
| 38 | + — evaluating the entire app graph took ~30s+ per run, and the e2e already |
| 39 | + covers it.) The capability *mechanism* is unit-tested in |
| 40 | + `@posthog/di` (`hostCapabilities.test.ts`). |
| 41 | + |
| 42 | +## Auth |
| 43 | + |
| 44 | +`WebOAuthFlowService` (`web-oauth-flow.ts`) implements the core |
| 45 | +`IAuthOAuthFlowService` with a browser PKCE flow: popup to |
| 46 | +`{cloud}/oauth/authorize`, redirect back to `{origin}/callback`, code relayed |
| 47 | +to the opener tab over a BroadcastChannel, token exchange via fetch. Session |
| 48 | +persistence is localStorage (`web-auth-adapters.ts`); the refresh token is |
| 49 | +encrypted at rest with AES-GCM under a **non-extractable** Web Crypto key held |
| 50 | +in IndexedDB (`webAuthTokenCipher`). The key round-trips through structured |
| 51 | +clone but its raw bytes are never exposed to JS, so a stolen localStorage dump |
| 52 | +can't be decrypted offline — matching the bar the desktop host's machine-bound |
| 53 | +cipher sets. A live XSS payload can still ask the key to decrypt while it runs |
| 54 | +on the page (httpOnly cookies would need server-side sessions the cloud host |
| 55 | +doesn't have). Tokens written by the earlier plaintext build fail to decrypt |
| 56 | +and are cleared, forcing a clean re-auth. |
| 57 | + |
| 58 | +## Hosting |
| 59 | + |
| 60 | +Build a static bundle and serve it: |
| 61 | + |
| 62 | +```bash |
| 63 | +pnpm --filter @posthog/web build # Vite output: apps/web/dist |
| 64 | +``` |
| 65 | + |
| 66 | +Serve `dist/` as a single-page app with a fallback to `index.html` for unknown |
| 67 | +paths. The OAuth popup lands on the real path `/callback` (`OAUTH_CALLBACK_PATH` |
| 68 | +in `web-oauth-flow.ts`, dispatched in `main.tsx`), which must load the SPA to |
| 69 | +relay the code back to the opener tab. The app's own routes use hash history, so |
| 70 | +`/callback` is the only real path that needs the fallback. |
| 71 | + |
| 72 | +The build is code-split: vendor libraries into cacheable groups (`manualChunks` |
| 73 | +in `vite.config.ts`) and each route's component into its own lazy chunk (the |
| 74 | +TanStack Router plugin's `autoCodeSplitting`, mirroring `apps/code`), so a screen |
| 75 | +downloads only when navigated to. All emitted assets are content-hashed — serve |
| 76 | +`dist/assets/` with a long-lived immutable `Cache-Control` and only `index.html` |
| 77 | +short-lived, so returning users re-download just the chunks that changed. |
| 78 | + |
| 79 | +The steps below are one-time setup for a deployed origin. None block the app |
| 80 | +from booting, but auth, attachments, and integrations each need one. |
| 81 | + |
| 82 | +### Environment variables |
| 83 | + |
| 84 | +All are Vite build-time vars (`import.meta.env.*`), baked in at build time. |
| 85 | + |
| 86 | +| Var | Required | Purpose | |
| 87 | +| --- | --- | --- | |
| 88 | +| `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`). | |
| 89 | +| `VITE_POSTHOG_API_HOST` | No | posthog-js ingestion host. Default `https://internal-c.posthog.com`. | |
| 90 | +| `VITE_POSTHOG_UI_HOST` | No | posthog-js UI host. Default `https://us.i.posthog.com`. | |
| 91 | +| `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. | |
| 92 | + |
| 93 | +Session recording turns on whenever posthog-js initializes (any build with a |
| 94 | +real key); automatic unhandled-error/rejection/console capture is additionally |
| 95 | +gated to non-dev (production) builds (`capture_exceptions` in |
| 96 | +`posthogAnalyticsImpl.ts`). |
| 97 | + |
| 98 | +### OAuth redirect URI registration — required for sign-in |
| 99 | + |
| 100 | +The web host reuses the Code ("Array") OAuth application client ids |
| 101 | +(`packages/shared/src/oauth.ts`). Each region stores its app's `redirect_uris` |
| 102 | +as database rows (Django admin → OAuth applications), and they must include: |
| 103 | + |
| 104 | +- `https://<web-origin>/callback` for the deployed host. `http` is rejected for |
| 105 | + non-loopback hosts by `OAuthApplication` redirect validation, so the origin |
| 106 | + must be HTTPS. |
| 107 | +- `http://localhost/callback` (portless) for local dev — PostHog's authorize |
| 108 | + view extends RFC 8252 §7.3 loopback port flexibility to `localhost` |
| 109 | + (`posthog/api/oauth/views.py: validate_redirect_uri`), so the portless form |
| 110 | + matches the Vite dev server on any port. If desktop dev builds can already |
| 111 | + sign in to the region, check whether the registered localhost URI is portless |
| 112 | + or pinned to `:8237`. |
| 113 | + |
| 114 | +A CIMD client (the `raycast_metadata.py` / `wizard_metadata.py` pattern in |
| 115 | +`posthog/api/oauth/`) is NOT suitable: CIMD registrations are capped to |
| 116 | +unprivileged scopes, and Code requires `scope=*` like the desktop app. |
| 117 | + |
| 118 | +### S3 artifact-bucket CORS — required for attachment uploads |
| 119 | + |
| 120 | +Composer attachments upload straight from the browser via an S3 presigned POST |
| 121 | +(`.../artifacts/prepare_upload/` returns the presigned post, then a `POST` to |
| 122 | +`s3.<region>.amazonaws.com/<bucket>`). The bucket |
| 123 | +(`posthog-cloud-prod-us-east-1-app-assets` for US) must return |
| 124 | +`Access-Control-Allow-Origin` for the web origin or the browser blocks the |
| 125 | +response — the POST itself returns `204` (it succeeds server-side) but `fetch` |
| 126 | +rejects with a bare `NetworkError`. Desktop (Electron/Node `fetch`) is not |
| 127 | +subject to CORS, so this is web-only. |
| 128 | + |
| 129 | +Add the deployed web origin (and `http://localhost:5273` for dev) with the |
| 130 | +`POST` method to the bucket's CORS config. Until then, attaching + preview work |
| 131 | +but sending a task with an attachment fails at the upload step. |
| 132 | + |
| 133 | +### `posthog_web` integration connect origin — required for Slack / GitHub connect |
| 134 | + |
| 135 | +PostHog brokers the Slack/GitHub OAuth server-side and, on completion, redirects |
| 136 | +to a target chosen by the `connect_from` value (`posthog_code` → |
| 137 | +`posthog-code://…`, `posthog_mobile` → `posthog://…`) — a per-known-client |
| 138 | +mapping, not an open redirect. The web host's `startFlow` opens |
| 139 | +`.../integrations/authorize/?kind={slack|github}&next=…&connect_from=posthog_web` |
| 140 | +in a tab; the backend needs a `posthog_web` mapping for the flow to complete. No |
| 141 | +callback relay is required because the integration is created server-side and the |
| 142 | +connect hooks refetch `getIntegrations()` on window-focus. See Known gaps for |
| 143 | +what remains once the mapping exists. |
| 144 | + |
| 145 | +### CORS — no action needed |
| 146 | + |
| 147 | +Verified against `us.posthog.com`: `/oauth/token` answers preflight with the |
| 148 | +request origin allowed, and `/api/*` responds `access-control-allow-origin: *` |
| 149 | +with `authorization` in the allowed headers. The agent-proxy stream service has |
| 150 | +a CORS origin allowlist (`TASKS_AGENT_PROXY_CORS_ORIGINS` in |
| 151 | +`PostHog/posthog/services/agent-proxy`), and `CloudTaskService` falls back to |
| 152 | +the CORS-open Django stream leg regardless. |
| 153 | + |
| 154 | +## Known gaps |
| 155 | + |
| 156 | +Limitations that remain even after the hosting setup above — each needs a code |
| 157 | +or backend change, not just configuration. |
| 158 | + |
| 159 | +- **Slack / GitHub connect** is client-wired (`startFlow` opens the authorize |
| 160 | + URL; the connection is detected via a window-focus refetch of |
| 161 | + `getIntegrations()`, not a callback relay) but gated on backend support for the |
| 162 | + `posthog_web` connect origin (see Hosting). Even with that mapping, the GitHub |
| 163 | + *user* flow (`POST /api/users/@me/integrations/github/start/`) hardcodes |
| 164 | + `connect_from: "posthog_code"` in `@posthog/api-client` and returns **400** on |
| 165 | + web — it needs a host-parameterized `connect_from`. Where a project already has |
| 166 | + the integration connected, the settings pages render the connected/manage state |
| 167 | + correctly. |
| 168 | +- **Per-device stores** (cloud workspaces, archive, pins, browser tabs) are |
| 169 | + localStorage-only — not durable across devices or a site-data clear. Desktop |
| 170 | + persists these in SQLite; the browser host needs server-side state to match. |
| 171 | +- **Skill dependency expansion** is a passthrough: a skill that declares |
| 172 | + `dependencies:` on other skills won't pull them in automatically (pick them |
| 173 | + explicitly). This is a pipeline gap, not just a web gap — `exportSkill` strips |
| 174 | + SKILL.md frontmatter and the team-skills API has no `dependencies` field, so |
| 175 | + the dependency list never reaches any client (desktop only expands local |
| 176 | + on-disk skills). Needs `dependencies` carried end-to-end through |
| 177 | + export → publish → the LlmSkill API (backend) → `fetchSkillForInstall`. |
0 commit comments