@variantlab/core 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,328 @@
1
+ # Debug overlay
2
+
3
+ The on-device debug overlay is variantlab's signature feature. This document describes the design, the UX, and the security constraints.
4
+
5
+ ## Table of contents
6
+
7
+ - [What it is](#what-it-is)
8
+ - [UX design](#ux-design)
9
+ - [Production safety](#production-safety)
10
+ - [Activation modes](#activation-modes)
11
+ - [Information architecture](#information-architecture)
12
+ - [Customization](#customization)
13
+ - [Accessibility](#accessibility)
14
+
15
+ ---
16
+
17
+ ## What it is
18
+
19
+ `<VariantDebugOverlay>` is a component that renders a floating button plus a bottom-sheet picker. In dev builds, you mount it once at the root:
20
+
21
+ ```tsx
22
+ import { VariantDebugOverlay } from "@variantlab/react-native";
23
+
24
+ export default function App() {
25
+ return (
26
+ <VariantLabProvider>
27
+ <MainApp />
28
+ {__DEV__ && <VariantDebugOverlay />}
29
+ </VariantLabProvider>
30
+ );
31
+ }
32
+ ```
33
+
34
+ When the user taps the floating button (or shakes the device, if enabled), a bottom sheet slides up showing every experiment active on the current route.
35
+
36
+ ---
37
+
38
+ ## UX design
39
+
40
+ ### Floating button
41
+
42
+ - 48×48 circle, absolute positioned
43
+ - Configurable corner: `top-left`, `top-right`, `bottom-left`, `bottom-right`
44
+ - Configurable offset from edges
45
+ - Shows a badge with the count of active experiments on the current route
46
+ - Long-press reveals a tooltip: "variantlab debug overlay"
47
+ - Respects safe-area insets
48
+
49
+ ### Bottom sheet picker
50
+
51
+ - Slides up from the bottom on tap
52
+ - Dims the background (tap to dismiss)
53
+ - Max height: 85% of screen
54
+ - Contains:
55
+ 1. A header bar with title + close button
56
+ 2. A search input (filters by experiment name/ID)
57
+ 3. A toggle: "Current route only" / "All experiments"
58
+ 4. A scrollable list of experiment cards
59
+ 5. A footer with "Reset all" and "Share state" buttons
60
+
61
+ ### Experiment card
62
+
63
+ Each experiment card shows:
64
+
65
+ - Experiment name
66
+ - Experiment ID (subtle, monospace)
67
+ - Current variant (highlighted)
68
+ - Assignment reason ("by targeting", "by manual override", "by default")
69
+ - Tap to expand: list of all variants with a radio picker
70
+ - Each variant row shows: label, description, "set active" button
71
+ - An overflow menu: "Copy ID", "Reset to default", "Share state"
72
+
73
+ ### Search
74
+
75
+ - Case-insensitive, substring match on ID + name + label
76
+ - Debounced 100 ms
77
+ - Highlights matches in the list
78
+
79
+ ### Empty state
80
+
81
+ When no experiments match the current route:
82
+
83
+ > No experiments on this route.
84
+ > Toggle "All experiments" above to see the full list.
85
+
86
+ ---
87
+
88
+ ## Production safety
89
+
90
+ The overlay is a dev-only tool. In production, mounting it is a bug. We make it hard to do accidentally.
91
+
92
+ ### Auto-disable in production
93
+
94
+ The overlay component checks multiple signals before rendering:
95
+
96
+ ```tsx
97
+ function VariantDebugOverlay({ forceEnable = false }) {
98
+ const isDev =
99
+ forceEnable ||
100
+ (typeof __DEV__ !== "undefined" && __DEV__) ||
101
+ process.env.NODE_ENV === "development";
102
+
103
+ if (!isDev) {
104
+ if (process.env.NODE_ENV === "production") {
105
+ console.warn(
106
+ "[variantlab] VariantDebugOverlay rendered in production. " +
107
+ "Use forceEnable={true} if this is intentional.",
108
+ );
109
+ }
110
+ return null;
111
+ }
112
+
113
+ return <OverlayImpl />;
114
+ }
115
+ ```
116
+
117
+ ### Tree-shaking in production builds
118
+
119
+ The overlay implementation is exported from a separate entry point:
120
+
121
+ ```ts
122
+ // @variantlab/react-native/debug
123
+ export { VariantDebugOverlay } from "./overlay.js";
124
+ ```
125
+
126
+ If the user doesn't import from `/debug`, the overlay code is not bundled. This keeps production bundles clean even if a developer forgets to wrap in `__DEV__`.
127
+
128
+ ### Never log user data
129
+
130
+ The overlay uses the same storage as the engine and respects the same privacy rules:
131
+
132
+ - No telemetry
133
+ - No remote logging
134
+ - No external network calls
135
+ - All state is local
136
+
137
+ ### Rate-limited actions
138
+
139
+ Rapid-fire variant switching is rate-limited to 5 changes/second to prevent accidental state thrashing or runaway loops.
140
+
141
+ ---
142
+
143
+ ## Activation modes
144
+
145
+ ### Tap (default)
146
+
147
+ Tap the floating button to open.
148
+
149
+ ### Shake gesture (opt-in)
150
+
151
+ ```tsx
152
+ <VariantDebugOverlay
153
+ activation={{ shake: true, shakeThreshold: 15 }}
154
+ />
155
+ ```
156
+
157
+ Uses device motion to detect a shake. Threshold configurable. On React Native, uses `react-native`'s `DeviceEventEmitter` (Android) or a lightweight motion sensor abstraction. On web, uses `DeviceMotionEvent` (requires user permission on iOS).
158
+
159
+ ### Secret gesture
160
+
161
+ Advanced: a 4-corner tap sequence in a specific order. Useful for hiding the overlay until QA knows the gesture.
162
+
163
+ ### Keyboard shortcut (web only)
164
+
165
+ `Ctrl+Shift+V` toggles the overlay on web builds.
166
+
167
+ ### URL parameter (web only)
168
+
169
+ Append `?__variantlab=1` to the URL to force-enable the overlay even in production (requires `forceEnable` on the component).
170
+
171
+ ---
172
+
173
+ ## Information architecture
174
+
175
+ ### The "Overview" tab (default)
176
+
177
+ Shows experiments matching the current route, grouped by:
178
+
179
+ - **Active** — experiments this user is enrolled in
180
+ - **Targeted but not enrolled** — experiments the user matches but was excluded by weights
181
+ - **Not targeted** — experiments that exist but don't match this user's context
182
+
183
+ ### The "History" tab
184
+
185
+ Time-travel inspector. See [`time-travel.md`](./time-travel.md).
186
+
187
+ ### The "Context" tab
188
+
189
+ Shows the current `VariantContext`:
190
+
191
+ - Platform, app version, locale
192
+ - Screen size bucket
193
+ - Route
194
+ - User ID (masked)
195
+ - Attributes (JSON tree view)
196
+
197
+ ### The "Config" tab
198
+
199
+ Shows the loaded config:
200
+
201
+ - Version
202
+ - Source (bundled / remote / file)
203
+ - Last fetched
204
+ - Signature status (valid / invalid / none)
205
+ - Total experiments count
206
+
207
+ ### The "Events" tab
208
+
209
+ Live-tailing telemetry stream (if configured):
210
+
211
+ - Every `assignment`, `exposure`, `variantChanged`, `rollback` event
212
+ - Timestamp, experiment, variant, reason
213
+ - Can be paused and exported as JSON
214
+
215
+ ---
216
+
217
+ ## Customization
218
+
219
+ Users can customize the overlay extensively:
220
+
221
+ ```tsx
222
+ <VariantDebugOverlay
223
+ position="bottom-right"
224
+ offset={{ x: 20, y: 80 }}
225
+ theme="dark"
226
+ activation={{ tap: true, shake: true, shortcut: "mod+shift+v" }}
227
+ tabs={["overview", "history", "context", "config", "events"]}
228
+ showBadge
229
+ locked={false}
230
+ onOpen={() => console.log("overlay opened")}
231
+ onClose={() => console.log("overlay closed")}
232
+ onVariantChange={(exp, variant) => console.log(exp, variant)}
233
+ renderButton={(props) => <CustomButton {...props} />}
234
+ />
235
+ ```
236
+
237
+ ### Custom button render
238
+
239
+ Users who want a non-floating button can render their own trigger:
240
+
241
+ ```tsx
242
+ <VariantDebugOverlay renderButton={() => null} ref={overlayRef} />
243
+
244
+ <Button onPress={() => overlayRef.current?.open()}>
245
+ Open debug
246
+ </Button>
247
+ ```
248
+
249
+ ### Custom theme
250
+
251
+ The overlay respects the app's dark/light theme by default, but users can override:
252
+
253
+ ```tsx
254
+ <VariantDebugOverlay
255
+ theme={{
256
+ background: "#1a1a1a",
257
+ foreground: "#ffffff",
258
+ accent: "#8b5cf6",
259
+ border: "#333",
260
+ }}
261
+ />
262
+ ```
263
+
264
+ ---
265
+
266
+ ## Accessibility
267
+
268
+ ### Screen readers
269
+
270
+ - The floating button has `accessibilityLabel="Open variantlab debug overlay"`
271
+ - Each experiment card announces: "Experiment {name}, current variant {variant}, double tap to expand"
272
+ - Variant radios use native radio semantics
273
+ - Focus is trapped inside the sheet when open
274
+ - ESC closes the sheet (web)
275
+
276
+ ### Keyboard navigation (web)
277
+
278
+ - `Tab` cycles focus through controls
279
+ - `Enter` / `Space` activates buttons
280
+ - Arrow keys move between variant rows
281
+ - `ESC` closes the overlay
282
+
283
+ ### Reduced motion
284
+
285
+ If the user has `prefers-reduced-motion`, the slide animation is replaced with a fade.
286
+
287
+ ### Contrast
288
+
289
+ All text meets WCAG AA contrast against the overlay background in both light and dark themes.
290
+
291
+ ---
292
+
293
+ ## Performance
294
+
295
+ - The overlay lazy-loads its tab implementations
296
+ - The experiment list is virtualized when > 50 experiments
297
+ - The search input is debounced
298
+ - No re-renders on unrelated state changes (isolated subscription)
299
+
300
+ ---
301
+
302
+ ## Dependencies
303
+
304
+ - Core: `@variantlab/core`
305
+ - React Native: no extra deps beyond React Native itself
306
+ - Web: no extra deps beyond React
307
+ - No animation library (hand-rolled with Animated API / CSS transitions)
308
+ - No bottom-sheet library (hand-rolled modal)
309
+ - No icon library (inline SVGs)
310
+
311
+ This keeps the overlay < 4 KB gzipped even with all features.
312
+
313
+ ---
314
+
315
+ ## Integration with other features
316
+
317
+ - **Route scoping** ([`targeting.md`](./targeting.md#routes)): the overlay filters by `useRouteExperiments`
318
+ - **Time travel** ([`time-travel.md`](./time-travel.md)): the history tab
319
+ - **QR sharing** ([`qr-sharing.md`](./qr-sharing.md)): the "Share state" button generates a QR
320
+ - **Crash rollback** ([`crash-rollback.md`](./crash-rollback.md)): rollback events show in the events tab
321
+ - **Codegen** ([`codegen.md`](./codegen.md)): overlay shows variant labels from the generated types
322
+
323
+ ---
324
+
325
+ ## See also
326
+
327
+ - [`API.md`](../../API.md) — `VariantDebugOverlay` props
328
+ - [`origin-story.md`](../research/origin-story.md) — why this feature exists
@@ -0,0 +1,330 @@
1
+ # HMAC-signed remote configs
2
+
3
+ Tamper-proof remote config delivery via HMAC-SHA256 signatures verified with Web Crypto API.
4
+
5
+ ## Table of contents
6
+
7
+ - [Why signing matters](#why-signing-matters)
8
+ - [The threat model](#the-threat-model)
9
+ - [How it works](#how-it-works)
10
+ - [Generating a signature](#generating-a-signature)
11
+ - [Verifying a signature](#verifying-a-signature)
12
+ - [Key management](#key-management)
13
+ - [What signing does NOT protect](#what-signing-does-not-protect)
14
+ - [Deployment workflow](#deployment-workflow)
15
+
16
+ ---
17
+
18
+ ## Why signing matters
19
+
20
+ When your app fetches `experiments.json` from a remote URL, anyone who can intercept that request can modify the config. That includes:
21
+
22
+ - MITM attackers on public Wi-Fi
23
+ - Compromised CDNs
24
+ - DNS hijacking
25
+ - Rogue employees with deploy access
26
+ - Supply chain attackers in your build pipeline
27
+
28
+ A modified config can:
29
+
30
+ - Enable features meant for staging
31
+ - Change pricing
32
+ - Collect user data (if combined with telemetry)
33
+ - Break the app via invalid variants
34
+
35
+ TLS is **not sufficient**. TLS protects the transport, not the content. If the attacker compromises your CDN or your S3 bucket, they ship the config over TLS just like you do.
36
+
37
+ HMAC signing protects the **content** regardless of how it was transported.
38
+
39
+ ---
40
+
41
+ ## The threat model
42
+
43
+ variantlab signing defends against:
44
+
45
+ - **T1**: Malicious remote config (CDN compromise, MITM, internal rogue) — mitigated by signing
46
+ - **T2**: Tampered local storage (malicious app, device compromise) — out of scope (TLS + signing both fail)
47
+ - **T8**: Unauthorized variant overrides via deep link — mitigated via the same HMAC mechanism on share payloads
48
+
49
+ See [`SECURITY.md`](../../SECURITY.md) for the full threat table.
50
+
51
+ ---
52
+
53
+ ## How it works
54
+
55
+ ### Signing
56
+
57
+ 1. Developer writes `experiments.json`
58
+ 2. `variantlab sign experiments.json --key $SECRET` computes HMAC-SHA256 over the canonical JSON form
59
+ 3. The CLI writes the signature into the `signature` field of the JSON
60
+ 4. The signed file is deployed to the CDN
61
+
62
+ ### Verification
63
+
64
+ 1. App fetches the signed JSON
65
+ 2. Engine extracts the `signature` field and strips it from the JSON
66
+ 3. Engine computes HMAC-SHA256 over the canonical form of the remaining JSON
67
+ 4. Engine compares the computed signature with the provided signature using `crypto.subtle.verify` (constant time)
68
+ 5. If they match, the config is applied. If not, the config is rejected and the engine falls back to the bundled config.
69
+
70
+ ### Canonical form
71
+
72
+ HMAC requires a deterministic byte representation of the input. We define the canonical form as:
73
+
74
+ - UTF-8 encoding
75
+ - Keys sorted lexicographically at every level
76
+ - No whitespace
77
+ - `"\u"` escapes normalized
78
+ - Signature field removed before signing/verifying
79
+
80
+ This matches [RFC 8785 (JSON Canonicalization Scheme)](https://www.rfc-editor.org/rfc/rfc8785).
81
+
82
+ ---
83
+
84
+ ## Generating a signature
85
+
86
+ ### CLI
87
+
88
+ ```bash
89
+ # Set the key via env var
90
+ export VARIANTLAB_HMAC_KEY=mysecret123
91
+
92
+ # Sign the file in place
93
+ variantlab sign experiments.json
94
+
95
+ # Or pipe
96
+ cat experiments.json | variantlab sign --key $VARIANTLAB_HMAC_KEY > signed.json
97
+ ```
98
+
99
+ ### Programmatically
100
+
101
+ ```ts
102
+ import { signConfig } from "@variantlab/cli";
103
+
104
+ const signed = await signConfig(config, {
105
+ key: process.env.VARIANTLAB_HMAC_KEY,
106
+ });
107
+ // signed.signature = "base64url-encoded-signature"
108
+ ```
109
+
110
+ ### Output format
111
+
112
+ ```json
113
+ {
114
+ "$schema": "https://variantlab.dev/schemas/experiments.schema.json",
115
+ "version": 1,
116
+ "signature": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
117
+ "experiments": [...]
118
+ }
119
+ ```
120
+
121
+ The signature is the base64url-encoded HMAC-SHA256 of the canonical form (with `signature` field excluded).
122
+
123
+ ---
124
+
125
+ ## Verifying a signature
126
+
127
+ ### At engine creation
128
+
129
+ ```ts
130
+ import { createEngine } from "@variantlab/core";
131
+
132
+ const engine = createEngine(config, {
133
+ hmacKey: process.env.NEXT_PUBLIC_VARIANTLAB_KEY,
134
+ onSignatureFailure: "reject", // or "warn"
135
+ });
136
+ ```
137
+
138
+ - `"reject"` — reject the config, fall back to the bundled one, emit a warning
139
+ - `"warn"` — log a warning but still apply the config (useful for soft rollouts of signing)
140
+
141
+ ### With remote fetching
142
+
143
+ ```ts
144
+ import { createHttpFetcher } from "@variantlab/core/fetcher";
145
+
146
+ const fetcher = createHttpFetcher({
147
+ url: "https://cdn.example.com/experiments.json",
148
+ headers: { "Cache-Control": "no-cache" },
149
+ });
150
+
151
+ const engine = createEngine(initialConfig, {
152
+ fetcher,
153
+ hmacKey: "mysecret",
154
+ pollInterval: 60000,
155
+ });
156
+ ```
157
+
158
+ Every fetched config is verified before being applied.
159
+
160
+ ### Manual verification
161
+
162
+ ```ts
163
+ import { verifySignature } from "@variantlab/core/crypto";
164
+
165
+ const isValid = await verifySignature(config, key);
166
+ if (!isValid) {
167
+ throw new Error("Config signature invalid");
168
+ }
169
+ ```
170
+
171
+ ---
172
+
173
+ ## Key management
174
+
175
+ The HMAC key is a shared secret between:
176
+
177
+ 1. The build system that signs configs
178
+ 2. The client app that verifies them
179
+
180
+ ### Where to store the key
181
+
182
+ **On the build system**:
183
+
184
+ - Environment variable in CI (`VARIANTLAB_HMAC_KEY`)
185
+ - Secrets manager (AWS Secrets Manager, Vault, GCP Secret Manager)
186
+ - **Never** commit to git
187
+
188
+ **On the client**:
189
+
190
+ - Embedded at build time via env var (`NEXT_PUBLIC_VARIANTLAB_KEY`)
191
+ - The key is visible in the client bundle — treat it as a shared secret, not a private key
192
+ - **This means**: anyone who reverse-engineers your app can extract the key
193
+
194
+ ### Why is that OK?
195
+
196
+ HMAC is not authenticating the user; it's authenticating the **config source**. Even if an attacker extracts the key, they still can't:
197
+
198
+ - Push a signed config to your CDN (they'd need deploy access)
199
+ - Forge a config that your CDN serves (it's the CDN's bytes, not theirs)
200
+
201
+ The only attack the extracted key enables is: the attacker signs their own config and somehow gets your app to fetch it (e.g., via a malicious deep link with a custom fetcher). That's a multi-step attack that signing alone can't prevent.
202
+
203
+ ### If you need key secrecy
204
+
205
+ If you absolutely need the key to be secret:
206
+
207
+ - Fetch the config from your authenticated API instead of a public CDN
208
+ - Use TLS pinning in the fetcher
209
+ - Sign with a rotating key that's fetched from a secure endpoint
210
+
211
+ This is out of scope for variantlab core. We provide the primitives; you compose the policy.
212
+
213
+ ### Key rotation
214
+
215
+ To rotate:
216
+
217
+ 1. Generate a new key
218
+ 2. Update the build system to sign with the new key
219
+ 3. Ship an app update with the new key
220
+ 4. Wait for the old app version to age out
221
+ 5. Retire the old key
222
+
223
+ The engine supports **multiple keys** for smooth rotation:
224
+
225
+ ```ts
226
+ createEngine(config, {
227
+ hmacKeys: [
228
+ { id: "v2", key: currentKey },
229
+ { id: "v1", key: previousKey },
230
+ ],
231
+ });
232
+ ```
233
+
234
+ The config's `signature` field can optionally include a key ID prefix: `"v2:dBjftJeZ..."`. The engine looks up the matching key.
235
+
236
+ ---
237
+
238
+ ## What signing does NOT protect
239
+
240
+ ### Replay attacks
241
+
242
+ An old signed config is still valid. An attacker who captures `experiments.v1.json` can replay it later even after you've shipped `experiments.v2.json`.
243
+
244
+ **Mitigation**: include a `nonce` or `timestamp` field in the config and reject old ones in the engine options. Post-v0.1.
245
+
246
+ ### Rollback to an older version
247
+
248
+ Related to replay. If the attacker serves an older signed config with a lower `version`, the engine may accept it.
249
+
250
+ **Mitigation**: the engine tracks the highest version it has seen and rejects configs with lower versions.
251
+
252
+ ### Key compromise
253
+
254
+ If the HMAC key leaks, the attacker can sign arbitrary configs. Rotate the key immediately.
255
+
256
+ ### Bundled config tampering
257
+
258
+ The bundled `experiments.json` (shipped inside the app binary) is not signed at runtime — if someone tampers with the app bundle, they can replace it. This is out of scope — if an attacker can modify your app binary, they can do far worse than change experiments.
259
+
260
+ ### DoS via malformed signature
261
+
262
+ A config with an invalid signature is rejected, and the engine falls back. This is the correct behavior — it's not a vulnerability. But it does mean an attacker who can serve configs can deny new ones.
263
+
264
+ **Mitigation**: pin the last-known-good config in Storage and apply it if the fetched one fails verification.
265
+
266
+ ---
267
+
268
+ ## Deployment workflow
269
+
270
+ The canonical flow for signed remote configs:
271
+
272
+ ```
273
+ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
274
+ │ Developer │ │ CI │ │ CDN │ │ User device │
275
+ │ edits json │──▶│ signs json │──▶│ serves json │──▶│ verifies │
276
+ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
277
+ ```
278
+
279
+ ### Example GitHub Actions workflow
280
+
281
+ ```yaml
282
+ name: Deploy experiments
283
+ on:
284
+ push:
285
+ paths: ['experiments.json']
286
+
287
+ jobs:
288
+ deploy:
289
+ runs-on: ubuntu-latest
290
+ steps:
291
+ - uses: actions/checkout@v4
292
+
293
+ - name: Install variantlab CLI
294
+ run: npm install -g @variantlab/cli
295
+
296
+ - name: Validate config
297
+ run: variantlab validate experiments.json
298
+
299
+ - name: Sign config
300
+ run: variantlab sign experiments.json --out signed.json
301
+ env:
302
+ VARIANTLAB_HMAC_KEY: ${{ secrets.VARIANTLAB_HMAC_KEY }}
303
+
304
+ - name: Upload to S3
305
+ run: aws s3 cp signed.json s3://config.example.com/experiments.json
306
+ --cache-control "max-age=60"
307
+ ```
308
+
309
+ ### CDN cache control
310
+
311
+ Signed configs should have a short cache TTL (e.g., 60s) so that config updates propagate quickly. The engine's `pollInterval` should match or be slightly longer.
312
+
313
+ ---
314
+
315
+ ## Cost of signing
316
+
317
+ - **Bundle size**: ~300 bytes (Web Crypto API has no bundle cost; we only ship the canonicalizer and the verify wrapper)
318
+ - **Runtime**: HMAC verification takes ~1 ms on a modern device
319
+ - **Build time**: Signing takes < 100 ms
320
+
321
+ Signing is cheap. There's no reason not to turn it on for production remote configs.
322
+
323
+ ---
324
+
325
+ ## See also
326
+
327
+ - [`SECURITY.md`](../../SECURITY.md) — threat model
328
+ - [`config-format.md`](../design/config-format.md) — the `signature` field
329
+ - [`API.md`](../../API.md) — `signConfig`, `verifySignature`
330
+ - [RFC 8785 — JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785)