diff --git a/AGENTS.md b/AGENTS.md index b9e3e21ac7..b0e63c4e75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). 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` 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 diff --git a/apps/web/src/web-archive-store.ts b/apps/web/src/web-archive-store.ts index 663ce1c12e..df2a09fd87 100644 --- a/apps/web/src/web-archive-store.ts +++ b/apps/web/src/web-archive-store.ts @@ -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 @@ -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 { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? (JSON.parse(raw) as Record) : {}; - } 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 = load(); +export type WebArchivedTask = z.infer; -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 { @@ -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); }, }; diff --git a/apps/web/src/web-auth-adapters.ts b/apps/web/src/web-auth-adapters.ts index 4179c6cf5c..a0d6a7b13b 100644 --- a/apps/web/src/web-auth-adapters.ts +++ b/apps/web/src/web-auth-adapters.ts @@ -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 @@ -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(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); } } @@ -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(PREFERENCES_KEY, () => ({ + accounts: {}, + orgProjects: {}, + })); } private write(preferences: StoredPreferences): void { - window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences)); + writeJsonStrict(PREFERENCES_KEY, preferences); } } diff --git a/apps/web/src/web-browser-tabs-store.ts b/apps/web/src/web-browser-tabs-store.ts index fc486e2017..44286a9e2f 100644 --- a/apps/web/src/web-browser-tabs-store.ts +++ b/apps/web/src/web-browser-tabs-store.ts @@ -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. // @@ -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 { @@ -158,11 +155,7 @@ class WebBrowserTabsStore extends TypedEventEmitter { } 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); } } diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index b3ecf20d45..ebeacc79b1 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -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); diff --git a/apps/web/src/web-local-store.ts b/apps/web/src/web-local-store.ts new file mode 100644 index 0000000000..c08c58e47b --- /dev/null +++ b/apps/web/src/web-local-store.ts @@ -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(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(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. + } +} + +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). + } +} + +// 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(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( + key: string, + schema: S, + fallback: () => z.infer, +): z.infer { + 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 { + 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`: 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( + key: string, + entrySchema: S, +): JsonStore>> { + const load = (): Record> => { + const rawRecord = readJson(key, () => ({})); + if (typeof rawRecord !== "object" || rawRecord === null) return {}; + const valid: Record> = {}; + 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); + }, + removeItem: (name: string): void => { + window.localStorage.removeItem(name); + }, +}; diff --git a/apps/web/src/web-storage.ts b/apps/web/src/web-storage.ts index 0e32f39721..6412dadf9f 100644 --- a/apps/web/src/web-storage.ts +++ b/apps/web/src/web-storage.ts @@ -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); diff --git a/apps/web/src/web-task-metadata-store.ts b/apps/web/src/web-task-metadata-store.ts index de160e07ba..b7c25182b5 100644 --- a/apps/web/src/web-task-metadata-store.ts +++ b/apps/web/src/web-task-metadata-store.ts @@ -1,14 +1,19 @@ +import { z } from "zod"; +import { createRecordStore } from "./web-local-store"; + // Per-device task metadata (pins + viewed/activity timestamps) for the web host, // backed by localStorage. Desktop persists this in a local metadata service // (workspace.getPinnedTaskIds / togglePin / getAllTaskTimestamps / markViewed / // markActivity). The archive flow reads pins early (getPinnedTaskIds + unpin), // so without these the whole archive rejects — hence this store. -export interface TaskMetadata { - pinnedAt: string | null; - lastViewedAt: string | null; - lastActivityAt: string | null; -} +const taskMetadataSchema = z.object({ + pinnedAt: z.string().nullable(), + lastViewedAt: z.string().nullable(), + lastActivityAt: z.string().nullable(), +}); + +export type TaskMetadata = z.infer; const EMPTY: TaskMetadata = { pinnedAt: null, @@ -16,51 +21,35 @@ const EMPTY: TaskMetadata = { lastActivityAt: null, }; -const STORAGE_KEY = "posthog-code:web-task-metadata"; - -function load(): Record { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? (JSON.parse(raw) as Record) : {}; - } catch { - return {}; - } -} - -let metadata: Record = load(); - -function persist(): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(metadata)); - } catch { - // Best-effort persistence. - } -} +const store = createRecordStore( + "posthog-code:web-task-metadata", + taskMetadataSchema, +); function update(taskId: string, patch: Partial): TaskMetadata { - const next = { ...(metadata[taskId] ?? EMPTY), ...patch }; - metadata = { ...metadata, [taskId]: next }; - persist(); + const current = store.get(); + const next = { ...(current[taskId] ?? EMPTY), ...patch }; + store.set({ ...current, [taskId]: next }); return next; } export const webTaskMetadataStore = { getAll(): Record { - return metadata; + return store.get(); }, get(taskId: string): TaskMetadata { - return metadata[taskId] ?? EMPTY; + return store.get()[taskId] ?? EMPTY; }, getPinnedTaskIds(): string[] { - return Object.entries(metadata) + return Object.entries(store.get()) .filter(([, m]) => m.pinnedAt !== null) .map(([taskId]) => taskId); }, togglePin(taskId: string): { isPinned: boolean; pinnedAt: string | null } { - const current = metadata[taskId] ?? EMPTY; + const current = store.get()[taskId] ?? EMPTY; const pinnedAt = current.pinnedAt ? null : new Date().toISOString(); update(taskId, { pinnedAt }); return { isPinned: pinnedAt !== null, pinnedAt }; @@ -75,9 +64,9 @@ export const webTaskMetadataStore = { }, remove(taskId: string): void { - if (!(taskId in metadata)) return; - const { [taskId]: _removed, ...rest } = metadata; - metadata = rest; - persist(); + const current = store.get(); + if (!(taskId in current)) return; + const { [taskId]: _removed, ...rest } = current; + store.set(rest); }, }; diff --git a/apps/web/src/web-workspace-store.ts b/apps/web/src/web-workspace-store.ts index 9fb45f2b0f..5108e23694 100644 --- a/apps/web/src/web-workspace-store.ts +++ b/apps/web/src/web-workspace-store.ts @@ -1,4 +1,5 @@ -import type { Workspace } from "@posthog/shared"; +import { type Workspace, workspaceSchema } from "@posthog/shared"; +import { createRecordStore } from "./web-local-store"; // Per-device cloud-workspace registry for the web host, backed by localStorage. // @@ -10,36 +11,20 @@ import type { Workspace } from "@posthog/shared"; // survives reloads via localStorage. Scope matches desktop: cloud tasks created // in THIS browser appear in the sidebar. -const STORAGE_KEY = "posthog-code:web-cloud-workspaces"; - -function load(): Record { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? (JSON.parse(raw) as Record) : {}; - } catch { - return {}; - } -} - -let workspaces: Record = load(); - -function persist(): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(workspaces)); - } catch { - // Best-effort: a storage failure only costs sidebar persistence, not the task. - } -} +const store = createRecordStore( + "posthog-code:web-cloud-workspaces", + workspaceSchema, +); export const webWorkspaceStore = { getAll(): Record { - return workspaces; + return store.get(); }, /** Register (or overwrite) a cloud workspace for a task. */ addCloud(taskId: string, branch: string | null, createdAt: string): void { - workspaces = { - ...workspaces, + store.set({ + ...store.get(), [taskId]: { taskId, folderId: "", @@ -52,14 +37,13 @@ export const webWorkspaceStore = { linkedBranch: null, createdAt, }, - }; - persist(); + }); }, remove(taskId: string): void { - if (!(taskId in workspaces)) return; - const { [taskId]: _removed, ...rest } = workspaces; - workspaces = rest; - persist(); + const current = store.get(); + if (!(taskId in current)) return; + const { [taskId]: _removed, ...rest } = current; + store.set(rest); }, };