hazo_umetrics 0.1.1

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.
Files changed (43) hide show
  1. package/CHANGE_LOG.md +28 -0
  2. package/README.md +129 -0
  3. package/SETUP_CHECKLIST.md +118 -0
  4. package/config/hazo_umetrics_config.ini.sample +35 -0
  5. package/dist/api/index.d.ts +78 -0
  6. package/dist/api/index.d.ts.map +1 -0
  7. package/dist/api/index.js +375 -0
  8. package/dist/index.client.d.ts +23 -0
  9. package/dist/index.client.d.ts.map +1 -0
  10. package/dist/index.client.js +44 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +5 -0
  14. package/dist/lib/index.d.ts +7 -0
  15. package/dist/lib/index.d.ts.map +1 -0
  16. package/dist/lib/index.js +7 -0
  17. package/dist/server/cookie.d.ts +9 -0
  18. package/dist/server/cookie.d.ts.map +1 -0
  19. package/dist/server/cookie.js +32 -0
  20. package/dist/server/experiments.d.ts +30 -0
  21. package/dist/server/experiments.d.ts.map +1 -0
  22. package/dist/server/experiments.js +17 -0
  23. package/dist/server/flags.d.ts +44 -0
  24. package/dist/server/flags.d.ts.map +1 -0
  25. package/dist/server/flags.js +207 -0
  26. package/dist/server/ga4.d.ts +30 -0
  27. package/dist/server/ga4.d.ts.map +1 -0
  28. package/dist/server/ga4.js +19 -0
  29. package/dist/server/stats.d.ts +51 -0
  30. package/dist/server/stats.d.ts.map +1 -0
  31. package/dist/server/stats.js +129 -0
  32. package/dist/server/types.d.ts +65 -0
  33. package/dist/server/types.d.ts.map +1 -0
  34. package/dist/server/types.js +2 -0
  35. package/dist/ui/index.d.ts +2 -0
  36. package/dist/ui/index.d.ts.map +1 -0
  37. package/dist/ui/index.js +3 -0
  38. package/dist/ui/metrics_panel.d.ts +33 -0
  39. package/dist/ui/metrics_panel.d.ts.map +1 -0
  40. package/dist/ui/metrics_panel.js +195 -0
  41. package/migrations/db_setup_postgres.sql +116 -0
  42. package/migrations/db_setup_sqlite.sql +116 -0
  43. package/package.json +116 -0
