ad2app-lib 1.42.0 → 1.43.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.
@@ -29,6 +29,8 @@ export declare const EVENTS: {
29
29
  readonly PLAYBOOK_OPENED: "playbook_opened";
30
30
  readonly PLAYBOOK_DOWNLOADED: "playbook_downloaded";
31
31
  readonly SIGNED_UP: "signed_up";
32
+ readonly ANALYTICS_CONSENT_RECORDED: "analytics_consent_recorded";
33
+ readonly FIRST_AUTHENTICATED: "first_authenticated";
32
34
  readonly PROFILE_COMPLETED: "profile_completed";
33
35
  readonly LOGGED_IN: "logged_in";
34
36
  readonly SOCIAL_ACCOUNT_CONNECTED: "social_account_connected";
@@ -262,6 +264,10 @@ export interface EventProperties {
262
264
  method: 'email' | 'google';
263
265
  role: string;
264
266
  };
267
+ [EVENTS.ANALYTICS_CONSENT_RECORDED]: {
268
+ state: 'granted' | 'declined' | 'unset';
269
+ };
270
+ [EVENTS.FIRST_AUTHENTICATED]: Record<string, never>;
265
271
  [EVENTS.PROFILE_COMPLETED]: {
266
272
  role: string;
267
273
  };
@@ -39,6 +39,15 @@ exports.EVENTS = {
39
39
  PLAYBOOK_DOWNLOADED: 'playbook_downloaded',
40
40
  // Activation (web app)
41
41
  SIGNED_UP: 'signed_up', // server-owned (backend, on user creation)
42
+ // 171 (backend#307): the analytics answer itself, emitted by the BACKEND so
43
+ // it arrives for the people who declined — a decline recorded only by the
44
+ // browser that declined is a fact nobody can read. Carries the answer and
45
+ // nothing else.
46
+ ANALYTICS_CONSENT_RECORDED: 'analytics_consent_recorded', // server-owned (backend)
47
+ // 171: the first authenticated request after signup. Server-owned and
48
+ // consent-independent, in the same spirit as T046/T054 — it is what tells
49
+ // "used the product, refused tracking" apart from "never came back".
50
+ FIRST_AUTHENTICATED: 'first_authenticated', // server-owned (backend)
42
51
  PROFILE_COMPLETED: 'profile_completed', // the /complete-profile step (influencers)
43
52
  LOGGED_IN: 'logged_in',
44
53
  SOCIAL_ACCOUNT_CONNECTED: 'social_account_connected',
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A person's stated answer about analytics, as an ACCOUNT FACT (spec 171,
3
+ * backend#307).
4
+ *
5
+ * WHY THIS EXISTS. `posthog-js` runs `opt_out_capturing_by_default: true`, and
6
+ * a decline calls `opt_out_capturing()`. That is correct and it is the point:
7
+ * a decline collects nothing. It also means the fact of the decline is known
8
+ * only to that browser — so from the product's side, someone using the app
9
+ * every day with analytics off is indistinguishable from someone who signed up
10
+ * and never came back. Five of nineteen beta testers were in that state on
11
+ * 2026-09-14, and every percentage on the beta dashboard was silently computed
12
+ * over fourteen people while claiming nineteen.
13
+ *
14
+ * RECORDING A DECLINE IS NOT THE TRACKING THAT WAS DECLINED. This carries the
15
+ * answer and nothing else — no page, no behaviour, no session. It is the same
16
+ * instrument as `push_consent_state`, which has recorded "declined" as a
17
+ * separate legal control since spec 162 without anyone calling it surveillance,
18
+ * and it is precisely what lets the product HONOUR a decline while still
19
+ * counting that person as a user rather than as an absence.
20
+ */
21
+ export declare const ANALYTICS_CONSENT_STATES: {
22
+ /** Never answered the banner. The default, and distinguishable from both
23
+ * answers — an unanswered question is not a "no". */
24
+ readonly UNSET: "unset";
25
+ /** Accepted analytics. Recorded the same way as a decline, so neither answer
26
+ * is ever inferred from the absence of the other. */
27
+ readonly GRANTED: "granted";
28
+ /** Declined analytics. The person is still using the product; they are
29
+ * invisible by choice, which is a different thing from inactive. */
30
+ readonly DECLINED: "declined";
31
+ };
32
+ export type AnalyticsConsentState = (typeof ANALYTICS_CONSENT_STATES)[keyof typeof ANALYTICS_CONSENT_STATES];
33
+ /**
34
+ * The body of `POST /users/analytics-consent`.
35
+ *
36
+ * `unset` IS sendable, and only from one place: the "Cookie settings" link
37
+ * that reopens the choice (review 2026-09-17 M2). The first draft forbade it
38
+ * on the reasoning that the absence of an answer is not an answer anyone
39
+ * gives — true of the banner, false of a RESET, which is a person actively
40
+ * withdrawing their previous answer. Without it the browser went back to
41
+ * `pending` while the column still said `declined`, so the dashboard counted
42
+ * someone as opted out while they were being asked again. A stale record is
43
+ * worse than an absent one.
44
+ */
45
+ export interface I_AnalyticsConsentBody {
46
+ state: AnalyticsConsentState;
47
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ANALYTICS_CONSENT_STATES = void 0;
4
+ /**
5
+ * A person's stated answer about analytics, as an ACCOUNT FACT (spec 171,
6
+ * backend#307).
7
+ *
8
+ * WHY THIS EXISTS. `posthog-js` runs `opt_out_capturing_by_default: true`, and
9
+ * a decline calls `opt_out_capturing()`. That is correct and it is the point:
10
+ * a decline collects nothing. It also means the fact of the decline is known
11
+ * only to that browser — so from the product's side, someone using the app
12
+ * every day with analytics off is indistinguishable from someone who signed up
13
+ * and never came back. Five of nineteen beta testers were in that state on
14
+ * 2026-09-14, and every percentage on the beta dashboard was silently computed
15
+ * over fourteen people while claiming nineteen.
16
+ *
17
+ * RECORDING A DECLINE IS NOT THE TRACKING THAT WAS DECLINED. This carries the
18
+ * answer and nothing else — no page, no behaviour, no session. It is the same
19
+ * instrument as `push_consent_state`, which has recorded "declined" as a
20
+ * separate legal control since spec 162 without anyone calling it surveillance,
21
+ * and it is precisely what lets the product HONOUR a decline while still
22
+ * counting that person as a user rather than as an absence.
23
+ */
24
+ exports.ANALYTICS_CONSENT_STATES = {
25
+ /** Never answered the banner. The default, and distinguishable from both
26
+ * answers — an unanswered question is not a "no". */
27
+ UNSET: "unset",
28
+ /** Accepted analytics. Recorded the same way as a decline, so neither answer
29
+ * is ever inferred from the absence of the other. */
30
+ GRANTED: "granted",
31
+ /** Declined analytics. The person is still using the product; they are
32
+ * invisible by choice, which is a different thing from inactive. */
33
+ DECLINED: "declined",
34
+ };
@@ -5,6 +5,7 @@ export * from "./I_Influencer";
5
5
  export * from "./I_InfluencersLists";
6
6
  export * from "./I_InfluencersCategories";
7
7
  export * from "./I_InfluencersProperties";
8
+ export * from "./I_AnalyticsConsent";
8
9
  export * from "./I_Item";
9
10
  export * from "./I_List";
10
11
  export * from "./I_PaginatedList";
@@ -21,6 +21,7 @@ __exportStar(require("./I_Influencer"), exports);
21
21
  __exportStar(require("./I_InfluencersLists"), exports);
22
22
  __exportStar(require("./I_InfluencersCategories"), exports);
23
23
  __exportStar(require("./I_InfluencersProperties"), exports);
24
+ __exportStar(require("./I_AnalyticsConsent"), exports);
24
25
  __exportStar(require("./I_Item"), exports);
25
26
  __exportStar(require("./I_List"), exports);
26
27
  __exportStar(require("./I_PaginatedList"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.42.0",
3
+ "version": "1.43.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -154,6 +154,11 @@ const EVENT_PROPERTY_WITNESS: { [E in keyof EventProperties]: EventProperties[E]
154
154
  // 164 G012 — both behind default-OFF flags, fixed for completeness
155
155
  [EVENTS.COMMENT_GUARD_MODE_CHANGED]: { mode: "flag-only" },
156
156
  [EVENTS.AI_TOOLS_REVOKE_REQUESTED]: {},
157
+ // 171 (backend#307) — both server-owned, both emitted by the backend so they
158
+ // arrive for the people who declined analytics. A decline recorded only by
159
+ // the browser that declined is a fact nobody can read.
160
+ [EVENTS.ANALYTICS_CONSENT_RECORDED]: { state: "declined" },
161
+ [EVENTS.FIRST_AUTHENTICATED]: {},
157
162
  };
158
163
 
159
164
  test("EVENTS values are 1:1 with EventProperties keys (no missing or typo'd event)", () => {
@@ -43,6 +43,15 @@ export const EVENTS = {
43
43
 
44
44
  // Activation (web app)
45
45
  SIGNED_UP: 'signed_up', // server-owned (backend, on user creation)
46
+ // 171 (backend#307): the analytics answer itself, emitted by the BACKEND so
47
+ // it arrives for the people who declined — a decline recorded only by the
48
+ // browser that declined is a fact nobody can read. Carries the answer and
49
+ // nothing else.
50
+ ANALYTICS_CONSENT_RECORDED: 'analytics_consent_recorded', // server-owned (backend)
51
+ // 171: the first authenticated request after signup. Server-owned and
52
+ // consent-independent, in the same spirit as T046/T054 — it is what tells
53
+ // "used the product, refused tracking" apart from "never came back".
54
+ FIRST_AUTHENTICATED: 'first_authenticated', // server-owned (backend)
46
55
  PROFILE_COMPLETED: 'profile_completed', // the /complete-profile step (influencers)
47
56
  LOGGED_IN: 'logged_in',
48
57
  SOCIAL_ACCOUNT_CONNECTED: 'social_account_connected',
@@ -405,6 +414,8 @@ export interface EventProperties {
405
414
  [EVENTS.PLAYBOOK_OPENED]: { edition_version?: string };
406
415
  [EVENTS.PLAYBOOK_DOWNLOADED]: { edition_version?: string; format?: string };
407
416
  [EVENTS.SIGNED_UP]: { method: 'email' | 'google'; role: string };
417
+ [EVENTS.ANALYTICS_CONSENT_RECORDED]: { state: 'granted' | 'declined' | 'unset' };
418
+ [EVENTS.FIRST_AUTHENTICATED]: Record<string, never>;
408
419
  [EVENTS.PROFILE_COMPLETED]: { role: string };
409
420
  // Spec 161: widened to include 'apple' — the value was already missing from
410
421
  // this union (Google/email only) even though native Apple sign-in has
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A person's stated answer about analytics, as an ACCOUNT FACT (spec 171,
3
+ * backend#307).
4
+ *
5
+ * WHY THIS EXISTS. `posthog-js` runs `opt_out_capturing_by_default: true`, and
6
+ * a decline calls `opt_out_capturing()`. That is correct and it is the point:
7
+ * a decline collects nothing. It also means the fact of the decline is known
8
+ * only to that browser — so from the product's side, someone using the app
9
+ * every day with analytics off is indistinguishable from someone who signed up
10
+ * and never came back. Five of nineteen beta testers were in that state on
11
+ * 2026-09-14, and every percentage on the beta dashboard was silently computed
12
+ * over fourteen people while claiming nineteen.
13
+ *
14
+ * RECORDING A DECLINE IS NOT THE TRACKING THAT WAS DECLINED. This carries the
15
+ * answer and nothing else — no page, no behaviour, no session. It is the same
16
+ * instrument as `push_consent_state`, which has recorded "declined" as a
17
+ * separate legal control since spec 162 without anyone calling it surveillance,
18
+ * and it is precisely what lets the product HONOUR a decline while still
19
+ * counting that person as a user rather than as an absence.
20
+ */
21
+ export const ANALYTICS_CONSENT_STATES = {
22
+ /** Never answered the banner. The default, and distinguishable from both
23
+ * answers — an unanswered question is not a "no". */
24
+ UNSET: "unset",
25
+ /** Accepted analytics. Recorded the same way as a decline, so neither answer
26
+ * is ever inferred from the absence of the other. */
27
+ GRANTED: "granted",
28
+ /** Declined analytics. The person is still using the product; they are
29
+ * invisible by choice, which is a different thing from inactive. */
30
+ DECLINED: "declined",
31
+ } as const;
32
+
33
+ export type AnalyticsConsentState =
34
+ (typeof ANALYTICS_CONSENT_STATES)[keyof typeof ANALYTICS_CONSENT_STATES];
35
+
36
+ /**
37
+ * The body of `POST /users/analytics-consent`.
38
+ *
39
+ * `unset` IS sendable, and only from one place: the "Cookie settings" link
40
+ * that reopens the choice (review 2026-09-17 M2). The first draft forbade it
41
+ * on the reasoning that the absence of an answer is not an answer anyone
42
+ * gives — true of the banner, false of a RESET, which is a person actively
43
+ * withdrawing their previous answer. Without it the browser went back to
44
+ * `pending` while the column still said `declined`, so the dashboard counted
45
+ * someone as opted out while they were being asked again. A stale record is
46
+ * worse than an absent one.
47
+ */
48
+ export interface I_AnalyticsConsentBody {
49
+ state: AnalyticsConsentState;
50
+ }
@@ -0,0 +1,52 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { test } from 'node:test';
3
+
4
+ import {
5
+ ANALYTICS_CONSENT_STATES,
6
+ type AnalyticsConsentState,
7
+ type I_AnalyticsConsentBody,
8
+ } from './I_AnalyticsConsent';
9
+
10
+ /**
11
+ * Spec 171 T003. Three repos and a PostHog query depend on these exact
12
+ * strings; a rename is a silent break in whichever of them does not get
13
+ * rebuilt, and the query simply stops matching.
14
+ */
15
+ test('the consent state names are stable strings', () => {
16
+ assert.deepEqual(ANALYTICS_CONSENT_STATES, {
17
+ UNSET: 'unset',
18
+ GRANTED: 'granted',
19
+ DECLINED: 'declined',
20
+ });
21
+ });
22
+
23
+ test('the union covers every state and nothing else', () => {
24
+ const all: AnalyticsConsentState[] = Object.values(ANALYTICS_CONSENT_STATES);
25
+ assert.equal(new Set(all).size, 3);
26
+ for (const value of all) {
27
+ switch (value) {
28
+ case 'unset':
29
+ case 'granted':
30
+ case 'declined':
31
+ break;
32
+ default: {
33
+ const never: never = value;
34
+ throw new Error(`unhandled state ${String(never)}`);
35
+ }
36
+ }
37
+ }
38
+ });
39
+
40
+ test('a client can send any of the three, including a withdrawal', () => {
41
+ // `unset` is sendable only from the "Cookie settings" reset (review M2): a
42
+ // person withdrawing a previous answer is an action, unlike never having
43
+ // answered. Without it the column kept the old answer while the browser
44
+ // went back to pending, and a stale record is worse than an absent one.
45
+ const granted: I_AnalyticsConsentBody = { state: 'granted' };
46
+ const declined: I_AnalyticsConsentBody = { state: 'declined' };
47
+ const withdrawn: I_AnalyticsConsentBody = { state: 'unset' };
48
+ assert.deepEqual(
49
+ [granted.state, declined.state, withdrawn.state],
50
+ ['granted', 'declined', 'unset'],
51
+ );
52
+ });
@@ -5,6 +5,7 @@ export * from "./I_Influencer";
5
5
  export * from "./I_InfluencersLists";
6
6
  export * from "./I_InfluencersCategories";
7
7
  export * from "./I_InfluencersProperties";
8
+ export * from "./I_AnalyticsConsent";
8
9
  export * from "./I_Item";
9
10
  export * from "./I_List";
10
11
  export * from "./I_PaginatedList";