@usefidel/contracts 0.6.0 → 0.8.0

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,354 @@
1
+ /**
2
+ * Analytics contract — GA Phase 1.
3
+ *
4
+ * ONE vocabulary for client analytics event names and their property shapes,
5
+ * shared by `usefidel/usefidel` (extension, admin) and `usefidel/fidel-web`
6
+ * (webapp, landing).
7
+ *
8
+ * WHY THIS IS A PACKAGE AND NOT A LOCAL FILE PER SURFACE.
9
+ * The webapp and the extension each identified the same human under a
10
+ * different PostHog `distinct_id` — the webapp by Supabase UUID-or-email, the
11
+ * extension by email-or-Figma-handle. `extension/src/lib/posthog.ts` carried a
12
+ * comment asserting the two agreed. They did not. Nothing failed; retention
13
+ * cohorts were simply wrong, and stayed wrong, because the two "authoritative"
14
+ * definitions lived in two repositories with no gate between them.
15
+ *
16
+ * The package is the only mechanism that spans both repositories.
17
+ *
18
+ * WHAT THIS OWNS: event NAMES and property TYPES.
19
+ * WHAT IT DOES NOT OWN: SDK initialization, identity resolution, storage,
20
+ * environment detection, redaction, or any `posthog.*` call. Those stay in a
21
+ * per-surface wrapper, because they differ per surface in ways a shared module
22
+ * would have to branch on — and a shared module full of surface branches is
23
+ * how the last divergence started.
24
+ *
25
+ * NARROWNESS (see index.ts). This file adds runtime values — two frozen name
26
+ * arrays and one error-code-to-phase map. That is the same shape as
27
+ * `RUN_ERROR_CODES`, which this package already publishes. It carries no
28
+ * engine, no endpoint, no credential, and no backend logic. Event names
29
+ * describe which buttons exist, which a contractor installing the package can
30
+ * already observe by using the product.
31
+ */
32
+ import { type RunErrorCode } from './run-errors.js';
33
+ /**
34
+ * Schema version stamped on every event emitted by a contract-aware wrapper.
35
+ *
36
+ * Version 1 is everything emitted before the GA cutover: mixed identity,
37
+ * success events that fired before the operation succeeded, camelCase and
38
+ * snake_case properties side by side. Version 2 is this contract.
39
+ *
40
+ * The versions are NOT semantically comparable and must never be summed in one
41
+ * tile. Identity is preserved across the cutover by aliasing, so a person's
42
+ * history survives; the meaning of their events does not.
43
+ */
44
+ export declare const ANALYTICS_SCHEMA_VERSION: 2;
45
+ /** Which client emitted the event. Server-side surfaces do not emit at all. */
46
+ export type AnalyticsSurface = 'landing' | 'webapp' | 'extension';
47
+ /** Which deployment emitted the event. Drives the default dashboard filter. */
48
+ export type AnalyticsEnvironment = 'production' | 'staging' | 'development';
49
+ /**
50
+ * Validation mode.
51
+ *
52
+ * Mirrors the `validation_runs.validation_mode` CHECK constraint —
53
+ * migration 096 established `figma_vs_live` / `url_vs_url`, migration 098
54
+ * added `design_system_vs_live`. Kept in step by `check-canonical-sync.mjs`.
55
+ */
56
+ export type ValidationMode = 'figma_vs_live' | 'url_vs_url' | 'design_system_vs_live';
57
+ /**
58
+ * How much of the required scope Fidel actually inspected.
59
+ *
60
+ * STRUCTURAL COPY of `EvidenceState` in
61
+ * `supabase/functions/_shared/result-completeness.ts` (plan 051 P1-5), which
62
+ * is the canonical definition and is vendor-synced to the Lambda and the
63
+ * marketplace Action. `check-canonical-sync.mjs` asserts this union matches
64
+ * that one exactly.
65
+ *
66
+ * Present ONLY on `design_system_vs_live` runs. Absent on every other mode by
67
+ * design — see `WebValidationCompletedProperties.result_completeness`.
68
+ */
69
+ export type ResultCompleteness = 'complete' | 'partial' | 'unverified' | 'configuration_required' | 'operational_failure';
70
+ /**
71
+ * Which stage of a run failed.
72
+ *
73
+ * DERIVED, never stored. `validation_runs` has `error_code` but no
74
+ * `error_phase` column, so phase is computed from the code by
75
+ * `errorPhaseFor()` below. That keeps one source of truth (the taxonomy) and
76
+ * means admin can group failures by phase today without a migration.
77
+ */
78
+ export type ValidationErrorPhase =
79
+ /** The submitted URLs were rejected before any work started. */
80
+ 'input_validation'
81
+ /** Could not reach, load, or authenticate against the live target. */
82
+ | 'target_capture'
83
+ /** Could not read the Figma file. */
84
+ | 'figma_fetch'
85
+ /** The design system or brand context could not be resolved. */
86
+ | 'design_system_resolve'
87
+ /** The matching pipeline ran and failed, or produced nothing usable. */
88
+ | 'pipeline'
89
+ /** The run completed but the result could not be written. */
90
+ | 'persist'
91
+ /** Unclassified. A growing count here means the taxonomy needs a code. */
92
+ | 'unknown';
93
+ /**
94
+ * Every run error code, mapped to the phase it belongs to.
95
+ *
96
+ * EXHAUSTIVE BY CONSTRUCTION: the `Record<RunErrorCode, …>` type means adding
97
+ * a code to the taxonomy without assigning it a phase is a compile error in
98
+ * this package, which is a merge gate in both repositories. That is deliberate
99
+ * — the alternative is a default branch, and a default branch silently files
100
+ * every new code under `unknown`.
101
+ */
102
+ export declare const ERROR_CODE_PHASE: Record<RunErrorCode, ValidationErrorPhase>;
103
+ /**
104
+ * Phase for a code, tolerating values from outside the taxonomy.
105
+ *
106
+ * The admin dashboard reads `error_code` out of Postgres, where it is a bare
107
+ * TEXT column with no FK to the taxonomy. A row written by an older deploy can
108
+ * therefore hold a code this build has never heard of. Returning `'unknown'`
109
+ * for that is right; throwing would take down the tile.
110
+ */
111
+ export declare function errorPhaseFor(code: string | null | undefined): ValidationErrorPhase;
112
+ /** True when `code` is a member of the current taxonomy. */
113
+ export declare function isKnownErrorCode(code: string | null | undefined): code is RunErrorCode;
114
+ /** Events the GA dashboard is built on. Typed properties, specified triggers. */
115
+ export declare const GA_EVENT_NAMES: readonly ["signup_started", "signup_completed", "onboarding_started", "onboarding_completed", "figma_connected", "environment_connected", "web_validation_started", "web_validation_completed", "web_validation_failed", "web_validation_cancelled", "report_viewed", "report_shared", "diff_mark_resolved", "upgrade_clicked", "checkout_started", "feedback_submitted", "extension_opened", "validation_start", "validation_accepted", "validation_complete", "validation_error", "validation_cancel", "review_saved", "figma_auth_success", "figma_auth_error", "extension_share_copied", "upgrade_modal_viewed", "upgrade_cta_clicked"];
116
+ /**
117
+ * Events that exist but are NOT GA metrics.
118
+ *
119
+ * `checkout_completed` is here rather than in `GA_EVENT_NAMES` deliberately.
120
+ * It fires in the browser on return from Stripe, which is neither reliable
121
+ * (the tab can be closed) nor authoritative (the webhook is). Paid conversion
122
+ * comes from `teams.subscription_status` and Stripe. This event is a funnel
123
+ * diagnostic for "did the user come back", nothing more, and must be labelled
124
+ * as such wherever it appears.
125
+ */
126
+ export declare const SECONDARY_EVENT_NAMES: readonly ["checkout_completed", "console_errors", "diff_expanded", "drawer_filter_changed", "review_save_error", "review_verdict", "review_verdict_all", "screenshot_exported", "ui_crash", "validation_resume", "validation_retry", "view_changed", "auth_refresh", "consensus_complete", "consensus_error", "extension_validation_failed", "idempotency_dedup_client", "pipeline_complete", "pipeline_phase", "pipeline_resume", "rate_limit_hit", "upgrade_wall_hit"];
127
+ /** Every event name any Fidel client may emit. */
128
+ export declare const ANALYTICS_EVENT_NAMES: readonly ["signup_started", "signup_completed", "onboarding_started", "onboarding_completed", "figma_connected", "environment_connected", "web_validation_started", "web_validation_completed", "web_validation_failed", "web_validation_cancelled", "report_viewed", "report_shared", "diff_mark_resolved", "upgrade_clicked", "checkout_started", "feedback_submitted", "extension_opened", "validation_start", "validation_accepted", "validation_complete", "validation_error", "validation_cancel", "review_saved", "figma_auth_success", "figma_auth_error", "extension_share_copied", "upgrade_modal_viewed", "upgrade_cta_clicked", "checkout_completed", "console_errors", "diff_expanded", "drawer_filter_changed", "review_save_error", "review_verdict", "review_verdict_all", "screenshot_exported", "ui_crash", "validation_resume", "validation_retry", "view_changed", "auth_refresh", "consensus_complete", "consensus_error", "extension_validation_failed", "idempotency_dedup_client", "pipeline_complete", "pipeline_phase", "pipeline_resume", "rate_limit_hit", "upgrade_wall_hit"];
129
+ export type GaEventName = (typeof GA_EVENT_NAMES)[number];
130
+ export type SecondaryEventName = (typeof SECONDARY_EVENT_NAMES)[number];
131
+ export type AnalyticsEventName = GaEventName | SecondaryEventName;
132
+ /**
133
+ * Stamped on every schema-2 event by the wrapper, not by call sites.
134
+ *
135
+ * `team_id` and `is_internal` are optional because the extension cannot get
136
+ * them without a network request it is not allowed to make: `team_id` lives in
137
+ * `profiles`, not in the Supabase JWT. Team-level analysis uses
138
+ * `validation_runs.team_id` in the database instead. The webapp has both and
139
+ * always sends them.
140
+ *
141
+ * There is no `user_id` field. The Supabase UUID is the `distinct_id`;
142
+ * duplicating it as a property invites two sources of truth for identity.
143
+ */
144
+ export interface AnalyticsContext {
145
+ analytics_schema_version: typeof ANALYTICS_SCHEMA_VERSION;
146
+ environment: AnalyticsEnvironment;
147
+ surface: AnalyticsSurface;
148
+ /** Git SHA (webapp/landing) or `manifest.json` version (extension). */
149
+ release: string;
150
+ team_id?: string;
151
+ is_internal?: boolean;
152
+ }
153
+ /**
154
+ * Person properties. Set on identify, never sent as event properties.
155
+ *
156
+ * `email` is a person property and not a `distinct_id` — that distinction is
157
+ * the entire identity migration. See plan §3.
158
+ */
159
+ export interface AnalyticsPersonProperties {
160
+ email?: string;
161
+ figma_handle?: string;
162
+ team_id?: string;
163
+ plan?: string;
164
+ subscription_status?: string;
165
+ is_internal?: boolean;
166
+ }
167
+ /** Properties common to every validation event, on both clients. */
168
+ export interface ValidationEventProperties {
169
+ /** `validation_runs.id`. The join key across PostHog, Postgres and Sentry. */
170
+ run_id: string;
171
+ validation_mode: ValidationMode;
172
+ }
173
+ export interface ValidationStartedProperties extends ValidationEventProperties {
174
+ viewport?: string;
175
+ is_revalidation?: boolean;
176
+ }
177
+ export interface WebValidationCompletedProperties extends ValidationEventProperties {
178
+ duration_ms: number;
179
+ /**
180
+ * Confirmed violations, per the result contract.
181
+ *
182
+ * For `design_system_vs_live` this is `driftCount + offTokenCount` and is
183
+ * `null` when either half is unavailable. It is NEVER `diffs.length`:
184
+ * that array also holds `unused` and `scoped` findings, and printing it as a
185
+ * violation count is the exact defect plan 051 P1-5 was written to close.
186
+ *
187
+ * `null` means "the producer told us nothing", which is not zero.
188
+ */
189
+ violation_count: number | null;
190
+ /**
191
+ * ABSENT on `figma_vs_live` and `url_vs_url` runs — the pipeline only emits
192
+ * a completeness block for `design_system_vs_live`. Absence here is correct
193
+ * behaviour and not an instrumentation failure; any tile using it must be
194
+ * scoped to DS runs.
195
+ */
196
+ result_completeness?: ResultCompleteness;
197
+ }
198
+ /**
199
+ * A run that failed or was cancelled.
200
+ *
201
+ * `run_id` is NULLABLE here and required on the success events, and the
202
+ * asymmetry is the point: a run rejected BEFORE the backend accepted it — the
203
+ * upgrade wall, a rate limit, an invalid URL — never received an id. Requiring
204
+ * one would force those events to be dropped, and they are the failures most
205
+ * worth counting. `null` means "rejected before acceptance"; a string means
206
+ * "accepted, then failed".
207
+ */
208
+ export interface ValidationFailedProperties {
209
+ run_id: string | null;
210
+ validation_mode: ValidationMode;
211
+ duration_ms: number;
212
+ error_code: string;
213
+ error_phase: ValidationErrorPhase;
214
+ retryable: boolean;
215
+ }
216
+ export interface ValidationCancelledProperties {
217
+ run_id: string | null;
218
+ validation_mode: ValidationMode;
219
+ duration_ms: number;
220
+ error_phase: ValidationErrorPhase;
221
+ }
222
+ export interface ReportViewedProperties {
223
+ run_id: string;
224
+ validation_mode: ValidationMode;
225
+ finding_count: number;
226
+ }
227
+ /**
228
+ * The exhaustive event-to-property mapping.
229
+ *
230
+ * Secondary events map to a permissive record: they predate this contract and
231
+ * typing their existing payloads would be a rename exercise with no consumer.
232
+ * They are still name-checked, which is what the drift gate needs.
233
+ */
234
+ export interface AnalyticsEventProperties {
235
+ signup_started: {
236
+ plan?: string;
237
+ };
238
+ signup_completed: {
239
+ method: string;
240
+ confirmation_required: boolean;
241
+ plan?: string;
242
+ };
243
+ onboarding_started: Record<string, never>;
244
+ onboarding_completed: {
245
+ completion_path: string;
246
+ figma_connected: boolean;
247
+ environment_connected: boolean;
248
+ };
249
+ figma_connected: {
250
+ method: 'oauth' | 'pat';
251
+ is_first_connect: boolean;
252
+ };
253
+ environment_connected: {
254
+ connection_type: string;
255
+ };
256
+ web_validation_started: ValidationStartedProperties;
257
+ web_validation_completed: WebValidationCompletedProperties;
258
+ web_validation_failed: ValidationFailedProperties;
259
+ web_validation_cancelled: ValidationCancelledProperties;
260
+ report_viewed: ReportViewedProperties;
261
+ report_shared: {
262
+ run_id: string;
263
+ };
264
+ diff_mark_resolved: {
265
+ run_id: string;
266
+ review_action: string;
267
+ };
268
+ upgrade_clicked: {
269
+ plan: string;
270
+ location: string;
271
+ };
272
+ checkout_started: {
273
+ plan: string;
274
+ interval: 'monthly' | 'annual';
275
+ };
276
+ feedback_submitted: {
277
+ feedback_type: string;
278
+ };
279
+ extension_opened: Record<string, never>;
280
+ /** User intent — the Validate button was pressed. Not an accepted run. */
281
+ validation_start: {
282
+ validation_mode: ValidationMode;
283
+ };
284
+ /** The backend accepted the run and issued a run id. The GA "started" event. */
285
+ validation_accepted: ValidationStartedProperties;
286
+ validation_complete: WebValidationCompletedProperties;
287
+ validation_error: ValidationFailedProperties;
288
+ validation_cancel: ValidationCancelledProperties;
289
+ review_saved: {
290
+ run_id?: string;
291
+ review_action: string;
292
+ };
293
+ figma_auth_success: Record<string, never>;
294
+ figma_auth_error: {
295
+ error: string;
296
+ };
297
+ extension_share_copied: {
298
+ run_id?: string;
299
+ };
300
+ upgrade_modal_viewed: {
301
+ plan: string;
302
+ };
303
+ upgrade_cta_clicked: {
304
+ plan: string;
305
+ location: string;
306
+ };
307
+ checkout_completed: {
308
+ plan: string;
309
+ subscription_status: string;
310
+ };
311
+ console_errors: Record<string, unknown>;
312
+ diff_expanded: Record<string, unknown>;
313
+ drawer_filter_changed: Record<string, unknown>;
314
+ review_save_error: Record<string, unknown>;
315
+ review_verdict: Record<string, unknown>;
316
+ review_verdict_all: Record<string, unknown>;
317
+ screenshot_exported: Record<string, unknown>;
318
+ ui_crash: Record<string, unknown>;
319
+ validation_resume: Record<string, unknown>;
320
+ validation_retry: Record<string, unknown>;
321
+ view_changed: Record<string, unknown>;
322
+ auth_refresh: Record<string, unknown>;
323
+ consensus_complete: Record<string, unknown>;
324
+ consensus_error: Record<string, unknown>;
325
+ extension_validation_failed: Record<string, unknown>;
326
+ idempotency_dedup_client: Record<string, unknown>;
327
+ pipeline_complete: Record<string, unknown>;
328
+ pipeline_phase: Record<string, unknown>;
329
+ pipeline_resume: Record<string, unknown>;
330
+ rate_limit_hit: Record<string, unknown>;
331
+ upgrade_wall_hit: Record<string, unknown>;
332
+ }
333
+ /** Payload for one event: its own properties plus the wrapper's context. */
334
+ export type AnalyticsPayload<E extends AnalyticsEventName> = AnalyticsEventProperties[E] & AnalyticsContext;
335
+ /**
336
+ * Property names that must never reach an analytics backend, at any value.
337
+ *
338
+ * This is a NAME denylist and therefore a backstop, not the control. The
339
+ * control is that call sites do not pass these. It exists because the cost of
340
+ * one leaked token is unbounded and the cost of a `Set.has()` is not.
341
+ *
342
+ * Design-token names and CSS custom-property names are NOT credentials and are
343
+ * deliberately absent from this list — they are frequently the only useful
344
+ * debugging signal on a DS run.
345
+ */
346
+ export declare const FORBIDDEN_PROPERTY_KEYS: readonly string[];
347
+ /**
348
+ * Strip forbidden keys and `undefined` values from a property bag.
349
+ *
350
+ * `undefined` is dropped rather than sent because PostHog stores it as a real
351
+ * property value, which then shows up in the UI as an existing-but-empty
352
+ * property and makes "is not set" filters wrong.
353
+ */
354
+ export declare function sanitizeProperties(props: Record<string, unknown> | undefined): Record<string, unknown>;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Analytics contract — GA Phase 1.
3
+ *
4
+ * ONE vocabulary for client analytics event names and their property shapes,
5
+ * shared by `usefidel/usefidel` (extension, admin) and `usefidel/fidel-web`
6
+ * (webapp, landing).
7
+ *
8
+ * WHY THIS IS A PACKAGE AND NOT A LOCAL FILE PER SURFACE.
9
+ * The webapp and the extension each identified the same human under a
10
+ * different PostHog `distinct_id` — the webapp by Supabase UUID-or-email, the
11
+ * extension by email-or-Figma-handle. `extension/src/lib/posthog.ts` carried a
12
+ * comment asserting the two agreed. They did not. Nothing failed; retention
13
+ * cohorts were simply wrong, and stayed wrong, because the two "authoritative"
14
+ * definitions lived in two repositories with no gate between them.
15
+ *
16
+ * The package is the only mechanism that spans both repositories.
17
+ *
18
+ * WHAT THIS OWNS: event NAMES and property TYPES.
19
+ * WHAT IT DOES NOT OWN: SDK initialization, identity resolution, storage,
20
+ * environment detection, redaction, or any `posthog.*` call. Those stay in a
21
+ * per-surface wrapper, because they differ per surface in ways a shared module
22
+ * would have to branch on — and a shared module full of surface branches is
23
+ * how the last divergence started.
24
+ *
25
+ * NARROWNESS (see index.ts). This file adds runtime values — two frozen name
26
+ * arrays and one error-code-to-phase map. That is the same shape as
27
+ * `RUN_ERROR_CODES`, which this package already publishes. It carries no
28
+ * engine, no endpoint, no credential, and no backend logic. Event names
29
+ * describe which buttons exist, which a contractor installing the package can
30
+ * already observe by using the product.
31
+ */
32
+ import { RUN_ERROR_CODES } from './run-errors.js';
33
+ /**
34
+ * Schema version stamped on every event emitted by a contract-aware wrapper.
35
+ *
36
+ * Version 1 is everything emitted before the GA cutover: mixed identity,
37
+ * success events that fired before the operation succeeded, camelCase and
38
+ * snake_case properties side by side. Version 2 is this contract.
39
+ *
40
+ * The versions are NOT semantically comparable and must never be summed in one
41
+ * tile. Identity is preserved across the cutover by aliasing, so a person's
42
+ * history survives; the meaning of their events does not.
43
+ */
44
+ export const ANALYTICS_SCHEMA_VERSION = 2;
45
+ /**
46
+ * Every run error code, mapped to the phase it belongs to.
47
+ *
48
+ * EXHAUSTIVE BY CONSTRUCTION: the `Record<RunErrorCode, …>` type means adding
49
+ * a code to the taxonomy without assigning it a phase is a compile error in
50
+ * this package, which is a merge gate in both repositories. That is deliberate
51
+ * — the alternative is a default branch, and a default branch silently files
52
+ * every new code under `unknown`.
53
+ */
54
+ export const ERROR_CODE_PHASE = {
55
+ INVALID_REFERENCE_URL: 'input_validation',
56
+ INVALID_TARGET_URL: 'input_validation',
57
+ TARGET_AUTH_WALL: 'target_capture',
58
+ TARGET_UNREACHABLE: 'target_capture',
59
+ TARGET_TIMEOUT: 'target_capture',
60
+ TARGET_CSP_BLOCKED: 'target_capture',
61
+ SESSION_EXPIRED: 'target_capture',
62
+ CAPABILITY_UNAVAILABLE: 'target_capture',
63
+ PROVIDER_REQUEST_REJECTED: 'target_capture',
64
+ FIGMA_ACCESS_DENIED: 'figma_fetch',
65
+ FIGMA_TOKEN_EXPIRED: 'figma_fetch',
66
+ FIGMA_NOT_FOUND: 'figma_fetch',
67
+ FIGMA_RATE_LIMITED: 'figma_fetch',
68
+ DESIGN_SYSTEM_NOT_AVAILABLE: 'design_system_resolve',
69
+ DESIGN_SYSTEM_CONTEXT_NOT_AVAILABLE: 'design_system_resolve',
70
+ DESIGN_SYSTEM_INCOMPATIBLE: 'design_system_resolve',
71
+ PIPELINE_TIMEOUT: 'pipeline',
72
+ PIPELINE_ERROR: 'pipeline',
73
+ ZERO_ELEMENTS_MATCHED: 'pipeline',
74
+ FLOW_STEP_FAILED: 'pipeline',
75
+ PERSIST_FAILED: 'persist',
76
+ UNKNOWN_ERROR: 'unknown',
77
+ };
78
+ /**
79
+ * Phase for a code, tolerating values from outside the taxonomy.
80
+ *
81
+ * The admin dashboard reads `error_code` out of Postgres, where it is a bare
82
+ * TEXT column with no FK to the taxonomy. A row written by an older deploy can
83
+ * therefore hold a code this build has never heard of. Returning `'unknown'`
84
+ * for that is right; throwing would take down the tile.
85
+ */
86
+ export function errorPhaseFor(code) {
87
+ if (!code)
88
+ return 'unknown';
89
+ return ERROR_CODE_PHASE[code] ?? 'unknown';
90
+ }
91
+ /** True when `code` is a member of the current taxonomy. */
92
+ export function isKnownErrorCode(code) {
93
+ return !!code && RUN_ERROR_CODES.includes(code);
94
+ }
95
+ // ── Event names ──────────────────────────────────────────────────────────────
96
+ //
97
+ // TWO LISTS, ONE UNION.
98
+ //
99
+ // GA events are the ones the GA Command Center is built on. They have typed,
100
+ // required properties and their firing conditions are specified in the plan.
101
+ //
102
+ // Secondary events already exist in the extension and keep flowing. They are
103
+ // listed because the drift gate is exhaustive: an emitted name absent from
104
+ // this file fails CI. Listing them is not an endorsement — several are
105
+ // diagnostic and must stay out of GA tiles.
106
+ //
107
+ // The extension's raw names differ from the webapp's on purpose. Renaming them
108
+ // would orphan existing history for no gain; PostHog *actions* combine the
109
+ // pairs (see plan §13). What matters is that both are declared here.
110
+ /** Events the GA dashboard is built on. Typed properties, specified triggers. */
111
+ export const GA_EVENT_NAMES = [
112
+ // webapp
113
+ 'signup_started',
114
+ 'signup_completed',
115
+ 'onboarding_started',
116
+ 'onboarding_completed',
117
+ 'figma_connected',
118
+ 'environment_connected',
119
+ 'web_validation_started',
120
+ 'web_validation_completed',
121
+ 'web_validation_failed',
122
+ 'web_validation_cancelled',
123
+ 'report_viewed',
124
+ 'report_shared',
125
+ 'diff_mark_resolved',
126
+ 'upgrade_clicked',
127
+ 'checkout_started',
128
+ 'feedback_submitted',
129
+ // extension — existing names, corrected semantics
130
+ 'extension_opened',
131
+ 'validation_start',
132
+ 'validation_accepted',
133
+ 'validation_complete',
134
+ 'validation_error',
135
+ 'validation_cancel',
136
+ 'review_saved',
137
+ 'figma_auth_success',
138
+ 'figma_auth_error',
139
+ 'extension_share_copied',
140
+ 'upgrade_modal_viewed',
141
+ 'upgrade_cta_clicked',
142
+ ];
143
+ /**
144
+ * Events that exist but are NOT GA metrics.
145
+ *
146
+ * `checkout_completed` is here rather than in `GA_EVENT_NAMES` deliberately.
147
+ * It fires in the browser on return from Stripe, which is neither reliable
148
+ * (the tab can be closed) nor authoritative (the webhook is). Paid conversion
149
+ * comes from `teams.subscription_status` and Stripe. This event is a funnel
150
+ * diagnostic for "did the user come back", nothing more, and must be labelled
151
+ * as such wherever it appears.
152
+ */
153
+ export const SECONDARY_EVENT_NAMES = [
154
+ 'checkout_completed',
155
+ // extension UI interaction + diagnostics
156
+ 'console_errors',
157
+ 'diff_expanded',
158
+ 'drawer_filter_changed',
159
+ 'review_save_error',
160
+ 'review_verdict',
161
+ 'review_verdict_all',
162
+ 'screenshot_exported',
163
+ 'ui_crash',
164
+ 'validation_resume',
165
+ 'validation_retry',
166
+ 'view_changed',
167
+ // extension pipeline internals, emitted from static IIFEs (hp.js)
168
+ 'auth_refresh',
169
+ 'consensus_complete',
170
+ 'consensus_error',
171
+ 'extension_validation_failed',
172
+ 'idempotency_dedup_client',
173
+ 'pipeline_complete',
174
+ 'pipeline_phase',
175
+ 'pipeline_resume',
176
+ 'rate_limit_hit',
177
+ 'upgrade_wall_hit',
178
+ ];
179
+ /** Every event name any Fidel client may emit. */
180
+ export const ANALYTICS_EVENT_NAMES = [
181
+ ...GA_EVENT_NAMES,
182
+ ...SECONDARY_EVENT_NAMES,
183
+ ];
184
+ // ── Redaction ────────────────────────────────────────────────────────────────
185
+ /**
186
+ * Property names that must never reach an analytics backend, at any value.
187
+ *
188
+ * This is a NAME denylist and therefore a backstop, not the control. The
189
+ * control is that call sites do not pass these. It exists because the cost of
190
+ * one leaked token is unbounded and the cost of a `Set.has()` is not.
191
+ *
192
+ * Design-token names and CSS custom-property names are NOT credentials and are
193
+ * deliberately absent from this list — they are frequently the only useful
194
+ * debugging signal on a DS run.
195
+ */
196
+ export const FORBIDDEN_PROPERTY_KEYS = [
197
+ 'access_token',
198
+ 'accessToken',
199
+ 'refresh_token',
200
+ 'refreshToken',
201
+ 'authorization',
202
+ 'Authorization',
203
+ 'apikey',
204
+ 'apiKey',
205
+ 'api_key',
206
+ 'anon_key',
207
+ 'anonKey',
208
+ 'cookie',
209
+ 'Cookie',
210
+ 'share_token',
211
+ 'shareToken',
212
+ 'figma_access_token',
213
+ 'figma_token',
214
+ 'figmaToken',
215
+ 'pat',
216
+ 'password',
217
+ 'jwt',
218
+ 'secret',
219
+ 't', // the `?t=` share-token query parameter
220
+ ];
221
+ /**
222
+ * Strip forbidden keys and `undefined` values from a property bag.
223
+ *
224
+ * `undefined` is dropped rather than sent because PostHog stores it as a real
225
+ * property value, which then shows up in the UI as an existing-but-empty
226
+ * property and makes "is not set" filters wrong.
227
+ */
228
+ export function sanitizeProperties(props) {
229
+ if (!props)
230
+ return {};
231
+ const forbidden = new Set(FORBIDDEN_PROPERTY_KEYS.map((k) => k.toLowerCase()));
232
+ const out = {};
233
+ for (const [key, value] of Object.entries(props)) {
234
+ if (value === undefined)
235
+ continue;
236
+ if (forbidden.has(key.toLowerCase()))
237
+ continue;
238
+ out[key] = value;
239
+ }
240
+ return out;
241
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export { RUN_ERROR_CODES, ERROR_CODE_META, mapSnapshotErrorToCode, mapFigmaErrorToCode, resolveRunDisplay, } from './run-errors.js';
2
2
  export type { RunErrorCode, RunErrorMeta, ErrorMapping, RunDisplay, } from './run-errors.js';
3
3
  export type { IntakeFramework, UnsupportedStack, UnsupportedReason, UnresolvedReferenceSection, UnresolvedReferenceReason, UnresolvedReference, ParsedThemeColor, ParsedThemeTypography, ParsedThemeSize, ShadcnConfig, ParsedTheme, IntakeDetection, TokenSourceKind, TokenSource, PackageRole, PackageInfo, } from './theme-intake.js';
4
+ export { ANALYTICS_SCHEMA_VERSION, ANALYTICS_EVENT_NAMES, GA_EVENT_NAMES, SECONDARY_EVENT_NAMES, ERROR_CODE_PHASE, FORBIDDEN_PROPERTY_KEYS, errorPhaseFor, isKnownErrorCode, sanitizeProperties, } from './analytics.js';
5
+ export type { AnalyticsSurface, AnalyticsEnvironment, AnalyticsContext, AnalyticsPersonProperties, AnalyticsEventName, GaEventName, SecondaryEventName, AnalyticsEventProperties, AnalyticsPayload, ValidationMode, ValidationErrorPhase, ResultCompleteness, ValidationEventProperties, ValidationStartedProperties, WebValidationCompletedProperties, ValidationFailedProperties, ValidationCancelledProperties, ReportViewedProperties, } from './analytics.js';
package/dist/index.js CHANGED
@@ -16,3 +16,21 @@
16
16
  // Adding an export here is an architectural change, not a convenience — it needs
17
17
  // the same review as any cross-surface contract.
18
18
  export { RUN_ERROR_CODES, ERROR_CODE_META, mapSnapshotErrorToCode, mapFigmaErrorToCode, resolveRunDisplay, } from './run-errors.js';
19
+ // analytics — client event names and property shapes (GA Phase 1).
20
+ //
21
+ // WHY THIS BELONGS HERE, against the narrowness rule above: it is the only
22
+ // mechanism that spans both repositories. The webapp and extension each held a
23
+ // local, self-declared "authoritative" identity and event definition; they
24
+ // disagreed, nothing failed, and retention cohorts were silently wrong for
25
+ // months. A gate that lives in one repo cannot catch that.
26
+ //
27
+ // It carries two frozen string arrays, one error-code-to-phase map and one
28
+ // pure sanitizer — the same runtime shape as RUN_ERROR_CODES above. No engine,
29
+ // no endpoint, no credential, no backend logic. Event names describe which
30
+ // buttons exist, which anyone using the product can already observe.
31
+ //
32
+ // What it deliberately does NOT carry are the SDK wrappers: initialization,
33
+ // identity resolution, storage and environment detection stay per-surface,
34
+ // because a shared module would have to branch on surface and a shared module
35
+ // full of surface branches is how the divergence started.
36
+ export { ANALYTICS_SCHEMA_VERSION, ANALYTICS_EVENT_NAMES, GA_EVENT_NAMES, SECONDARY_EVENT_NAMES, ERROR_CODE_PHASE, FORBIDDEN_PROPERTY_KEYS, errorPhaseFor, isKnownErrorCode, sanitizeProperties, } from './analytics.js';
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "The source of truth still lives in the monorepo at packages/contracts/."
8
8
  ],
9
9
  "name": "@usefidel/contracts",
10
- "version": "0.6.0",
10
+ "version": "0.8.0",
11
11
  "description": "Shared, code-free contracts between Fidel surfaces. Run-error taxonomy, theme-intake wire types, and the canonical fidel.config.json builders.",
12
12
  "license": "UNLICENSED",
13
13
  "private": false,
@@ -31,6 +31,10 @@
31
31
  "./onboarding-config": {
32
32
  "types": "./dist/onboarding-config.d.ts",
33
33
  "import": "./dist/onboarding-config.js"
34
+ },
35
+ "./analytics": {
36
+ "types": "./dist/analytics.d.ts",
37
+ "import": "./dist/analytics.js"
34
38
  }
35
39
  },
36
40
  "files": [