@wtfalch/reporting 0.1.1 → 0.2.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.
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { createContext, useContext, useEffect, useMemo, useState } from 'react';
4
+ import { createBeacon, } from '../browser/index.js';
5
+ const ReportingContext = createContext({
6
+ beacon: null,
7
+ answer: null,
8
+ mode: 'none',
9
+ accept: () => undefined,
10
+ decline: () => undefined,
11
+ });
12
+ export function ReportingProvider(props) {
13
+ const [beacon, setBeacon] = useState(null);
14
+ const [answer, setAnswer] = useState(null);
15
+ const mode = props.consent ?? 'consented';
16
+ useEffect(() => {
17
+ const b = createBeacon({ collector: props.collector, site: props.site, consent: mode });
18
+ setBeacon(b);
19
+ setAnswer(b.answer());
20
+ return () => b.destroy();
21
+ }, [props.collector, props.site, mode]);
22
+ const value = useMemo(() => ({
23
+ beacon,
24
+ answer,
25
+ mode,
26
+ accept() {
27
+ beacon?.accept();
28
+ setAnswer(beacon?.answer() ?? null);
29
+ },
30
+ decline() {
31
+ beacon?.decline();
32
+ setAnswer(beacon?.answer() ?? null);
33
+ },
34
+ }), [beacon, answer, mode]);
35
+ return _jsx(ReportingContext.Provider, { value: value, children: props.children });
36
+ }
37
+ /** `track(name, props)` from a client component; a no-op before the beacon mounts. */
38
+ export function useTrack() {
39
+ const { beacon } = useContext(ReportingContext);
40
+ return (name, props) => beacon?.track(name, props);
41
+ }
42
+ /**
43
+ * The consent question, shown only under `consented` and only until it is
44
+ * answered. The host supplies the words; the control supplies the two
45
+ * buttons and remembers the answer through the beacon's cookie.
46
+ */
47
+ export function ConsentControl(props) {
48
+ const { answer, mode, accept, decline, beacon } = useContext(ReportingContext);
49
+ if (mode !== 'consented' || answer !== null || !beacon)
50
+ return null;
51
+ return (_jsxs("section", { "data-reporting-consent": "", className: props.className, "aria-label": "Analytics consent", children: [props.children, _jsxs("div", { "data-reporting-consent-actions": "", children: [_jsx("button", { type: "button", "data-reporting-consent-decline": "", onClick: decline, children: props.declineLabel ?? 'No thanks' }), _jsx("button", { type: "button", "data-reporting-consent-accept": "", onClick: accept, children: props.acceptLabel ?? 'Allow' })] })] }));
52
+ }
package/dist/schema.d.ts CHANGED
@@ -28,9 +28,16 @@ export declare const LIMITS: {
28
28
  readonly dataBytes: 16384;
29
29
  readonly site: 64;
30
30
  };
31
- /** Flat, bounded, and free of the banned keys. Numbers must be finite: JSON has no NaN. */
31
+ /** Flat, bounded, banned keys refused, numbers finite. */
32
32
  export declare const flatDataSchema: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
33
33
  export type FlatData = z.infer<typeof flatDataSchema>;
34
+ /**
35
+ * `z.object`, not strict: a host hands over its resolved principal, which
36
+ * carries a display name and an address beside `class` and `id`, and those
37
+ * must be dropped rather than refused. Stripping is the privacy behaviour;
38
+ * refusing would make every host's call site narrow by hand, and the first
39
+ * one did not.
40
+ */
34
41
  export declare const actorSchema: z.ZodObject<{
35
42
  class: z.ZodEnum<{
36
43
  human: "human";
@@ -39,12 +46,12 @@ export declare const actorSchema: z.ZodObject<{
39
46
  service: "service";
40
47
  }>;
41
48
  id: z.ZodString;
42
- }, z.core.$strict>;
49
+ }, z.core.$strip>;
43
50
  export type Actor = z.infer<typeof actorSchema>;
44
51
  export declare const targetSchema: z.ZodObject<{
45
52
  type: z.ZodString;
46
53
  id: z.ZodString;
47
- }, z.core.$strict>;
54
+ }, z.core.$strip>;
48
55
  /** What a caller hands `event()`. Everything else on the row is the writer's. */
49
56
  export declare const eventInputSchema: z.ZodObject<{
50
57
  kind: z.ZodString;
@@ -64,12 +71,12 @@ export declare const eventInputSchema: z.ZodObject<{
64
71
  service: "service";
65
72
  }>;
66
73
  id: z.ZodString;
67
- }, z.core.$strict>>>;
74
+ }, z.core.$strip>>>;
68
75
  requestId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
69
76
  target: z.ZodOptional<z.ZodNullable<z.ZodObject<{
70
77
  type: z.ZodString;
71
78
  id: z.ZodString;
72
- }, z.core.$strict>>>;
79
+ }, z.core.$strip>>>;
73
80
  data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>>;
