diff --git a/CHANGELOG.md b/CHANGELOG.md index 25cd9d9..41d068b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,186 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.15.0 — 2026-05-28 + +**Minor — no schema migration; one new build-time env var bound via +`flotilla overlay set`.** This release ships Amplitude Analytics + +Session Replay instrumentation (roadmap item #13). The frontend +gains a small wrapper around `@amplitude/unified` that gates SDK +initialization on the v0.13.0 cookie/privacy consent — the SDK is +never loaded for visitors who have not granted analytics consent, +no session is recorded, no network request fires; a later consent +flip to `denied` calls `setOptOut(true)` so events and session +replay stop immediately. The wrapper exposes a stable taxonomy of +nine events (Page Viewed, RFC Viewed, User Signed In / Signed Out, +RFC Proposed, PR Opened, Comment Posted, Beta Access Requested, Admin +Permission Decision) wired into the existing routes, the Login flow, +the propose / open-PR / discussion / PR-review surfaces, and the +admin grant/revoke action. Event bodies carry only ids and enums; no +free-text fields (titles, comment bodies, names, emails) are ever +sent. **Session replay** records sessions at `sampleRate: 1` (100%) +— vendor-recommended default; gated by the same v0.13.0 analytics +consent. The Amplitude API key is read from `VITE_AMPLITUDE_API_KEY` +at build time; when unset, the wrapper logs one console warning and +no-ops so dev environments without analytics keep working. No backend +events ship in this release — Amplitude SaaS holds the events, +nothing lands in our DB, no migration. + +### Added + +- **Analytics wrapper** (`frontend/src/lib/analytics.js`). Public + surface: `track(name, props)`, `identify({ user_id, properties? })`, + `setUserProperties(properties)`, `anonymize()`, the `EVENTS` + taxonomy constant, and a `__resetForTests` helper. Internally + lazy-imports `@amplitude/unified` and calls + `amplitude.initAll(API_KEY, { analytics: { autocapture: true }, + sessionReplay: { sampleRate: 1 } })` only after consent is + granted; queues pre-init calls and drains them on init resolve; + flips `setOptOut(true)` on a granted→denied consent change (stops + both analytics events and session replay). The wrapper subscribes + to `onConsentChange()` so a freshly-banner-clicked "analytics on" + flips the SDK live without a page reload. +- **User identity lifecycle** (per `ohm-rfc/ROADMAP.md` #21 Part C — + shipped inline with v0.15.0 instead of waiting for a follow-up). + `identify({ user_id, properties })` accepts a property bag that + applies as an Amplitude `Identify` event with `.set()` semantics + by default; values wrapped as `['__setOnce__', value]` apply with + `.setOnce()` semantics (immutable after first write — for + account-history markers like `first_sign_in_at`). The new + `setUserProperties(properties)` exposes the same property-apply + path for mid-session state changes (role grant/revoke, passcode + set, device trusted) so the Amplitude record stays current without + waiting for the next sign-in. `anonymize()` now clears both the + user_id binding AND the pending-property cache so a subsequent + sign-in as a different user starts with a fully fresh slate. +- **Event taxonomy** wired into the app: + - `Page Viewed` — fires from `App.jsx` on every route change with + `path` (`location.pathname`); the location hook owns the firing + and dedupes by path. + - `RFC Viewed` — fires from `RFCView.jsx` once per slug load with + `rfc_slug` and `rfc_id`. + - `User Signed In` — fires from `Login.jsx` with + `method ∈ { 'otc', 'passcode', 'trust-device' }` matching the + three sign-in paths from v0.7.0 / v0.10.0 / v0.11.0. + - `User Signed Out` — fires from `App.jsx`'s "Sign out" click, + followed by `anonymize()` to clear the SDK's user binding before + the hard nav to `/auth/logout`. + - `RFC Proposed` — fires from `ProposeModal.jsx` on submit success + with `rfc_slug`. + - `PR Opened` — fires from `PRModal.jsx` on submit success with + `rfc_slug` and `pr_number`. + - `Comment Posted` — fires from `RFCDiscussionPanel.jsx` + (`surface: 'discussion'`) and from `PRView.jsx` + (`surface: 'pr'`, with `pr_number`) on each post-success. + - `Beta Access Requested` — fires from `Login.jsx` capture-profile + submit success. No PII in the event. + - `Admin Permission Decision` — fires from `Admin.jsx`'s grant / + revoke action with `action ∈ { 'grant', 'revoke' }` and + `target_user_id` (string). +- **User binding + properties** (`App.jsx`): when `me.authenticated` + lands and a user id is available, the wrapper's + `identify({ user_id, properties })` is called with + `String(viewer.id)` AND a durable property bag — `role`, + `permission_state`, `passcode_set`, `device_trusted` (mutable; + refresh each sign-in), plus `first_sign_in_at` and + `account_created_at` (setOnce — immutable user-history markers). + The sign-out gesture calls `anonymize()` before the nav. No email, + display name, gitea_login, or other PII is passed through the SDK — + Amplitude only sees opaque ids, enums, timestamps, booleans. +- **`@amplitude/unified`** dependency added to + `frontend/package.json` (analytics + session replay in one + install). Lockfile updated. +- **`VITE_AMPLITUDE_API_KEY`** documented in `frontend/.env.example` + with the secret-vs-overlay binding caveat (see below). + +### Changed + +- **`frontend/src/App.jsx`** — adds `useLocation` for the route-change + Page Viewed firing, a `lastUserIdRef` memo to call + `identify` once per signed-in viewer, and an `onClick` handler on + the "Sign out" link that fires `User Signed Out` + `anonymize()` + before the hard nav. +- **`frontend/src/components/Login.jsx`** — fires `User Signed In` + with the appropriate `method` at each of the three sign-in points + (trust-device cookie path, passcode verify success, OTC verify + success), and fires `Beta Access Requested` on capture-profile + submit success. +- **`frontend/src/components/ProposeModal.jsx`** — fires `RFC Proposed` + with `rfc_slug` on submit success. +- **`frontend/src/components/RFCView.jsx`** — fires `RFC Viewed` + inside the `getRFC` resolution so the event is keyed on the slug + param and includes the loaded `rfc_id`. +- **`frontend/src/components/PRModal.jsx`** — fires `PR Opened` with + `rfc_slug` and `pr_number` on submit success. +- **`frontend/src/components/RFCDiscussionPanel.jsx`** — fires + `Comment Posted` with `surface: 'discussion'` on send-success. +- **`frontend/src/components/PRView.jsx`** — fires `Comment Posted` + with `surface: 'pr'` and `pr_number` on review-comment success. +- **`frontend/src/components/Admin.jsx`** — fires + `Admin Permission Decision` on grant/revoke success. + +### Migration + +- **No schema migration.** Amplitude SaaS holds the events; the + framework's DB is unchanged. Migration slot **015** is unused by + this release and remains available for the next minor that needs a + schema bump. + +### Caveat — overlay binding for `VITE_AMPLITUDE_API_KEY` + +Amplitude browser API keys are embedded in the frontend bundle at +build time and visible to anyone with browser dev tools. They are +public by design — same nature as the v0.12.0 +`VITE_TURNSTILE_SITE_KEY` (also public, also bundle-embedded, +explicitly contrasted with `CLOUDFLARE_TURNSTILE_SECRET` which is +the real secret-half of that pair). The Amplitude installation +guidance from the vendor shows the key inline as a literal string +argument to `initAll(…)`, confirming the public framing. This +release accordingly binds the value via `flotilla overlay set`, +not `flotilla secret set` — the env-var name is `VITE_AMPLITUDE_API_KEY` +(Vite-prefix convention, so the build picks it up directly without +an alias step). + +(Roadmap row #13 originally said "new secret: AMPLITUDE_API_KEY"; +that wording predated vendor consultation. Mid-Session-L the +operator provisioned the Amplitude project, surfaced the vendor's +recommended init prompt, and the binding settled as overlay. The +roadmap row will be updated to match when #13 ships.) + +### Caveat — session replay scope and consent + +This release enables Amplitude Session Replay at `sampleRate: 1` +(100% of sessions recorded for full-DOM playback). The vendor's +installation wizard recommends this default for new deployments — +maximum learning during the early phase. The v0.13.0 single +"analytics" consent toggle gates session replay together with +events, so no recording happens without explicit opt-in. A future +release **MAY** split this into a separate consent category for +session replay specifically (recording has a meaningfully larger +privacy footprint than event counters); §19.2 candidate. + +### Upgrade steps (from 0.14.0) + +- You **MUST** install the new frontend dependency before building: + `cd frontend && npm install` picks up `@amplitude/unified` from + the updated `frontend/package.json` and the refreshed + `package-lock.json`. The lockfile change is committed. +- You **MUST** rebuild the frontend after upgrading so the analytics + wrapper and its consent gate ship to viewers. `frontend/package.json#version` + and `VERSION` both move to `0.15.0`. No schema migration; the + backend is unchanged for this release. +- **MUST**: before deploying, the operator runs `/Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla/.venv/bin/ohm-rfc-app-flotilla overlay set ohm-rfc-app VITE_AMPLITUDE_API_KEY=` to bind the Amplitude project's public API key. (Receiving the value in the conversation is fine — it's bundle-embedded by design, same as `VITE_TURNSTILE_SITE_KEY`.) The deploy **SHOULD NOT** proceed before this binding exists; if the binding is absent, the frontend's analytics wrapper no-ops with a console warning and the rest of the app continues to function — but no events or session replays are sent. +- You **MAY** leave `VITE_AMPLITUDE_API_KEY` unset in dev environments + — the wrapper detects the empty value and no-ops with a single + console warning. The app, the consent banner, and every other + surface keep working unchanged. +- You **SHOULD** verify after deploy that the Amplitude dashboard + receives events and a session replay when a consenting browser + exercises one of the taxonomy events (the easiest probe: open the + deployed site in an Incognito window, accept analytics on the + consent banner, navigate to an RFC, and watch the project's live + event stream + replay panel). + ## 0.14.0 — 2026-05-28 **Minor — no operator action required; new optional env var.** This diff --git a/VERSION b/VERSION index ac454c6..a551051 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.12.0 +0.15.0 diff --git a/frontend/.env.example b/frontend/.env.example index 147b48d..9176ea9 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -62,3 +62,26 @@ VITE_COOKIES_POLICY_URL= # Examples: # VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA... VITE_TURNSTILE_SITE_KEY= + +# v0.15.0 / roadmap item #13: Amplitude project API key (public). +# Embedded in the frontend bundle at build time and used by the +# analytics wrapper (`frontend/src/lib/analytics.js`) — which loads +# `@amplitude/unified` (Analytics + Session Replay) when the user +# has granted analytics consent (v0.13.0 cookie banner). Provision +# an Amplitude project at app.amplitude.com → Projects → New, copy +# the API key. +# +# Public by design: Amplitude browser keys are bundle-embedded +# (visible in dev tools), same nature as VITE_TURNSTILE_SITE_KEY +# (also public; the truly-secret half of that Turnstile pair is +# CLOUDFLARE_TURNSTILE_SECRET on the backend). For deployments +# behind flotilla, bind via `flotilla overlay set +# VITE_AMPLITUDE_API_KEY=` — NOT `flotilla secret set`. The +# vendor's installation wizard shows the key inline as a literal +# string in the init call, confirming the public framing. Leave +# unset in dev; the wrapper logs one console warning and no-ops +# (the app continues to work). +# +# Examples: +# VITE_AMPLITUDE_API_KEY=01234567890abcdef01234567890abcd +VITE_AMPLITUDE_API_KEY= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2fd0758..ad2b45f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,14 @@ { "name": "rfc-app-frontend", - "version": "0.12.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rfc-app-frontend", - "version": "0.12.0", + "version": "0.15.0", "dependencies": { + "@amplitude/unified": "^1.1.9", "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", @@ -30,6 +31,360 @@ "vite": "^8.0.12" } }, + "node_modules/@amplitude/analytics-browser": { + "version": "2.42.4", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.42.4.tgz", + "integrity": "sha512-q1XUlaKQkLq2CFx8xsVEc+uekOwHlnDYyaMBzlQDf2vcEaPaQDb7LzJ7z4CFs4Jn9FyBGDNo4w3IYjv9L6xjGA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "@amplitude/plugin-autocapture-browser": "1.27.2", + "@amplitude/plugin-custom-enrichment-browser": "0.1.9", + "@amplitude/plugin-event-property-attribution-browser": "0.2.1", + "@amplitude/plugin-network-capture-browser": "1.10.1", + "@amplitude/plugin-page-url-enrichment-browser": "0.7.11", + "@amplitude/plugin-page-view-tracking-browser": "2.11.1", + "@amplitude/plugin-web-vitals-browser": "1.1.33", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/analytics-client-common": { + "version": "2.4.48", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-client-common/-/analytics-client-common-2.4.48.tgz", + "integrity": "sha512-jdRvu8ux3aIf74FvTDZuSFR1mutzdrIg1ebXYqpKizs9upXz1AJnHClkldSw9i4yu924AJ2wudxq6dccHWlNiA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.4.8", + "@amplitude/analytics-core": "2.48.2", + "@amplitude/analytics-types": "2.11.1", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/analytics-connector": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz", + "integrity": "sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==", + "license": "MIT" + }, + "node_modules/@amplitude/analytics-core": { + "version": "2.48.2", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.2.tgz", + "integrity": "sha512-r9O+hsTnTsDa1p6QdyC0KbBPXupzoWz9053RQB9XQz8078LM+5KCMbCKYOrSYniH4DH/OM2kOUEdJlwdxIl/IA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "@types/zen-observable": "0.8.3", + "safe-json-stringify": "1.2.0", + "tslib": "^2.4.1", + "zen-observable": "0.10.0" + } + }, + "node_modules/@amplitude/analytics-types": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.11.1.tgz", + "integrity": "sha512-wFEgb0t99ly2uJKm5oZ28Lti0Kh5RecR5XBkwfUpDzn84IoCIZ8GJTsMw/nThu8FZFc7xFDA4UAt76zhZKrs9A==", + "license": "MIT" + }, + "node_modules/@amplitude/engagement-browser": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@amplitude/engagement-browser/-/engagement-browser-1.0.9.tgz", + "integrity": "sha512-zvPr0L5aLlOS3nG8scIkEEDMVK2y3MaMbgjYhMfYruhMpfsC/U0apov22nEc1RRrTwve2awEXruPRKf1TysqrQ==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-types": "^2.0.0" + } + }, + "node_modules/@amplitude/experiment-core": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.13.1.tgz", + "integrity": "sha512-ZHvR0dxTltasp8MiMcQ6qKsY20mWnODoy3oebGad6qaRR1ywpUi8IuLf5AwLTM35ZwgzEUTn9TEIWKLHpDwHMw==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@amplitude/experiment-js-client": { + "version": "1.21.1", + "resolved": "https://registry.npmjs.org/@amplitude/experiment-js-client/-/experiment-js-client-1.21.1.tgz", + "integrity": "sha512-chE/4qQG/5Cgl93Wqj1NEdgOL5LkqySLlfk1EN0f+7bJa52HpkGFALA2FeCNYf31Z5CglEeKX6dUMgL7y33SIw==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "@amplitude/experiment-core": "^0.13.1", + "@amplitude/ua-parser-js": "^0.7.31", + "base64-js": "1.5.1", + "unfetch": "4.1.0" + } + }, + "node_modules/@amplitude/plugin-autocapture-browser": { + "version": "1.27.2", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.27.2.tgz", + "integrity": "sha512-UTA/0IDw/f2nnK+S1XILqoI5pgUgMTEZokDS6+pC4wuYtmOS9uNAgKuyajzjW12uobybMHRpv7xLjCJ5khKGAg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-custom-enrichment-browser": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-custom-enrichment-browser/-/plugin-custom-enrichment-browser-0.1.9.tgz", + "integrity": "sha512-wemh2Tw3zgQ7sa7MUNyMGz9OR6VjTG4tlAMrLlDKbQ4tVkgNI3oAwOF7+0BA8qzgeMXX6iw+CEKaE+EC/okkuQ==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-event-property-attribution-browser": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-event-property-attribution-browser/-/plugin-event-property-attribution-browser-0.2.1.tgz", + "integrity": "sha512-xqBCZe0DYsKyQ1eELN2LM8adXwRE2eOi3SnvSu9SkS0GDXBYWinuPCuLqyc/3uD5hY2FLACWvakpU0tr7GDJgg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-experiment-browser": { + "version": "1.0.0-beta.28", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-experiment-browser/-/plugin-experiment-browser-1.0.0-beta.28.tgz", + "integrity": "sha512-NQz267zLi7vl2G2lx10yUrEoGOCe5K9iqcPSIjbTavGu/XGvsmqLDqBHhg+EkdEMAPwypoXnmtPEs3RMhX+1MA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "@amplitude/experiment-js-client": "^1.15.5" + } + }, + "node_modules/@amplitude/plugin-network-capture-browser": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.10.1.tgz", + "integrity": "sha512-jROIAkUDPd25A/t8W5MpmsTiBat2qoJbCMoNBKKxLMNEaE8VYbheflByWLkm4enbHgWS7OveWy0i3Oc7uPCfAg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-page-url-enrichment-browser": { + "version": "0.7.11", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-url-enrichment-browser/-/plugin-page-url-enrichment-browser-0.7.11.tgz", + "integrity": "sha512-u9JhUP/VenJifCSbdTz2YZZiXAphs3efzd+qx1SRAIU6d1swPh0g/GVw3sTwvH+4MZtw3SwVC1OFxmz+f2QVyA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-page-view-tracking-browser": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.11.1.tgz", + "integrity": "sha512-tfXg6Uir6X1XuWsOOXE/EgZ9NvM7i2ktDdagydSrFN6OyVkMvqdjPKUZSSUPuHtOoomboi3WaZsTUfq1jkWP3w==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-session-replay-browser": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-session-replay-browser/-/plugin-session-replay-browser-1.31.0.tgz", + "integrity": "sha512-b7kyYVEdW3EMR6cPXCfld+h8nQsuAR5o6vum8Glu+ofhFDfG4wj/mTJ0ITEaNbsJCfXniKQ3kFgTe6hTtxSFGQ==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-client-common": "2.4.48", + "@amplitude/analytics-core": "2.48.2", + "@amplitude/analytics-types": "2.11.1", + "@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40", + "@amplitude/rrweb-record": "2.0.0-alpha.40", + "@amplitude/session-replay-browser": "1.44.0", + "idb-keyval": "^6.2.1", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-web-vitals-browser": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-1.1.33.tgz", + "integrity": "sha512-33FzxMH1Lr2lhvr5DDy3xD1HHWEI4KPLQsMUXqDTldkLl/ENNeBWcsljQTTDJipmRdS32I79KJhuHRNaoXd6fg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "2.48.2", + "tslib": "^2.4.1", + "web-vitals": "5.1.0" + } + }, + "node_modules/@amplitude/rrdom": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@amplitude/rrdom/-/rrdom-2.1.0.tgz", + "integrity": "sha512-2dAtxXL02usBV2CSOnScLd3WoVqWaeiGpxN8LuXJ0r/NpLJkW1k876v2tRKAz5NrxPwSdjihsMmwCIXHpJhHfA==", + "license": "MIT", + "dependencies": { + "@amplitude/rrweb-snapshot": "^2.1.0" + } + }, + "node_modules/@amplitude/rrweb": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb/-/rrweb-2.1.1.tgz", + "integrity": "sha512-6uA+5VE/VHumaXPXTTLGRogd/K9MDwd01jGteppeLzsX0PvqlDyY5aIi35yh9+q1iS6ciPBn/2NRg0lg4cFIlw==", + "license": "MIT", + "dependencies": { + "@amplitude/rrdom": "^2.1.0", + "@amplitude/rrweb-snapshot": "^2.1.0", + "@amplitude/rrweb-types": "^2.1.0", + "@amplitude/rrweb-utils": "^2.1.0", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "mitt": "^3.0.0" + } + }, + "node_modules/@amplitude/rrweb-packer": { + "version": "2.0.0-alpha.40", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-packer/-/rrweb-packer-2.0.0-alpha.40.tgz", + "integrity": "sha512-Btb6b9pS1IvDMbvyYxpUdTk9NRJugSoJjRCl7R6jP/iSlPWXoveJIwHaNFAS9ZmWUEK7HhyBJ8bKGFN3giUsDg==", + "license": "MIT", + "dependencies": { + "@amplitude/rrweb-types": "^2.0.0-alpha.40", + "fflate": "^0.4.4" + } + }, + "node_modules/@amplitude/rrweb-plugin-console-record": { + "version": "2.0.0-alpha.40", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-plugin-console-record/-/rrweb-plugin-console-record-2.0.0-alpha.40.tgz", + "integrity": "sha512-vtY7T/kGFl62nC1u7ZUXQvU7ulB70cZGVHPRN/SO9fzVfsY7y6rCmBfoc2jS5KmISdlgkVzMjY2r/EE2Gk9AQA==", + "license": "MIT", + "peerDependencies": { + "@amplitude/rrweb": "^2.0.0-alpha.40" + } + }, + "node_modules/@amplitude/rrweb-record": { + "version": "2.0.0-alpha.40", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-record/-/rrweb-record-2.0.0-alpha.40.tgz", + "integrity": "sha512-5cJhQwzhymJWX5/XOtpWK0h2NLq9+t2YiO6ub0cdZ9F5AZizaRbsVH88int07DfX0YiXTKWbISezVuduCLqgSQ==", + "license": "MIT", + "dependencies": { + "@amplitude/rrweb": "^2.0.0-alpha.40", + "@amplitude/rrweb-types": "^2.0.0-alpha.40" + } + }, + "node_modules/@amplitude/rrweb-snapshot": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-snapshot/-/rrweb-snapshot-2.1.0.tgz", + "integrity": "sha512-xYQvOW73ig+5M7caqilA8j0S6MHWUULLeJNK+2VVvUqv8mr4FMT2DUAQiVBGCImNlb9Gu2rLUfCScMnVxn+EDg==", + "license": "MIT", + "dependencies": { + "postcss": "^8.4.38" + } + }, + "node_modules/@amplitude/rrweb-types": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.1.0.tgz", + "integrity": "sha512-S73tBI/04A6HCHgnrUNeeVOvnDTEoQnNrmZGyrZncJwRlTIX+6BQSYtBFofMag8GnAy9gA+NtC0TL0CnluOWBw==", + "license": "MIT" + }, + "node_modules/@amplitude/rrweb-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.1.0.tgz", + "integrity": "sha512-dTCDnSiMMHZ10utYHJ8dSd/xkjFgdF67y74PkOzAPcCKW1rLxyJYcFOA3uPL2b7cIVVmoel/5NTp5eflaUaJfQ==", + "license": "MIT" + }, + "node_modules/@amplitude/session-replay-browser": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@amplitude/session-replay-browser/-/session-replay-browser-1.44.0.tgz", + "integrity": "sha512-8Ruep2TTDMcfVMKurSpBbVclBK/v8Lb3aSHFsYd/xOQ1E3CaKoAu39pplli28NWoUcW7unyVE7khkOa2zzn0Lw==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-client-common": "2.4.48", + "@amplitude/analytics-core": "2.48.2", + "@amplitude/analytics-types": "2.11.1", + "@amplitude/experiment-core": "0.7.2", + "@amplitude/rrweb-packer": "2.0.0-alpha.40", + "@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40", + "@amplitude/rrweb-record": "2.0.0-alpha.40", + "@amplitude/rrweb-types": "2.0.0-alpha.40", + "@amplitude/rrweb-utils": "2.0.0-alpha.40", + "@amplitude/targeting": "0.2.0", + "@rollup/plugin-replace": "^6.0.1", + "idb": "8.0.0", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/experiment-core": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz", + "integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-types": { + "version": "2.0.0-alpha.40", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.0.0-alpha.40.tgz", + "integrity": "sha512-rP7CBDkzXupxOA7ukvC+zDYLuCtsz54TuJKC4+5O72Jsz4YdokLznKZRG34P6zXozfhGU0261qckk87lLY6mKQ==", + "license": "MIT" + }, + "node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-utils": { + "version": "2.0.0-alpha.40", + "resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.0.0-alpha.40.tgz", + "integrity": "sha512-i1CCt6MCjlqoeNc+1Hse5bz+ZbASaWaIJ0WdJZvnQjUCHH29Xy/QFouyOuor73RZ+UWX4s2tYSrUIdmBepXk3w==", + "license": "MIT" + }, + "node_modules/@amplitude/targeting": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@amplitude/targeting/-/targeting-0.2.0.tgz", + "integrity": "sha512-/50ywTrC4hfcfJVBbh5DFbqMPPfaIOivZeb5Gb+OGM03QrA+lsUqdvtnKLNuWtceD4H6QQ2KFzPJ5aAJLyzVDA==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-client-common": ">=1 <3", + "@amplitude/analytics-core": ">=1 <3", + "@amplitude/analytics-types": ">=1 <3", + "@amplitude/experiment-core": "0.7.2", + "idb": "^8.0.0", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/targeting/node_modules/@amplitude/experiment-core": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz", + "integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==", + "license": "MIT", + "dependencies": { + "js-base64": "^3.7.5" + } + }, + "node_modules/@amplitude/ua-parser-js": { + "version": "0.7.33", + "resolved": "https://registry.npmjs.org/@amplitude/ua-parser-js/-/ua-parser-js-0.7.33.tgz", + "integrity": "sha512-wKEtVR4vXuPT9cVEIJkYWnlF++Gx3BdLatPBM+SZ1ztVIvnhdGBZR/mn9x/PzyrMcRlZmyi6L56I2J3doVBnjA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@amplitude/unified": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@amplitude/unified/-/unified-1.1.9.tgz", + "integrity": "sha512-YPgQbp/vDQ92GshHs2hfUxoeRnR3rRBWCoQ6wXgFjXQ1uiJf2tP0CBZWdrCStSDuhcpo2rsCz/Ek2LGq5J6SIQ==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-browser": "2.42.4", + "@amplitude/analytics-core": "2.48.2", + "@amplitude/engagement-browser": "^1.0.3", + "@amplitude/plugin-experiment-browser": "1.0.0-beta.28", + "@amplitude/plugin-session-replay-browser": "1.31.0" + } + }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", @@ -264,6 +619,12 @@ "import-meta-resolve": "^4.2.0" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, "node_modules/@lezer/common": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", @@ -657,6 +1018,49 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@tiptap/core": { "version": "3.23.6", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.6.tgz", @@ -1109,6 +1513,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "license": "MIT" + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -1362,6 +1772,12 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -1399,6 +1815,12 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/zen-observable": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz", + "integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==", + "license": "MIT" + }, "node_modules/@upsetjs/venn.js": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", @@ -1435,6 +1857,41 @@ } } }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -2021,6 +2478,12 @@ "benchmarks" ] }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -2048,6 +2511,12 @@ } } }, + "node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2081,6 +2550,18 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz", + "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==", + "license": "ISC" + }, + "node_modules/idb-keyval": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.4.tgz", + "integrity": "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==", + "license": "Apache-2.0" + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -2100,6 +2581,12 @@ "node": ">=12" } }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/katex": { "version": "0.16.47", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", @@ -2421,6 +2908,15 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/marked": { "version": "18.0.4", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", @@ -2474,11 +2970,16 @@ "node": ">= 20" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, "funding": [ { "type": "github", @@ -2515,14 +3016,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2551,7 +3050,6 @@ "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2828,6 +3326,12 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause" }, + "node_modules/safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -2850,7 +3354,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2907,9 +3410,13 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/unfetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.1.0.tgz", + "integrity": "sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg==", + "license": "MIT" }, "node_modules/use-sync-external-store": { "version": "1.6.0", @@ -3016,6 +3523,18 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" + }, + "node_modules/web-vitals": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz", + "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", + "license": "Apache-2.0" + }, + "node_modules/zen-observable": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.10.0.tgz", + "integrity": "sha512-iI3lT0iojZhKwT5DaFy2Ce42n3yFcLdFyOh01G7H0flMY60P8MJuVFEoJoNwXlmAyQ45GrjL6AcZmmlv8A5rbw==", + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 69fda27..f9b6b2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.12.0", + "version": "0.15.0", "type": "module", "scripts": { "dev": "vite", @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@amplitude/unified": "^1.1.9", "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 247f955..a3d1a83 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from 'react' -import { Routes, Route, Link, useNavigate } from 'react-router-dom' +import { useEffect, useRef, useState } from 'react' +import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom' import { getMe, subscribeToNotifications } from './api' +import { anonymize, EVENTS, identify, track } from './lib/analytics' import Catalog from './components/Catalog.jsx' import Inbox from './components/Inbox.jsx' import RFCView from './components/RFCView.jsx' @@ -34,6 +35,56 @@ export default function App() { // event that bumps this. const [consentReopenTick, setConsentReopenTick] = useState(0) const navigate = useNavigate() + const location = useLocation() + // v0.15.0 — Page Viewed event taxonomy. We fire on every + // route change; the analytics wrapper itself decides whether + // anything ships out (consent + key check). The first fire is + // also covered because `location` is set on mount. + const lastPathRef = useRef(null) + useEffect(() => { + const path = location.pathname + (location.search || '') + if (lastPathRef.current === path) return + lastPathRef.current = path + track(EVENTS.PAGE_VIEWED, { path: location.pathname }) + }, [location.pathname, location.search]) + + // v0.15.0 + #21 Part C — bind the authenticated user id AND + // durable user properties to the analytics session when sign-in + // lands; reset on sign-out (viewer flips to null). The wrapper + // queues these calls until consent + init resolve, so the order + // is safe even on a cold load. + // + // Property bag passed to identify (set vs setOnce per #21 Part C): + // set: role, permission_state, passcode_set, device_trusted + // (these can change mid-account-life — refresh each sign-in) + // setOnce: first_sign_in_at, account_created_at + // (immutable user-history markers — set on the first + // sign-in that observes them, never overwritten) + // + // PII discipline: NO email, NO display_name, NO gitea_login passed + // through — Amplitude only sees opaque ids + enums + timestamps + + // booleans. + const lastUserIdRef = useRef(null) + useEffect(() => { + const uid = me?.authenticated ? me.user?.id : null + const viewer = me?.authenticated ? me.user : null + if (uid != null && lastUserIdRef.current !== uid) { + lastUserIdRef.current = uid + const props = {} + if (viewer?.role != null) props.role = viewer.role + if (viewer?.permission_state != null) props.permission_state = viewer.permission_state + if (viewer?.passcode_set != null) props.passcode_set = !!viewer.passcode_set + if (viewer?.device_trusted != null) props.device_trusted = !!viewer.device_trusted + if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at] + if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at] + identify({ user_id: String(uid), properties: props }) + } else if (uid == null && lastUserIdRef.current != null) { + // Sign-out edge — App-level reset is handled separately by the + // sign-out gesture that fires User Signed Out. Clear our local + // memo so a fresh sign-in re-fires identify. + lastUserIdRef.current = null + } + }, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted]) useEffect(() => { const handler = () => setConsentReopenTick(t => t + 1) @@ -141,7 +192,20 @@ export default function App() { <> {viewer.display_name} {viewer.role} - Sign out + { + // v0.15.0 — fire the sign-out event before the + // hard nav. The wrapper's track() is sync-enqueue; + // the underlying SDK flush is best-effort across + // navigation. anonymize() clears the user binding + // so any post-nav anonymous events on the next + // page aren't attributed to the prior user. + track(EVENTS.USER_SIGNED_OUT) + anonymize() + }} + >Sign out ) : ( diff --git a/frontend/src/components/Admin.jsx b/frontend/src/components/Admin.jsx index 0f85c13..69efcc0 100644 --- a/frontend/src/components/Admin.jsx +++ b/frontend/src/components/Admin.jsx @@ -24,6 +24,7 @@ import { addAllowlistEmail, removeAllowlistEmail, } from '../api.js' +import { EVENTS, track } from '../lib/analytics.js' const TABS = [ { path: 'users', label: 'Users' }, @@ -133,6 +134,12 @@ function UsersTab() { setError(null) try { await setUserPermission(userId, state) + // v0.15.0 — analytics: fire on a successful §6.1 grant/revoke. + // action collapses the {pending → granted, revoked → granted} + // edges onto `grant`, and `granted → revoked` onto `revoke`, + // matching the roadmap's two-arm taxonomy. + const action = state === 'granted' ? 'grant' : 'revoke' + track(EVENTS.ADMIN_PERMISSION_DECISION, { action, target_user_id: String(userId) }) // Refresh the full row so permission_decided_{at,by_*} update too. await refresh() } catch (e) { diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx index 7440f00..be5cabc 100644 --- a/frontend/src/components/Login.jsx +++ b/frontend/src/components/Login.jsx @@ -85,6 +85,7 @@ import { startDeviceTrust, } from '../api' import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget' +import { EVENTS, track } from '../lib/analytics' export default function Login() { // Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after @@ -150,7 +151,12 @@ export default function Login() { ;(async () => { try { await startDeviceTrust() - if (!cancelled) window.location.assign('/') + if (!cancelled) { + // v0.15.0 — analytics: device-trust cookie path is one of + // three sign-in methods the taxonomy distinguishes. + track(EVENTS.USER_SIGNED_IN, { method: 'trust-device' }) + window.location.assign('/') + } } catch (_) { // No trusted device — fall through to the email step. } @@ -206,6 +212,10 @@ export default function Login() { setStatus('') try { await verifyPasscode(email.trim(), passcode.trim(), { trustDevice }) + // v0.15.0 — analytics: passcode is the second of three + // sign-in methods. trust-device gets credited separately when + // the cookie-driven path fires above. + track(EVENTS.USER_SIGNED_IN, { method: 'passcode' }) // Reload so App.jsx's getMe() picks up the fresh session. A // returning passcode user is by definition already past the // §6.1 capture step (they couldn't have set a passcode while @@ -264,6 +274,11 @@ export default function Login() { setStatus('') try { await verifyOtc(email.trim(), code.trim(), { trustDevice }) + // v0.15.0 — analytics: OTC is the third sign-in method. + // We fire it here regardless of whether the user then lands + // in capture-profile or offer-passcode — sign-in has happened + // server-side either way. + track(EVENTS.USER_SIGNED_IN, { method: 'otc' }) // OTC verified — the server has signed in the user. Fetch the // canonical /api/auth/me to decide where to land: // * needs_profile → §6.1 capture (then /beta-pending). @@ -320,6 +335,11 @@ export default function Login() { last_name: ln, beta_request_reason: why, }) + // v0.15.0 — analytics: a successful capture-profile submit is + // the moment a beta-access request lands. No PII in the event + // body (no name, no reason text); the count + timestamp is + // what the funnel needs. + track(EVENTS.BETA_ACCESS_REQUESTED) // Hard-load so App.jsx re-fetches /api/auth/me and picks up // the captured fields. The user stays permission_state='pending' // until an admin grants access — the next thing they should diff --git a/frontend/src/components/PRModal.jsx b/frontend/src/components/PRModal.jsx index a1fd4de..23ba78c 100644 --- a/frontend/src/components/PRModal.jsx +++ b/frontend/src/components/PRModal.jsx @@ -10,6 +10,7 @@ import { useEffect, useState } from 'react' import { draftPRText, openPR } from '../api' +import { EVENTS, track } from '../lib/analytics' export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) { const [title, setTitle] = useState('') @@ -39,6 +40,9 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen setError(null) try { const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() }) + // v0.15.0 — analytics: fire on §10.2 PR-open success. slug + // and pr_number are the join keys; title/description stay out. + track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number }) onOpened?.(pr_number) } catch (e) { setError(e.message) diff --git a/frontend/src/components/PRView.jsx b/frontend/src/components/PRView.jsx index 3c11457..813ed8c 100644 --- a/frontend/src/components/PRView.jsx +++ b/frontend/src/components/PRView.jsx @@ -22,6 +22,7 @@ import { startResolutionBranch, withdrawPR, } from '../api' +import { EVENTS, track } from '../lib/analytics' export default function PRView({ viewer }) { const { slug, prNumber: prNumberParam } = useParams() @@ -135,6 +136,10 @@ export default function PRView({ viewer }) { anchorPayload: reviewDraft?.anchorPayload || {}, quote: reviewDraft?.quote || null, }) + // v0.15.0 — analytics: fire on §10.4 review-comment success. + // surface=pr distinguishes this from RFC discussion comments. + // No body text or quote material in the event. + track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, pr_number: prNumber, surface: 'pr' }) setReviewText('') setReviewDraft(null) await refresh() diff --git a/frontend/src/components/ProposeModal.jsx b/frontend/src/components/ProposeModal.jsx index 3e54f1b..c42b8ab 100644 --- a/frontend/src/components/ProposeModal.jsx +++ b/frontend/src/components/ProposeModal.jsx @@ -11,6 +11,7 @@ import { useEffect, useState } from 'react' import { proposeRFC } from '../api' +import { EVENTS, track } from '../lib/analytics' function slugify(title) { return title @@ -52,6 +53,10 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) { pitch: pitch.trim(), tags, }) + // v0.15.0 — analytics: fire on the §9.1 propose-RFC submit. + // Slug is a stable, low-cardinality identifier (kebab-case + // ascii); title and pitch stay out of the event body. + track(EVENTS.RFC_PROPOSED, { rfc_slug: slug }) onSubmitted?.(result) } catch (err) { setError(err.message || 'Submission failed.') diff --git a/frontend/src/components/RFCDiscussionPanel.jsx b/frontend/src/components/RFCDiscussionPanel.jsx index 9d812c8..197f293 100644 --- a/frontend/src/components/RFCDiscussionPanel.jsx +++ b/frontend/src/components/RFCDiscussionPanel.jsx @@ -18,6 +18,7 @@ import { postDiscussionMessage, resolveDiscussionThread, } from '../api' +import { EVENTS, track } from '../lib/analytics' export default function RFCDiscussionPanel({ slug, viewer }) { const [threads, setThreads] = useState([]) @@ -100,6 +101,10 @@ export default function RFCDiscussionPanel({ slug, viewer }) { void message_id } setComposer('') + // v0.15.0 — analytics: fire on a successful discussion post. + // surface=discussion distinguishes this from PR review comments + // which fire from PRView with surface=pr. No body text. + track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, surface: 'discussion' }) } catch (err) { setError(err.message) } finally { diff --git a/frontend/src/components/RFCView.jsx b/frontend/src/components/RFCView.jsx index 79936d1..5c7f405 100644 --- a/frontend/src/components/RFCView.jsx +++ b/frontend/src/components/RFCView.jsx @@ -44,6 +44,7 @@ import ChangePanel, { diffWords } from './ChangePanel.jsx' import PRModal from './PRModal.jsx' import GraduateDialog from './GraduateDialog.jsx' import { claimOwnership } from '../api' +import { EVENTS, track } from '../lib/analytics' const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail. const MANUAL_DEBOUNCE_MS = 800 @@ -121,7 +122,15 @@ export default function RFCView({ viewer }) { const [drawerOpen, setDrawerOpen] = useState(false) useEffect(() => { - getRFC(slug).then(setEntry).catch(err => setError(err.message)) + getRFC(slug).then(entry => { + setEntry(entry) + // v0.15.0 — analytics: fire RFC Viewed once per slug load. + // We key on the slug param rather than the loaded entry so a + // re-render doesn't double-fire; the slug is the stable + // identifier. id is included for join-friendliness in the + // Amplitude dashboard. + track(EVENTS.RFC_VIEWED, { rfc_slug: slug, rfc_id: entry?.id }) + }).catch(err => setError(err.message)) listModels(slug) .then(({ models, default: def }) => { setModels(models || []) diff --git a/frontend/src/lib/analytics.js b/frontend/src/lib/analytics.js new file mode 100644 index 0000000..05d702b --- /dev/null +++ b/frontend/src/lib/analytics.js @@ -0,0 +1,378 @@ +// analytics.js — v0.15.0 / roadmap item #13. +// +// Wrapper around `@amplitude/unified` (Amplitude Analytics + +// Session Replay) that gates SDK initialization on the user's +// cookie/privacy consent (v0.13.0, `frontend/src/lib/consent.js`, +// SPEC §14.5). The wrapper presents a stable surface to the rest +// of the app: +// +// import { track, identify, anonymize } from './lib/analytics' +// +// track('RFC Viewed', { rfc_slug: 'open-human-model' }) +// identify({ user_id: 'u_123' }) +// anonymize() // call on sign-out +// +// At first import the wrapper: +// 1. Calls `bootstrap()` once, which reads `getConsent()` and +// subscribes to `onConsentChange()`. If consent.analytics is +// true, it lazily imports the Amplitude SDK and calls +// `amplitude.initAll(API_KEY, { analytics: { autocapture: true }, +// sessionReplay: { sampleRate: 1 } })`. +// If consent.analytics is false (or undecided), the SDK is +// not loaded — no network request, no cookies, no session +// replay recording. A later consent change to `true` triggers +// init at that moment. +// 2. The wrapper queues `track()` and `identify()` calls made +// before init finishes (lazy import + consent grant), and +// drains the queue when init completes. +// 3. If the user later flips consent from granted → denied, the +// wrapper calls `amplitude.setOptOut(true)` so subsequent +// events are dropped client-side and session replay stops +// recording (the SDK is still loaded — we cannot unload a +// script — but it stops firing). +// +// Consent precedence ladder: +// +// consent.analytics === true → init + track +// consent.analytics === false → no init; or if already init, +// setOptOut(true) +// consent.recorded_at === null → treat as denied (banner is up; +// the user has not yet chosen) +// +// Session replay scope: this release ships session replay at +// `sampleRate: 1` (100% of sessions are recorded for full-DOM +// playback). That is the vendor-recommended default for new +// Amplitude deployments. The v0.13.0 consent banner's single +// "analytics" toggle gates both events and session replay together — +// a separate consent category for session-replay specifically is a +// §19.2 follow-up. +// +// API key resolution: +// +// The build-time env var `VITE_AMPLITUDE_API_KEY` carries the +// Amplitude project's API key. When it is unset/empty, the +// wrapper logs one console warning and no-ops — every public +// function becomes a deterministic no-op so dev environments +// (and deployments that intentionally don't ship analytics) +// keep working. The deploy gesture wires the key via flotilla's +// `overlay set` verb (see CHANGELOG for the operator gesture): +// Amplitude browser keys are bundle-embedded by design (visible +// to anyone with dev tools, same nature as the v0.12.0 +// `VITE_TURNSTILE_SITE_KEY`), so the binding is overlay, not +// secret. +// +// PII discipline: +// +// `identify({ user_id })` SHOULD pass only the opaque server- +// side user id (the `viewer.id` integer or string). DO NOT pass +// email, display name, IP, or any other PII through the SDK. +// Event properties SHOULD likewise stay limited to ids and +// enums; free-text fields (titles, comment bodies) MUST NOT be +// sent. +// +// Event taxonomy: defined in `EVENTS` below. Callers SHOULD use +// one of these names rather than firing arbitrary strings — that +// keeps the Amplitude dashboard coherent over time. + +import { getConsent, onConsentChange } from './consent.js' + +const API_KEY = import.meta.env.VITE_AMPLITUDE_API_KEY || '' + +// Public taxonomy. Keep this short and stable — new entries should +// land via a release, not ad-hoc. The strings match the Amplitude +// dashboard names exactly (Title Case, spaces, no punctuation). +export const EVENTS = Object.freeze({ + PAGE_VIEWED: 'Page Viewed', + RFC_VIEWED: 'RFC Viewed', + USER_SIGNED_IN: 'User Signed In', + USER_SIGNED_OUT: 'User Signed Out', + RFC_PROPOSED: 'RFC Proposed', + PR_OPENED: 'PR Opened', + COMMENT_POSTED: 'Comment Posted', + BETA_ACCESS_REQUESTED: 'Beta Access Requested', + ADMIN_PERMISSION_DECISION: 'Admin Permission Decision', +}) + +// Internal state. +let _bootstrapped = false +let _amplitude = null // The dynamically imported SDK module. +let _initPromise = null // Pending init (lazy import + sdk.init). +let _initialized = false // True after sdk.init has resolved. +let _warnedNoKey = false +let _pendingUserId = null // identify() called before init resolves. +let _pendingProperties = null // identify({ properties }) or + // setUserProperties() before init. +const _queue = [] // {kind: 'track'|'identify'|'anonymize'| + // 'setUserProperties', ...} + +function warnNoKey() { + if (_warnedNoKey) return + _warnedNoKey = true + // eslint-disable-next-line no-console + console.warn( + '[analytics] VITE_AMPLITUDE_API_KEY is unset; analytics events ' + + 'and session replay will not be sent. This is expected in dev; ' + + 'in production it means the operator has not yet run ' + + '`flotilla overlay set VITE_AMPLITUDE_API_KEY=`.', + ) +} + +function consentGranted() { + const c = getConsent() + return !!(c && c.recorded_at && c.analytics) +} + +// Apply a {key: value} property bag as an Amplitude Identify event. +// Used by both `identify({ properties })` and `setUserProperties`. +function applyProperties(props) { + if (!_initialized || !_amplitude || !props) return + try { + const id = new _amplitude.Identify() + for (const [k, v] of Object.entries(props)) { + if (v === undefined || v === null) continue + if (Array.isArray(v) && v.length === 2 && v[0] === '__setOnce__') { + id.setOnce(k, v[1]) + } else { + id.set(k, v) + } + } + _amplitude.identify(id) + } catch (_) { + // SDK errors are non-fatal; analytics is best-effort. + } +} + +// Drain the queue. Called once init resolves. +function drainQueue() { + if (!_initialized || !_amplitude) return + if (_pendingUserId != null) { + try { _amplitude.setUserId(_pendingUserId) } catch (_) {} + _pendingUserId = null + } + if (_pendingProperties != null) { + applyProperties(_pendingProperties) + _pendingProperties = null + } + while (_queue.length > 0) { + const item = _queue.shift() + try { + if (item.kind === 'track') { + _amplitude.track(item.name, item.props || {}) + } else if (item.kind === 'identify') { + if (item.user_id != null) _amplitude.setUserId(item.user_id) + if (item.properties != null) applyProperties(item.properties) + } else if (item.kind === 'setUserProperties') { + applyProperties(item.properties) + } else if (item.kind === 'anonymize') { + _amplitude.reset() + } + } catch (_) { + // SDK errors are non-fatal; analytics is best-effort. + } + } +} + +// Lazy import + init. Resolves once the SDK is ready to take events. +// Idempotent: subsequent calls return the same promise. +async function initSdk() { + if (_initPromise) return _initPromise + if (!API_KEY) { + warnNoKey() + // Resolve immediately with a no-op shape; the wrapper's public + // functions check API_KEY and short-circuit, so this never + // actually runs SDK code. + _initPromise = Promise.resolve(null) + return _initPromise + } + _initPromise = (async () => { + try { + const mod = await import('@amplitude/unified') + // The unified package exposes `initAll`, `track`, + // `setUserId`, `reset`, `setOptOut` as named functions. + // We hold the module so the queue drainer can call them + // by name. + _amplitude = mod + // initAll wires up both Analytics and Session Replay in one + // call. Vendor-recommended init shape from the Amplitude + // installation wizard: + // - analytics.autocapture: true — auto-instruments page + // views, session start/end, clicks, and form interactions. + // Our explicit `track('Page Viewed', …)` etc. layer on top + // for app-specific names that survive renames. + // - sessionReplay.sampleRate: 1 — record 100% of sessions + // for full-DOM playback. Gated by the v0.13.0 consent + // banner just like the rest of the SDK; never starts + // recording without explicit analytics opt-in. + const ret = mod.initAll(API_KEY, { + analytics: { autocapture: true }, + sessionReplay: { sampleRate: 1 }, + }) + // initAll returns an AmplitudeReturn with a `.promise` accessor + // (consistent with the legacy `init`). Some unified builds + // resolve synchronously; await defensively. + if (ret && ret.promise) await ret.promise + _initialized = true + drainQueue() + } catch (err) { + // Init failure is non-fatal; keep the wrapper alive so future + // calls no-op. Log once for the operator. + // eslint-disable-next-line no-console + console.warn('[analytics] Amplitude init failed:', err) + _initialized = false + } + return _amplitude + })() + return _initPromise +} + +// Bootstrap is called lazily on first track/identify. It wires the +// consent subscription so a later flip from denied→granted triggers +// init at that moment, and granted→denied flips the opt-out. +function bootstrap() { + if (_bootstrapped) return + _bootstrapped = true + if (consentGranted()) { + // Fire-and-forget; the queue catches any events that arrive + // before init resolves. + initSdk() + } + onConsentChange(snapshot => { + const allowed = !!(snapshot && snapshot.recorded_at && snapshot.analytics) + if (allowed && !_initPromise) { + initSdk() + } else if (allowed && _initialized && _amplitude) { + // Re-enable in case we previously opted out. + try { _amplitude.setOptOut(false) } catch (_) {} + } else if (!allowed && _initialized && _amplitude) { + // Granted → denied. Stop firing. We cannot unload the script + // tag; setOptOut is the SDK's contract for "drop subsequent + // events client-side". + try { _amplitude.setOptOut(true) } catch (_) {} + } + }) +} + +/** Fire a track event. Safe to call before consent / init resolve; + * the call is queued and drained once both are true. Drops the + * event silently if API_KEY is empty (with a one-shot warn) or + * consent.analytics is false. */ +export function track(name, props) { + if (!API_KEY) { warnNoKey(); return } + bootstrap() + if (!consentGranted()) return + if (_initialized && _amplitude) { + try { _amplitude.track(name, props || {}) } catch (_) {} + return + } + _queue.push({ kind: 'track', name, props }) +} + +/** Attach an authenticated user id and optional durable properties. + * Pass `{ user_id: '', properties?: { role, first_sign_in_at, … } }`. + * DO NOT pass email, display name, or other PII as user_id or in + * properties. Idempotent — subsequent calls with the same id are + * cheap; properties are merged into the Amplitude user record. + * + * To mark a property as setOnce (immutable after first write), + * pass `properties: { first_sign_in_at: ['__setOnce__', '2026-05-28T…'] }`. + * Bare values use Amplitude's `.set()` (mutable). + * + * Pattern (per #21 Part C): + * - On sign-in success in App.jsx: identify with viewer.id + the + * durable property bag (role, permission_state, first_sign_in_at + * setOnce, passcode_set, device_trusted_count, account_created_at + * setOnce). + * - On invite-claim success in InviteClaim.jsx / AcceptInvitation.jsx: + * identify with the new viewer.id + invitation-derived properties + * (invited_by_admin_id, invited_at setOnce, initial_role, claim_method) + * BEFORE firing any track() — so the Amplitude user record is + * created with the OHM user_id from the first event, not as an + * anonymous device that retroactively links. */ +export function identify({ user_id, properties } = {}) { + if (!API_KEY) { warnNoKey(); return } + if (user_id == null && properties == null) return + bootstrap() + if (!consentGranted()) { + // Hold for when consent lands; identify-on-sign-in is a common + // race with the consent banner choice. + if (user_id != null) _pendingUserId = user_id + if (properties != null) { + _pendingProperties = { ..._pendingProperties, ...properties } + } + return + } + if (_initialized && _amplitude) { + try { + if (user_id != null) _amplitude.setUserId(user_id) + if (properties != null) applyProperties(properties) + } catch (_) {} + return + } + if (user_id != null) _pendingUserId = user_id + if (properties != null) { + _pendingProperties = { ..._pendingProperties, ...properties } + } + _queue.push({ kind: 'identify', user_id, properties }) +} + +/** Update durable user properties on the current Amplitude user + * record mid-session — for state changes that shouldn't wait for the + * next sign-in to surface (role grant/revoke, passcode set, device + * trusted, etc.). Same property shape as `identify({ properties })`. + * setOnce values use the `['__setOnce__', value]` sentinel pattern. + * Has no effect if no identify has happened yet — set the user_id + * via `identify()` first. + * + * Per #21 Part C: call this from any surface where the user's + * Amplitude-relevant state changes mid-session, so the dashboard + * stays current. */ +export function setUserProperties(properties) { + if (!API_KEY) { warnNoKey(); return } + if (properties == null) return + bootstrap() + if (!consentGranted()) { + _pendingProperties = { ..._pendingProperties, ...properties } + return + } + if (_initialized && _amplitude) { + applyProperties(properties) + return + } + _pendingProperties = { ..._pendingProperties, ...properties } + _queue.push({ kind: 'setUserProperties', properties }) +} + +/** Reset the user binding. Call this on sign-out so the next page + * navigations are attributed to a fresh anonymous device id. Has + * no effect when analytics is disabled. + * + * Per #21 Part C: clears both the user_id binding AND the pending + * property cache, so a subsequent sign-in as a different user + * starts with a fully fresh slate (no carry-over properties from + * the previous user). */ +export function anonymize() { + if (!API_KEY) { warnNoKey(); return } + _pendingUserId = null + _pendingProperties = null + bootstrap() + if (!consentGranted()) return + if (_initialized && _amplitude) { + try { _amplitude.reset() } catch (_) {} + return + } + _queue.push({ kind: 'anonymize' }) +} + +/** Test helper — exposed for unit tests, not for app code. + * Resets module-level state so a fresh bootstrap cycle can be + * exercised. */ +export function __resetForTests() { + _bootstrapped = false + _amplitude = null + _initPromise = null + _initialized = false + _warnedNoKey = false + _pendingUserId = null + _pendingProperties = null + _queue.length = 0 +}