Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
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
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,19 @@ node scripts/check-host-boundaries.mjs --prune

Do not use `--init` to baseline new violations.

## Web Host

`apps/web` is a real host and the portability smoke test: if a `@posthog/core`/`@posthog/ui` change compiles and boots on desktop but not here, it leaked a host dependency. It is **cloud-only** — no local filesystem, git, pty, or worktrees — so it binds `HOST_CAPABILITIES` to `{ localWorkspaces: false }` and stubs local-only host clients to reject at call time.

Building a feature for web means: the portable core/UI already runs unchanged; you only supply web adapters and bind them.

- **Composition root** is `apps/web/src/web-container.ts` (`WebBindings` + `TypedContainer<WebBindings>`). It loads the same core/UI feature modules `apps/code`'s renderer does, then binds web adapters (the `web-*.ts` files) for each platform/host capability. Unlike desktop, module-loading and adapter-binding live in this one file, not split into `desktop-contributions.ts` / `desktop-services.ts`.
- **Transport** is the entire desktop↔web difference. `web-trpc.ts` builds `HOST_TRPC_CLIENT` from an in-process `unstable_localLink` over `web-host-router.ts` (a subset of `HostRouter`, same procedure shapes) instead of Electron's `ipcLink` — no HTTP hop; the backing services are host-agnostic core code resolved from the root container per call. Both hosts use the `superjson` transformer; keep them in sync (the host-trpc base sets `transformer: superjson`).
- **New host capability?** Add a `@posthog/platform` interface (host-neutral) and a web adapter under `apps/web/src`, then bind it in `web-container.ts`. If the shared app resolves it eagerly at `__root` via `useService`, an unbound token crashes the tree — `assertHostCapabilities(container, REQUIRED_HOST_CAPABILITIES)` at the end of `web-container.ts` catches that at boot instead of on first navigation.
- **Persistence.** localStorage is the web host's single persistence layer; route all access through `web-local-store.ts` — `createRecordStore(key, entrySchema)` for the per-device `Record<string, Entry>` registries, `readValidated(key, schema, fallback)` for a single persisted object, the raw `readJson`/`writeJson`/`removeKey` primitives for anything without a schema, and `rawLocalStorage` for the zustand backend. Do not call `window.localStorage` directly. Persisted stores are discardable per-device caches validated against a Zod schema on read (invalid data is dropped and rebuilt), so evolving a shape is a schema edit, not a hand-written migration. IndexedDB is reserved for exactly one thing — the non-extractable auth cipher key in `web-auth-adapters.ts` — because localStorage cannot hold a `CryptoKey` without exposing its raw bytes; do not add other IndexedDB usage or move that key to localStorage.
- **Boot** is `main.tsx`: import `./web-storage` first (registers the persistence backend before stores construct), then the container, `setRootContainer`, `boot()`.
- **Commands.** `pnpm --filter @posthog/web dev` (Vite dev server), `build`, `typecheck`. E2E: `pnpm --filter @posthog/web test:e2e` (Playwright, `tests/e2e/`). There is no Vitest unit suite in `apps/web`.

## Structure