74
81
  occurredAt: z.ZodOptional<z.ZodDate>;
75
82
  }, z.core.$strict>;
package/dist/schema.js CHANGED
@@ -36,7 +36,7 @@ export const LIMITS = {
36
36
  site: 64,
37
37
  };
38
38
  const scalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);
39
- /** Flat, bounded, and free of the banned keys. Numbers must be finite: JSON has no NaN. */
39
+ /** Flat, bounded, banned keys refused, numbers finite. */
40
40
  export const flatDataSchema = z.record(z.string(), scalar).superRefine((data, ctx) => {
41
41
  for (const key of Object.keys(data)) {
42
42
  if (BANNED_KEYS.includes(key)) {
@@ -55,11 +55,18 @@ export const flatDataSchema = z.record(z.string(), scalar).superRefine((data, ct
55
55
  }
56
56
  });
57
57
  const bounded = (max) => z.string().min(1).max(max);
58
- export const actorSchema = z.strictObject({
58
+ /**
59
+ * `z.object`, not strict: a host hands over its resolved principal, which
60
+ * carries a display name and an address beside `class` and `id`, and those
61
+ * must be dropped rather than refused. Stripping is the privacy behaviour;
62
+ * refusing would make every host's call site narrow by hand, and the first
63
+ * one did not.
64
+ */
65
+ export const actorSchema = z.object({
59
66
  class: z.enum(ACTOR_CLASSES),
60
67
  id: bounded(LIMITS.actorId),
61
68
  });
62
- export const targetSchema = z.strictObject({
69
+ export const targetSchema = z.object({
63
70
  type: bounded(LIMITS.targetType),
64
71
  id: bounded(LIMITS.targetId),
65
72
  });
@@ -9,7 +9,7 @@ export declare const RETENTION_BOUNDS: {
9
9
  readonly min: 7;
10
10
  readonly max: 400;
11
11
  };
12
- export declare const SETTING_KEYS: (keyof Settings)[];
12
+ export declare const CONSENT_MODES: readonly ["none", "consented", "always"];
13
13
  export declare const DEFAULT_SETTINGS: Settings;
14
14
  export declare function getSettings(db: Db): Promise<Settings>;
15
15
  /** Validates every key, writes the changed ones in one transaction, returns before and after. Throws on an out-of-bounds value: the caller is a form. */
package/dist/settings.js CHANGED
@@ -8,16 +8,20 @@ import { reportingSettings } from './tables.js';
8
8
  * do is shorten history to a week.
9
9
  */
10
10
  export const RETENTION_BOUNDS = { min: 7, max: 400 };
11
+ export const CONSENT_MODES = ['none', 'consented', 'always'];
12
+ const retention = z.number().int().min(RETENTION_BOUNDS.min).max(RETENTION_BOUNDS.max);
11
13
  const DEFS = {
12
- 'events.retention_days': {
13
- schema: z.number().int().min(RETENTION_BOUNDS.min).max(RETENTION_BOUNDS.max),
14
- default: 30,
15
- },
14
+ 'events.retention_days': { schema: retention, default: 30 },
15
+ // Ninety, not thirty: behaviour is watched over years through the rollups,
16
+ // and a dimension they lack is a recompute over this window (D13).
17
+ 'analytics.retention_days': { schema: retention, default: 90 },
18
+ // Cookieless until the person says otherwise (D15).
19
+ 'analytics.consent': { schema: z.enum(CONSENT_MODES), default: 'consented' },
20
+ // A signed-in person's rows carry their id, as the product's own record of
21
+ // serving them; one line to change if advice changes (D15).
22
+ 'analytics.identify_signed_in': { schema: z.boolean(), default: true },
16
23
  };
17
- export const SETTING_KEYS = Object.keys(DEFS);
18
- export const DEFAULT_SETTINGS = Object.freeze({
19
- 'events.retention_days': DEFS['events.retention_days'].default,
20
- });
24
+ export const DEFAULT_SETTINGS = Object.freeze(Object.fromEntries(Object.entries(DEFS).map(([k, d]) => [k, d.default])));
21
25
  function fromRows(rows) {
22
26
  const out = { ...DEFAULT_SETTINGS };
23
27
  for (const row of rows) {
@@ -45,7 +49,9 @@ export async function setSettings(db, patch, by) {
45
49
  throw new Error(`reporting.settings: unknown key "${key}"`);
46
50
  const parsed = def.schema.safeParse(value);
47
51
  if (!parsed.success) {
48
- throw new Error(`reporting.settings: ${key} must be an integer between ${RETENTION_BOUNDS.min} and ${RETENTION_BOUNDS.max}`);
52
+ throw new Error(key.endsWith('retention_days')
53
+ ? `reporting.settings: ${key} must be an integer between ${RETENTION_BOUNDS.min} and ${RETENTION_BOUNDS.max}`
54
+ : `reporting.settings: ${key} is not a valid value`);
49
55
  }
50
56
  }
51
57
  return db.transaction(async (tx) => {