@@ -0,0 +1,375 @@
1
+ // hazo_umetrics/src/api/index.ts — HTTP route handler factories.
2
+ //
3
+ // hazo_auth is NOT imported at the top level. It carries an `import 'server-only'`
4
+ // guard inside `hazo_auth/server-lib`, which would crash test environments if
5
+ // pulled in eagerly. Instead, each handler dynamically imports it on first use,
6
+ // or callers supply their own `getAuth` implementation.
7
+ import { createCrudService } from 'hazo_connect/server';
8
+ import { recordStat as _recordStat, getLatestStat as _getLatestStat, getStatSeries as _getStatSeries, } from '../server/stats.js';
9
+ import { resolveVariant } from '../server/flags.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Default auth — dynamically imports hazo_auth/server-lib to avoid the
12
+ // server-only guard firing at module-evaluation time (e.g. in test runners).
13
+ // ---------------------------------------------------------------------------
14
+ async function defaultGetAuth(req, opts) {
15
+ const { hazo_get_auth } = await import('hazo_auth/server-lib');
16
+ return hazo_get_auth(req, opts);
17
+ }
18
+ // ---------------------------------------------------------------------------
19
+ // Auth gate helper
20
+ // ---------------------------------------------------------------------------
21
+ async function gateAuth(req, opts) {
22
+ const result = await opts.getAuth(req, {
23
+ required_permissions: opts.required_permissions,
24
+ scope_id: opts.scope_id,
25
+ strict: false,
26
+ });
27
+ if (!result.authenticated) {
28
+ return {
29
+ ok: false,
30
+ response: new Response(JSON.stringify({
31
+ ok: false,
32
+ error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
33
+ }), { status: 401, headers: { 'Content-Type': 'application/json' } }),
34
+ };
35
+ }
36
+ if (!result.permission_ok) {
37
+ return {
38
+ ok: false,
39
+ response: new Response(JSON.stringify({
40
+ ok: false,
41
+ error: { code: 'FORBIDDEN', message: 'Missing required permission' },
42
+ }), { status: 403, headers: { 'Content-Type': 'application/json' } }),
43
+ };
44
+ }
45
+ if (opts.scope_id && result.scope_ok === false) {
46
+ return {
47
+ ok: false,
48
+ response: new Response(JSON.stringify({
49
+ ok: false,
50
+ error: { code: 'FORBIDDEN', message: 'Scope access denied' },
51
+ }), { status: 403, headers: { 'Content-Type': 'application/json' } }),
52
+ };
53
+ }
54
+ return { ok: true, userId: result.user.id };
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // JSON helpers
58
+ // ---------------------------------------------------------------------------
59
+ function jsonOk(data, status = 200) {
60
+ return new Response(JSON.stringify({ ok: true, data }), {
61
+ status,
62
+ headers: { 'Content-Type': 'application/json' },
63
+ });
64
+ }
65
+ function jsonError(code, message, status) {
66
+ return new Response(JSON.stringify({ ok: false, error: { code, message } }), {
67
+ status,
68
+ headers: { 'Content-Type': 'application/json' },
69
+ });
70
+ }
71
+ // ---------------------------------------------------------------------------
72
+ // createStatHandlers
73
+ // ---------------------------------------------------------------------------
74
+ export function createStatHandlers(options) {
75
+ const getAuth = options.getAuth ?? defaultGetAuth;
76
+ return {
77
+ /**
78
+ * GET /...?scope_id=<id>&metric_key=<key>
79
+ * Returns the latest reading for the metric, or null if none.
80
+ */
81
+ async getStat(req) {
82
+ const url = new URL(req.url);
83
+ const scope_id = url.searchParams.get('scope_id') ?? '';
84
+ const metric_key = url.searchParams.get('metric_key') ?? '';
85
+ const gate = await gateAuth(req, {
86
+ required_permissions: ['metrics.view'],
87
+ scope_id: scope_id || undefined,
88
+ getAuth,
89
+ });
90
+ if (!gate.ok)
91
+ return gate.response;
92
+ if (!scope_id || !metric_key) {
93
+ return jsonError('BAD_REQUEST', 'scope_id and metric_key are required', 400);
94
+ }
95
+ const adapter = await options.getAdapter();
96
+ const stat = await _getLatestStat(adapter, { scope_id, metric_key });
97
+ return jsonOk({ stat });
98
+ },
99
+ /**
100
+ * GET /...?scope_id=<id>&metric_key=<key>[&since=<iso>][&limit=<n>]
101
+ * Returns time-series readings.
102
+ */
103
+ async getStatSeries(req) {
104
+ const url = new URL(req.url);
105
+ const scope_id = url.searchParams.get('scope_id') ?? '';
106
+ const metric_key = url.searchParams.get('metric_key') ?? '';
107
+ const since = url.searchParams.get('since') ?? undefined;
108
+ const limitParam = url.searchParams.get('limit');
109
+ const limit = limitParam != null ? parseInt(limitParam, 10) : undefined;
110
+ const gate = await gateAuth(req, {
111
+ required_permissions: ['metrics.view'],
112
+ scope_id: scope_id || undefined,
113
+ getAuth,
114
+ });
115
+ if (!gate.ok)
116
+ return gate.response;
117
+ if (!scope_id || !metric_key) {
118
+ return jsonError('BAD_REQUEST', 'scope_id and metric_key are required', 400);
119
+ }
120
+ const adapter = await options.getAdapter();
121
+ const series = await _getStatSeries(adapter, { scope_id, metric_key, since, limit });
122
+ return jsonOk({ series });
123
+ },
124
+ /**
125
+ * POST /...
126
+ * Body: { scope_id, metric_key, value, app_id?, unit?, dimensions? }
127
+ * Appends a new stat reading.
128
+ */
129
+ async recordStat(req) {
130
+ const url = new URL(req.url);
131
+ const scope_id = url.searchParams.get('scope_id') ?? '';
132
+ const gate = await gateAuth(req, {
133
+ required_permissions: ['metrics.view'],
134
+ scope_id: scope_id || undefined,
135
+ getAuth,
136
+ });
137
+ if (!gate.ok)
138
+ return gate.response;
139
+ let body;
140
+ try {
141
+ body = (await req.json());
142
+ }
143
+ catch {
144
+ return jsonError('BAD_REQUEST', 'Invalid JSON body', 400);
145
+ }
146
+ const { scope_id: bodyScopeId, metric_key, value, app_id, unit, dimensions } = body;
147
+ const resolvedScopeId = (bodyScopeId ?? scope_id);
148
+ if (!resolvedScopeId || !metric_key || value == null) {
149
+ return jsonError('BAD_REQUEST', 'scope_id, metric_key, and value are required', 400);
150
+ }
151
+ const adapter = await options.getAdapter();
152
+ await _recordStat(adapter, {
153
+ scope_id: resolvedScopeId,
154
+ metric_key,
155
+ value: value,
156
+ app_id,
157
+ unit,
158
+ dimensions,
159
+ });
160
+ return jsonOk({ recorded: true }, 201);
161
+ },
162
+ };
163
+ }
164
+ export function createExperimentHandlers(options) {
165
+ const getAuth = options.getAuth ?? defaultGetAuth;
166
+ function getExpCrud(adapter) {
167
+ return createCrudService(adapter, 'hazo_umetrics_experiment', {
168
+ primaryKeys: ['key', 'scope_id'],
169
+ autoId: false,
170
+ });
171
+ }
172
+ return {
173
+ /**
174
+ * GET /...?scope_id=<id>
175
+ * Lists all experiments for a scope.
176
+ */
177
+ async listExperiments(req) {
178
+ const url = new URL(req.url);
179
+ const scope_id = url.searchParams.get('scope_id') ?? '';
180
+ const gate = await gateAuth(req, {
181
+ required_permissions: ['metrics.view'],
182
+ scope_id: scope_id || undefined,
183
+ getAuth,
184
+ });
185
+ if (!gate.ok)
186
+ return gate.response;
187
+ if (!scope_id) {
188
+ return jsonError('BAD_REQUEST', 'scope_id is required', 400);
189
+ }
190
+ const adapter = await options.getAdapter();
191
+ const crud = getExpCrud(adapter);
192
+ const rows = await crud.list((qb) => qb.where('scope_id', 'eq', scope_id));
193
+ return jsonOk({ experiments: rows });
194
+ },
195
+ /**
196
+ * POST /...
197
+ * Body: { experimentKey, scope_id, subjectId?, consent? }
198
+ * Resolves the variant for a subject.
199
+ */
200
+ async evalVariant(req) {
201
+ const url = new URL(req.url);
202
+ const scope_id = url.searchParams.get('scope_id') ?? '';
203
+ const gate = await gateAuth(req, {
204
+ required_permissions: ['metrics.view'],
205
+ scope_id: scope_id || undefined,
206
+ getAuth,
207
+ });
208
+ if (!gate.ok)
209
+ return gate.response;
210
+ let body;
211
+ try {
212
+ body = (await req.json());
213
+ }
214
+ catch {
215
+ return jsonError('BAD_REQUEST', 'Invalid JSON body', 400);
216
+ }
217
+ const { experimentKey, scope_id: bodyScopeId, subjectId, consent, cookieValue, } = body;
218
+ const resolvedScopeId = (bodyScopeId ?? scope_id);
219
+ if (!resolvedScopeId || !experimentKey) {
220
+ return jsonError('BAD_REQUEST', 'experimentKey and scope_id are required', 400);
221
+ }
222
+ const adapter = await options.getAdapter();
223
+ const result = await resolveVariant(adapter, {
224
+ experimentKey,
225
+ scope_id: resolvedScopeId,
226
+ subjectId,
227
+ consent,
228
+ cookieValue,
229
+ });
230
+ return jsonOk(result);
231
+ },
232
+ /**
233
+ * POST /...
234
+ * Body: { key, scope_id }
235
+ * Transitions an experiment to 'running'. Requires metrics.manage.
236
+ */
237
+ async startExperiment(req) {
238
+ const url = new URL(req.url);
239
+ const scope_id = url.searchParams.get('scope_id') ?? '';
240
+ const gate = await gateAuth(req, {
241
+ required_permissions: ['metrics.manage'],
242
+ scope_id: scope_id || undefined,
243
+ getAuth,
244
+ });
245
+ if (!gate.ok)
246
+ return gate.response;
247
+ let body;
248
+ try {
249
+ body = (await req.json());
250
+ }
251
+ catch {
252
+ return jsonError('BAD_REQUEST', 'Invalid JSON body', 400);
253
+ }
254
+ const { key, scope_id: bodyScopeId } = body;
255
+ const resolvedScopeId = (bodyScopeId ?? scope_id);
256
+ if (!key || !resolvedScopeId) {
257
+ return jsonError('BAD_REQUEST', 'key and scope_id are required', 400);
258
+ }
259
+ const adapter = await options.getAdapter();
260
+ const crud = getExpCrud(adapter);
261
+ const existing = await crud.findOneBy({ key, scope_id: resolvedScopeId });
262
+ if (!existing) {
263
+ return jsonError('NOT_FOUND', `Experiment '${key}' not found in scope '${resolvedScopeId}'`, 404);
264
+ }
265
+ await crud.upsert({ ...existing, status: 'running' }, ['key', 'scope_id']);
266
+ await options.onAudit?.({
267
+ action: 'experiment.start',
268
+ actor_id: gate.userId,
269
+ scope_id: resolvedScopeId,
270
+ details: { key },
271
+ });
272
+ return jsonOk({ key, status: 'running' });
273
+ },
274
+ /**
275
+ * POST /...
276
+ * Body: { key, scope_id }
277
+ * Transitions an experiment to 'stopped'. Requires metrics.manage.
278
+ */
279
+ async stopExperiment(req) {
280
+ const url = new URL(req.url);
281
+ const scope_id = url.searchParams.get('scope_id') ?? '';
282
+ const gate = await gateAuth(req, {
283
+ required_permissions: ['metrics.manage'],
284
+ scope_id: scope_id || undefined,
285
+ getAuth,
286
+ });
287
+ if (!gate.ok)
288
+ return gate.response;
289
+ let body;
290
+ try {
291
+ body = (await req.json());
292
+ }
293
+ catch {
294
+ return jsonError('BAD_REQUEST', 'Invalid JSON body', 400);
295
+ }
296
+ const { key, scope_id: bodyScopeId } = body;
297
+ const resolvedScopeId = (bodyScopeId ?? scope_id);
298
+ if (!key || !resolvedScopeId) {
299
+ return jsonError('BAD_REQUEST', 'key and scope_id are required', 400);
300
+ }
301
+ const adapter = await options.getAdapter();
302
+ const crud = getExpCrud(adapter);
303
+ const existing = await crud.findOneBy({ key, scope_id: resolvedScopeId });
304
+ if (!existing) {
305
+ return jsonError('NOT_FOUND', `Experiment '${key}' not found in scope '${resolvedScopeId}'`, 404);
306
+ }
307
+ await crud.upsert({ ...existing, status: 'stopped' }, ['key', 'scope_id']);
308
+ await options.onAudit?.({
309
+ action: 'experiment.stop',
310
+ actor_id: gate.userId,
311
+ scope_id: resolvedScopeId,
312
+ details: { key },
313
+ });
314
+ return jsonOk({ key, status: 'stopped' });
315
+ },
316
+ };
317
+ }
318
+ // ---------------------------------------------------------------------------
319
+ // createGa4ConnectHandlers — STUB (M1)
320
+ // ---------------------------------------------------------------------------
321
+ export function createGa4ConnectHandlers(options) {
322
+ const getAuth = options.getAuth ?? defaultGetAuth;
323
+ return {
324
+ /** STUB (M1): Initiate GA4 OAuth connection for a scope. */
325
+ async connect(req) {
326
+ const url = new URL(req.url);
327
+ const scope_id = url.searchParams.get('scope_id') ?? '';
328
+ const gate = await gateAuth(req, {
329
+ required_permissions: ['metrics.manage'],
330
+ scope_id: scope_id || undefined,
331
+ getAuth,
332
+ });
333
+ if (!gate.ok)
334
+ return gate.response;
335
+ // STUB (M1)
336
+ return jsonOk({ status: 'stub' });
337
+ },
338
+ /** STUB (M1): Provision a GA4 property for a scope. */
339
+ async provision(req) {
340
+ const url = new URL(req.url);
341
+ const scope_id = url.searchParams.get('scope_id') ?? '';
342
+ const gate = await gateAuth(req, {
343
+ required_permissions: ['metrics.manage'],
344
+ scope_id: scope_id || undefined,
345
+ getAuth,
346
+ });
347
+ if (!gate.ok)
348
+ return gate.response;
349
+ // STUB (M1)
350
+ return jsonOk({ status: 'stub' });
351
+ },
352
+ };
353
+ }
354
+ // ---------------------------------------------------------------------------
355
+ // createQueryHandlers — STUB (M1)
356
+ // ---------------------------------------------------------------------------
357
+ export function createQueryHandlers(options) {
358
+ const getAuth = options.getAuth ?? defaultGetAuth;
359
+ return {
360
+ /** STUB (M1): Execute a GA4 or stat-store query. */
361
+ async query(req) {
362
+ const url = new URL(req.url);
363
+ const scope_id = url.searchParams.get('scope_id') ?? '';
364
+ const gate = await gateAuth(req, {
365
+ required_permissions: ['metrics.view'],
366
+ scope_id: scope_id || undefined,
367
+ getAuth,
368
+ });
369
+ if (!gate.ok)
370
+ return gate.response;
371
+ // STUB (M1)
372
+ return jsonOk([]);
373
+ },
374
+ };
375
+ }
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ interface MetricsContextValue {
3
+ scopeId: string | null | undefined;
4
+ identify: (userId: string) => void;
5
+ recordStat: (key: string, value: number, opts?: {
6
+ unit?: string;
7
+ }) => void;
8
+ track: (event: string, props?: Record<string, unknown>) => void;
9
+ }
10
+ export interface HazoMetricsProviderProps {
11
+ scopeId?: string;
12
+ children: React.ReactNode;
13
+ }
14
+ export declare function HazoMetricsProvider({ scopeId, children }: HazoMetricsProviderProps): React.FunctionComponentElement<React.ProviderProps<MetricsContextValue>>;
15
+ export declare function useMetrics(): MetricsContextValue;
16
+ export declare function getVariant(serverVariant: string): string;
17
+ export declare function isEnabled(serverVariant: string): boolean;
18
+ export declare function identify(_userId: string): void;
19
+ export declare function recordStat(_key: string, _value: number, _opts?: {
20
+ unit?: string;
21
+ }): void;
22
+ export {};
23
+ //# sourceMappingURL=index.client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,MAAM,OAAO,CAAC;AAM1B,UAAU,mBAAmB;IAC3B,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IAC3E,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;CACjE;AASD,MAAM,WAAW,wBAAwB;IACvC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,mBAAmB,CAAC,EAAE,OAAmB,EAAE,QAAQ,EAAE,EAAE,wBAAwB,4EAW9F;AAMD,wBAAgB,UAAU,IAAI,mBAAmB,CAEhD;AAGD,wBAAgB,UAAU,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAgB,SAAS,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAExD;AAGD,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE9C;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAExF"}
@@ -0,0 +1,44 @@
1
+ // hazo_umetrics/src/index.client.ts — Client-safe entry point.
2
+ // Never import Node.js builtins (fs/path/crypto) from this file.
3
+ // Never import hazo_auth/server-lib or other server-only modules.
4
+ // Full hook wiring is M1 — these are thin client shells.
5
+ 'use client';
6
+ import React from 'react';
7
+ const MetricsContext = React.createContext({
8
+ scopeId: null,
9
+ identify: () => { },
10
+ recordStat: () => { },
11
+ track: () => { },
12
+ });
13
+ export function HazoMetricsProvider({ scopeId = undefined, children }) {
14
+ const identify = React.useCallback((_userId) => {
15
+ // M1: send to /api/hazo_umetrics/identify
16
+ }, []);
17
+ const recordStat = React.useCallback((_key, _value, _opts) => {
18
+ // M1: send to /api/hazo_umetrics/stats
19
+ }, []);
20
+ const track = React.useCallback((_event, _props) => {
21
+ // M1: send to /api/hazo_umetrics/events
22
+ }, []);
23
+ return React.createElement(MetricsContext.Provider, { value: { scopeId, identify, recordStat, track } }, children);
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Hooks
27
+ // ---------------------------------------------------------------------------
28
+ export function useMetrics() {
29
+ return React.useContext(MetricsContext);
30
+ }
31
+ // Hydrate a variant from a server-resolved decision (passed via props/cookie)
32
+ export function getVariant(serverVariant) {
33
+ return serverVariant;
34
+ }
35
+ export function isEnabled(serverVariant) {
36
+ return serverVariant === 'on';
37
+ }
38
+ // Re-export identify + track as standalone functions for RSC/non-hook contexts
39
+ export function identify(_userId) {
40
+ // M1: client-side identify via API
41
+ }
42
+ export function recordStat(_key, _value, _opts) {
43
+ // M1: client-side stat record via API
44
+ }
@@ -0,0 +1,2 @@
1
+ export * from './lib/index.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // hazo_umetrics/src/index.ts — Server entry point (no client imports)
2
+ // Use 'hazo_umetrics/api' for HTTP route factories.
3
+ // Use 'hazo_umetrics/client' for client-safe hooks/components.
4
+ // Use 'hazo_umetrics/ui' for React UI components.
5
+ export * from './lib/index.js';
@@ -0,0 +1,7 @@
1
+ export * from '../server/types.js';
2
+ export * from '../server/stats.js';
3
+ export * from '../server/flags.js';
4
+ export * from '../server/cookie.js';
5
+ export * from '../server/ga4.js';
6
+ export * from '../server/experiments.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AACA,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,0BAA0B,CAAC"}
@@ -0,0 +1,7 @@
1
+ // hazo_umetrics/src/lib/index.ts — re-exports server-side modules
2
+ export * from '../server/types.js';
3
+ export * from '../server/stats.js';
4
+ export * from '../server/flags.js';
5
+ export * from '../server/cookie.js';
6
+ export * from '../server/ga4.js';
7
+ export * from '../server/experiments.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Encrypt a subjectId into a cookie value string (JSON.stringify of EncryptedField).
3
+ */
4
+ export declare function signSubjectCookie(subjectId: string): Promise<string>;
5
+ /**
6
+ * Decrypt a cookie value → subjectId, or null on tamper / missing key / parse error.
7
+ */
8
+ export declare function readSubjectCookie(value: string): Promise<string | null>;
9
+ //# sourceMappingURL=cookie.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookie.d.ts","sourceRoot":"","sources":["../../src/server/cookie.ts"],"names":[],"mappings":"AAiBA;;GAEG;AACH,wBAAsB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG1E;AAED;;GAEG;AACH,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAQ7E"}
@@ -0,0 +1,32 @@
1
+ // hazo_umetrics/src/server/cookie.ts — sign/verify the A/B assignment cookie
2
+ // via hazo_secure/crypto (AES-256-GCM).
3
+ //
4
+ // Env vars required:
5
+ // HAZO_UMETRICS_COOKIE_KEY_CURRENT — key id, e.g. "v1"
6
+ // HAZO_UMETRICS_COOKIE_KEY_v1 — base64-encoded 32-byte AES-256 key
7
+ //
8
+ // EnvKeyProvider reads lazily on first call, so setting env vars in beforeAll
9
+ // in tests is sufficient.
10
+ import { encryptField, decryptField, EnvKeyProvider } from 'hazo_secure/crypto';
11
+ const cookieKeys = new EnvKeyProvider('HAZO_UMETRICS_COOKIE_KEY');
12
+ const COOKIE_AAD = 'umetrics_assignment';
13
+ /**
14
+ * Encrypt a subjectId into a cookie value string (JSON.stringify of EncryptedField).
15
+ */
16
+ export async function signSubjectCookie(subjectId) {
17
+ const encrypted = await encryptField(subjectId, { keys: cookieKeys, aad: COOKIE_AAD });
18
+ return JSON.stringify(encrypted);
19
+ }
20
+ /**
21
+ * Decrypt a cookie value → subjectId, or null on tamper / missing key / parse error.
22
+ */
23
+ export async function readSubjectCookie(value) {
24
+ try {
25
+ const field = JSON.parse(value);
26
+ const subjectId = await decryptField(field, { keys: cookieKeys, aad: COOKIE_AAD });
27
+ return subjectId;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
@@ -0,0 +1,30 @@
1
+ export interface SignificanceResult {
2
+ pValue: number;
3
+ confidenceInterval: [number, number];
4
+ significant: boolean;
5
+ sampleSize: number;
6
+ power: number;
7
+ }
8
+ export interface SignificanceParams {
9
+ control: {
10
+ conversions: number;
11
+ visitors: number;
12
+ };
13
+ variant: {
14
+ conversions: number;
15
+ visitors: number;
16
+ };
17
+ alpha?: number;
18
+ }
19
+ export declare function computeSignificance(_params: SignificanceParams): SignificanceResult;
20
+ export interface SampleSizeEstimate {
21
+ requiredPerVariant: number;
22
+ estimatedDaysToComplete: number | null;
23
+ }
24
+ export declare function estimateSampleSize(_params: {
25
+ baselineRate: number;
26
+ minimumDetectableEffect: number;
27
+ alpha?: number;
28
+ power?: number;
29
+ }): SampleSizeEstimate;
30
+ //# sourceMappingURL=experiments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"experiments.d.ts","sourceRoot":"","sources":["../../src/server/experiments.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,kBAAkB,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,WAAW,EAAE,OAAO,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,OAAO,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAGD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,kBAAkB,GAAG,kBAAkB,CAQnF;AAED,MAAM,WAAW,kBAAkB;IACjC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,uBAAuB,EAAE,MAAM,GAAG,IAAI,CAAC;CACxC;AAGD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,uBAAuB,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,kBAAkB,CAEzJ"}
@@ -0,0 +1,17 @@
1
+ // hazo_umetrics/src/server/experiments.ts — Experiment significance stubs.
2
+ // STUB: z-test / confidence interval / low-traffic estimator deferred to M2+.
3
+ // Re-exports the flag engine functions for the experiment layer.
4
+ // STUB (M2/deferred) — returns a placeholder result
5
+ export function computeSignificance(_params) {
6
+ return {
7
+ pValue: 1,
8
+ confidenceInterval: [0, 1],
9
+ significant: false,
10
+ sampleSize: 0,
11
+ power: 0,
12
+ };
13
+ }
14
+ // STUB (M2/deferred)
15
+ export function estimateSampleSize(_params) {
16
+ return { requiredPerVariant: 0, estimatedDaysToComplete: null }; // STUB
17
+ }
@@ -0,0 +1,44 @@
1
+ import type { HazoConnectAdapter } from 'hazo_connect/server';
2
+ type Variant = {
3
+ name: string;
4
+ weight: number;
5
+ };
6
+ /**
7
+ * Given a list of variants with weights, deterministically assign a bucket
8
+ * using the hashInput string. Weights are normalized to sum to 100.
9
+ *
10
+ * Throws HazoError if variants is empty.
11
+ */
12
+ export declare function weightedBucket(variants: Variant[], hashInput: string): string;
13
+ export interface ResolveVariantOpts {
14
+ experimentKey: string;
15
+ scope_id: string;
16
+ subjectId?: string;
17
+ consent?: boolean;
18
+ /** Pass a signed cookie value from a previous call to restore anon identity. */
19
+ cookieValue?: string;
20
+ }
21
+ export interface ResolveVariantResult {
22
+ variant: string;
23
+ sticky: boolean;
24
+ cookieValue?: string;
25
+ }
26
+ /**
27
+ * Resolve the variant for a subject in an experiment.
28
+ *
29
+ * - draft/stopped experiment → first variant (or 'control'), no DB writes
30
+ * - running + subjectId (logged-in) → deterministic bucket, upsert assignment, write exposure
31
+ * - running + no subjectId + consent=true → ephemeral anon ID signed in cookie, sticky
32
+ * - running + no subjectId + consent=false/undefined → per-request bucket, no persistence
33
+ */
34
+ export declare function resolveVariant(adapter: HazoConnectAdapter, opts: ResolveVariantOpts): Promise<ResolveVariantResult>;
35
+ /**
36
+ * Resolve the variant name for a subject. Wraps resolveVariant.
37
+ */
38
+ export declare function getVariant(adapter: HazoConnectAdapter, opts: ResolveVariantOpts): Promise<string>;
39
+ /**
40
+ * Returns true when the resolved variant is 'on'. Useful for simple feature flags.
41
+ */
42
+ export declare function isEnabled(adapter: HazoConnectAdapter, opts: ResolveVariantOpts): Promise<boolean>;
43
+ export {};
44
+ //# sourceMappingURL=flags.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flags.d.ts","sourceRoot":"","sources":["../../src/server/flags.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAwB9D,KAAK,OAAO,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CA2B7E;AA4CD,MAAM,WAAW,kBAAkB;IACjC,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,kBAAkB,GACvB,OAAO,CAAC,oBAAoB,CAAC,CAyI/B;AAkBD;;GAEG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,kBAAkB,GACvB,OAAO,CAAC,MAAM,CAAC,CAGjB;AAED;;GAEG;AACH,wBAAsB,SAAS,CAC7B,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,kBAAkB,GACvB,OAAO,CAAC,OAAO,CAAC,CAElB"}