```text
Expand Down
60 changes: 24 additions & 36 deletions apps/web/src/web-archive-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { z } from "zod";
import { createRecordStore } from "./web-local-store";

// Per-device archived-task registry for the web host, backed by localStorage.
//
// On desktop, archiving is a LOCAL operation: it trashes the task's local
Expand All @@ -6,44 +9,30 @@
// archiving is purely "hide this task from my sidebar on this device", which
// this store persists. Shape mirrors the workspace store (web-workspace-store).

export interface WebArchivedTask {
taskId: string;
archivedAt: string;
folderId: string;
mode: "worktree" | "local" | "cloud";
worktreeName: string | null;
branchName: string | null;
checkpointId: string | null;
}

const STORAGE_KEY = "posthog-code:web-archived-tasks";

function load(): Record<string, WebArchivedTask> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Record<string, WebArchivedTask>) : {};
} catch {
return {};
}
}
const webArchivedTaskSchema = z.object({
taskId: z.string(),
archivedAt: z.string(),
folderId: z.string(),
mode: z.enum(["worktree", "local", "cloud"]),
worktreeName: z.string().nullable(),
branchName: z.string().nullable(),
checkpointId: z.string().nullable(),
});

let archived: Record<string, WebArchivedTask> = load();
export type WebArchivedTask = z.infer<typeof webArchivedTaskSchema>;

function persist(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(archived));
} catch {
// Best-effort persistence.
}
}
const store = createRecordStore(
"posthog-code:web-archived-tasks",
webArchivedTaskSchema,
);

export const webArchiveStore = {
list(): WebArchivedTask[] {
return Object.values(archived);
return Object.values(store.get());
},

ids(): string[] {
return Object.keys(archived);
return Object.keys(store.get());
},

add(taskId: string, archivedAt: string): WebArchivedTask {
Expand All @@ -56,15 +45,14 @@ export const webArchiveStore = {
branchName: null,
checkpointId: null,
};
archived = { ...archived, [taskId]: entry };
persist();
store.set({ ...store.get(), [taskId]: entry });
return entry;
},

remove(taskId: string): void {
if (!(taskId in archived)) return;
const { [taskId]: _removed, ...rest } = archived;
archived = rest;
persist();
const current = store.get();
if (!(taskId in current)) return;
const { [taskId]: _removed, ...rest } = current;
store.set(rest);
},
};
29 changes: 11 additions & 18 deletions apps/web/src/web-auth-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
} from "@posthog/core/auth/identifiers";
import type { IPowerManager } from "@posthog/platform/power-manager";
import type { CloudRegion } from "@posthog/shared";
import { readJson, removeKeyStrict, writeJsonStrict } from "./web-local-store";

// Web counterparts of the desktop auth adapters. Desktop persists the session
// in workspace-server SQLite behind a machine-bound node:crypto cipher and
Expand All @@ -22,22 +23,17 @@ const PREFERENCES_KEY = "posthog-code:auth-preferences";

export class WebAuthSessionStore implements IAuthSessionStore {
getCurrent(): AuthSessionRecord | null {
const raw = window.localStorage.getItem(SESSION_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as AuthSessionRecord;
} catch {
window.localStorage.removeItem(SESSION_KEY);
return null;
}
return readJson<AuthSessionRecord | null>(SESSION_KEY, () => null);
}

saveCurrent(input: PersistAuthSessionRecord): void {
window.localStorage.setItem(SESSION_KEY, JSON.stringify(input));
writeJsonStrict(SESSION_KEY, input);
}

clearCurrent(): void {
window.localStorage.removeItem(SESSION_KEY);
// Strict: a swallowed failure here would report logout as complete while the
// session stays in localStorage, recoverable on reload. Let it propagate.
removeKeyStrict(SESSION_KEY);
}
}

Expand Down Expand Up @@ -79,17 +75,14 @@ export class WebAuthPreferenceStore implements IAuthPreferenceStore {
}

private read(): StoredPreferences {
const raw = window.localStorage.getItem(PREFERENCES_KEY);
if (!raw) return { accounts: {}, orgProjects: {} };
try {
return JSON.parse(raw) as StoredPreferences;
} catch {
return { accounts: {}, orgProjects: {} };
}
return readJson<StoredPreferences>(PREFERENCES_KEY, () => ({
accounts: {},
orgProjects: {},
}));
}

private write(preferences: StoredPreferences): void {
window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences));
writeJsonStrict(PREFERENCES_KEY, preferences);
}
}

Expand Down
15 changes: 4 additions & 11 deletions apps/web/src/web-browser-tabs-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
type TabsSnapshot,
type TabTarget,
TypedEventEmitter,
tabsSnapshotSchema,
} from "@posthog/shared";
import { readValidated, writeJson } from "./web-local-store";

// Per-device browser-tab strip for the web host, backed by localStorage.
//
Expand All @@ -33,12 +35,7 @@ const EMPTY_SNAPSHOT: TabsSnapshot = { windows: [], tabs: [] };
type SnapshotChangeEvents = { snapshotChange: TabsSnapshot };

function load(): TabsSnapshot {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as TabsSnapshot) : EMPTY_SNAPSHOT;
} catch {
return EMPTY_SNAPSHOT;
}
return readValidated(STORAGE_KEY, tabsSnapshotSchema, () => EMPTY_SNAPSHOT);
}

class WebBrowserTabsStore extends TypedEventEmitter<SnapshotChangeEvents> {
Expand Down Expand Up @@ -158,11 +155,7 @@ class WebBrowserTabsStore extends TypedEventEmitter<SnapshotChangeEvents> {
}

private persist(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.snapshot));
} catch {
// Best-effort: a storage failure only costs tab persistence across reloads.
}
writeJson(STORAGE_KEY, this.snapshot);
}
}

Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/web-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,8 +814,9 @@ container.bind(REPORT_MODEL_RESOLVER).toConstantValue({

// Fail loudly at composition time if a capability the shared app resolves via
// service location is unbound, instead of limping to the first navigation that
// needs it (how the missing reportModelResolver first surfaced). CI locks this
// in via web-container.test.ts.
// needs it (how the missing reportModelResolver first surfaced). This runs at
// module load, so any boot — including the e2e smoke run — trips an unbound
// required capability immediately.
assertHostCapabilities(container, REQUIRED_HOST_CAPABILITIES);

setRootContainer(container);
148 changes: 148 additions & 0 deletions apps/web/src/web-local-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { z } from "zod";

// The single seam for the web host's browser persistence. Every per-device
// store routes its reads and writes through here so the
// getItem -> JSON.parse -> guard -> JSON.stringify -> setItem boilerplate lives
// in exactly one place instead of being hand-rolled in each store.
//
// localStorage is the web host's one persistence layer. IndexedDB is used in
// exactly one place (web-auth-adapters.ts) and is deliberately NOT routed
// through here: it holds the non-extractable AES-GCM cipher key, which
// localStorage physically cannot store without exposing its raw bytes as a
// string — the very property that keeps a stolen token dump undecryptable
// offline. That is a key vault, not app state, so it stays separate.
//
// Versioning: every store here is a discardable per-device cache that rebuilds
// from the server or re-derives, so "drop what no longer fits and rebuild" IS
// the migration strategy. The validated readers below (readValidated,
// createRecordStore) parse persisted data against a Zod schema on load and shed
// anything that fails — a shape change needs only a schema edit, never a
// hand-written localStorage migration.
//
// Two write tiers. The best-effort helpers (writeJson/removeKey) swallow storage
// failures because for a rebuildable cache a dropped write only costs persistence
// across reloads, never correctness. The *Strict variants propagate the failure
// and MUST be used where a silently-dropped write would be a correctness or
// security bug — the auth session/preferences, where a clear() that looked like
// it succeeded but didn't would leave a stale session recoverable on reload.

export function readJson<T>(key: string, fallback: () => T): T {
try {
const raw = window.localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback();
} catch {
// Absent, corrupt, or unparseable: fall back rather than throw. Callers
// treat missing state as empty.
return fallback();
}
}

export function writeJson<T>(key: string, value: T): void {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Best-effort: a storage failure (quota, privacy mode) only costs
// persistence across reloads, never correctness — the in-memory value is
// still authoritative for this session. Use writeJsonStrict where a dropped
// write must surface.
}
}
Comment thread
gantoine marked this conversation as resolved.

export function removeKey(key: string): void {
try {
window.localStorage.removeItem(key);
} catch {
// Best-effort, same rationale as writeJson. Use removeKeyStrict where a
// dropped removal must surface (e.g. clearing an auth session on logout).
}
}
Comment thread
gantoine marked this conversation as resolved.

// Strict write/remove: let storage failures propagate so the caller can react
// instead of treating a dropped write as success. For auth state (see the two-
// tier note above), not the rebuildable caches.
export function writeJsonStrict<T>(key: string, value: T): void {
window.localStorage.setItem(key, JSON.stringify(value));
}

export function removeKeyStrict(key: string): void {
window.localStorage.removeItem(key);
}

// Schema-validated read of a single persisted object. Parses the JSON, checks it
// against `schema`, and returns the fallback on any failure (absent, corrupt, or
// a shape that no longer matches — see the versioning note above).
export function readValidated<S extends z.ZodType>(
key: string,
schema: S,
fallback: () => z.infer<S>,
): z.infer<S> {
let raw: string | null;
try {
raw = window.localStorage.getItem(key);
} catch {
return fallback();
}
if (!raw) return fallback();
try {
const result = schema.safeParse(JSON.parse(raw));
return result.success ? result.data : fallback();
} catch {
// Invalid JSON.
return fallback();
}
}

export interface JsonStore<T> {
get(): T;
set(value: T): void;
clear(): void;
}

// A cached live store for the per-device registries (workspaces, archive, task
// metadata), which are all `Record<string, Entry>`: load once at construction,
// keep the value in memory, write through on every set. Each entry is validated
// against `entrySchema` on load and invalid entries are dropped individually, so
// a shape change sheds only the stale rows instead of nuking the whole map.
export function createRecordStore<S extends z.ZodType>(
key: string,
entrySchema: S,
): JsonStore<Record<string, z.infer<S>>> {
const load = (): Record<string, z.infer<S>> => {
const rawRecord = readJson<unknown>(key, () => ({}));
if (typeof rawRecord !== "object" || rawRecord === null) return {};
const valid: Record<string, z.infer<S>> = {};
for (const [id, value] of Object.entries(rawRecord)) {
const result = entrySchema.safeParse(value);
if (result.success) valid[id] = result.data;
}
return valid;
};

let cache = load();
return {
get: () => cache,
set: (value) => {
cache = value;
writeJson(key, value);
},
clear: () => {
cache = {};
removeKey(key);
},
};
}

// StateStorage backend for @posthog/ui's zustand persist (web-storage.ts).
// Zustand already serializes, so this passes raw strings straight through. It
// also lets storage errors propagate: the renderer persistence layer awaits and
// logs failed writes, so swallowing here would report a dropped draft/setting/
// layout write as a success that vanishes on reload.
export const rawLocalStorage = {
getItem: (name: string): string | null => window.localStorage.getItem(name),
setItem: (name: string, value: string): void => {
window.localStorage.setItem(name, value);
},
Comment thread
gantoine marked this conversation as resolved.
removeItem: (name: string): void => {
window.localStorage.removeItem(name);
},
};
14 changes: 4 additions & 10 deletions apps/web/src/web-storage.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import { registerRendererStateStorage } from "@posthog/ui/shell/rendererStorage";
import { rawLocalStorage } from "./web-local-store";

// Web persistence backend for @posthog/ui stores (drafts, settings, layout).
// Desktop persists through the host; web uses origin-scoped localStorage.
registerRendererStateStorage({
getItem: (name) => window.localStorage.getItem(name),
setItem: (name, value) => {
window.localStorage.setItem(name, value);
},
removeItem: (name) => {
window.localStorage.removeItem(name);
},
});
// Desktop persists through the host; web uses origin-scoped localStorage via the
// shared seam (web-local-store).
registerRendererStateStorage(rawLocalStorage);
Loading
Loading