ad2app-lib 1.41.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.
- package/dist/analytics/index.d.ts +6 -0
- package/dist/analytics/index.js +9 -0
- package/dist/types/I_AccessDenial.d.ts +49 -0
- package/dist/types/I_AccessDenial.js +43 -1
- package/dist/types/I_AnalyticsConsent.d.ts +47 -0
- package/dist/types/I_AnalyticsConsent.js +34 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/package.json +1 -1
- package/src/analytics/index.test.ts +5 -0
- package/src/analytics/index.ts +11 -0
- package/src/types/I_AccessDenial.ts +52 -0
- package/src/types/I_AnalyticsConsent.ts +50 -0
- package/src/types/access-denial.test.ts +70 -0
- package/src/types/analytics-consent.test.ts +52 -0
- package/src/types/index.ts +1 -0
|
@@ -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
|
};
|
package/dist/analytics/index.js
CHANGED
|
@@ -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',
|
|
@@ -26,14 +26,63 @@ export declare const ACCESS_DENIAL_CODES: {
|
|
|
26
26
|
readonly ENTITLEMENT_PRECONDITION: "entitlement-precondition";
|
|
27
27
|
};
|
|
28
28
|
export type AccessDenialCode = (typeof ACCESS_DENIAL_CODES)[keyof typeof ACCESS_DENIAL_CODES];
|
|
29
|
+
/**
|
|
30
|
+
* WHICH precondition the entitlement wall refused on (backend#305, spec 170).
|
|
31
|
+
*
|
|
32
|
+
* `ENTITLEMENT_PRECONDITION` above is the CLIENT contract: every cause below
|
|
33
|
+
* wants the same behaviour from a client — render the surface's own empty
|
|
34
|
+
* state, do not redirect, never send a paying user to buy a plan they already
|
|
35
|
+
* own — so a fourth code would force every consumer to re-learn a wall it
|
|
36
|
+
* already handles correctly, to carry information no consumer acts on.
|
|
37
|
+
*
|
|
38
|
+
* This is for whoever is READING. There is one throw site in the backend and
|
|
39
|
+
* four ways to reach it, and until now all four answered with the same string:
|
|
40
|
+
* "has no beta place", "subscription lapsed", "has connected nothing yet" and
|
|
41
|
+
* "was left in a state we do not recognise" were indistinguishable outside a
|
|
42
|
+
* database session. One beta tester was refused across /dashboard, /inbox,
|
|
43
|
+
* /settings and a post page — 21 denials in 30 days — and the only way to find
|
|
44
|
+
* out why was to open the database by hand.
|
|
45
|
+
*
|
|
46
|
+
* NOTHING HERE MAY SAY ANYTHING ABOUT THE ACCOUNT beyond which condition
|
|
47
|
+
* failed. These names travel to the browser. `UNRECOGNISED_PLAN` deliberately
|
|
48
|
+
* does not carry WHICH plan value was unrecognised — that belongs in the
|
|
49
|
+
* server-side log.
|
|
50
|
+
*/
|
|
51
|
+
export declare const ENTITLEMENT_PRECONDITIONS: {
|
|
52
|
+
/** No such user row. Unentitled, not merely unconnected. */
|
|
53
|
+
readonly UNKNOWN_ACCOUNT: "unknown-account";
|
|
54
|
+
/**
|
|
55
|
+
* A plan value the allow-list deliberately refuses: 'TRIALING', 'PAST_DUE',
|
|
56
|
+
* a future tier, or a typo. Split out of NO_ENTITLEMENT on purpose — both
|
|
57
|
+
* stay refused, and the fail-closed property of the allow-list is not
|
|
58
|
+
* touched, but a plan value we do not recognise is a DEFECT and a plain free
|
|
59
|
+
* account is not. Reported separately because that distinction is most of
|
|
60
|
+
* what makes a locked-out account diagnosable.
|
|
61
|
+
*/
|
|
62
|
+
readonly UNRECOGNISED_PLAN: "unrecognised-plan";
|
|
63
|
+
/** No paid plan and no live beta place; or a lapsed payer / lapsed place
|
|
64
|
+
* holder, who keeps read-only access to content we hold but has no claim on
|
|
65
|
+
* a live vendor read. */
|
|
66
|
+
readonly NO_ENTITLEMENT: "no-entitlement";
|
|
67
|
+
/** Entitled, and has connected nothing yet. They need an account, NOT a
|
|
68
|
+
* purchase — the distinction spec 144 drew in prose and nothing recorded. */
|
|
69
|
+
readonly NO_CONNECTION: "no-connection";
|
|
70
|
+
};
|
|
71
|
+
export type EntitlementPrecondition = (typeof ENTITLEMENT_PRECONDITIONS)[keyof typeof ENTITLEMENT_PRECONDITIONS];
|
|
29
72
|
/**
|
|
30
73
|
* The body shape those 403s answer with. `statusCode`/`error` are carried
|
|
31
74
|
* explicitly so the response stays byte-identical to Nest's default envelope
|
|
32
75
|
* and `code` is purely additive — no existing consumer sees a change.
|
|
76
|
+
*
|
|
77
|
+
* `precondition` is additive in exactly the same way, and OPTIONAL, which is
|
|
78
|
+
* what makes two promises true by construction: a client that ignores it
|
|
79
|
+
* behaves as it does today, and a response from a backend that predates it is
|
|
80
|
+
* still a valid body rather than something a consumer has to special-case.
|
|
33
81
|
*/
|
|
34
82
|
export interface I_AccessDenialBody {
|
|
35
83
|
statusCode: number;
|
|
36
84
|
error: string;
|
|
37
85
|
message: string;
|
|
38
86
|
code: AccessDenialCode;
|
|
87
|
+
precondition?: EntitlementPrecondition;
|
|
39
88
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ACCESS_DENIAL_CODES = void 0;
|
|
3
|
+
exports.ENTITLEMENT_PRECONDITIONS = exports.ACCESS_DENIAL_CODES = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Why a 403 happened, as a value a client can branch on.
|
|
6
6
|
*
|
|
@@ -28,3 +28,45 @@ exports.ACCESS_DENIAL_CODES = {
|
|
|
28
28
|
*/
|
|
29
29
|
ENTITLEMENT_PRECONDITION: "entitlement-precondition",
|
|
30
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* WHICH precondition the entitlement wall refused on (backend#305, spec 170).
|
|
33
|
+
*
|
|
34
|
+
* `ENTITLEMENT_PRECONDITION` above is the CLIENT contract: every cause below
|
|
35
|
+
* wants the same behaviour from a client — render the surface's own empty
|
|
36
|
+
* state, do not redirect, never send a paying user to buy a plan they already
|
|
37
|
+
* own — so a fourth code would force every consumer to re-learn a wall it
|
|
38
|
+
* already handles correctly, to carry information no consumer acts on.
|
|
39
|
+
*
|
|
40
|
+
* This is for whoever is READING. There is one throw site in the backend and
|
|
41
|
+
* four ways to reach it, and until now all four answered with the same string:
|
|
42
|
+
* "has no beta place", "subscription lapsed", "has connected nothing yet" and
|
|
43
|
+
* "was left in a state we do not recognise" were indistinguishable outside a
|
|
44
|
+
* database session. One beta tester was refused across /dashboard, /inbox,
|
|
45
|
+
* /settings and a post page — 21 denials in 30 days — and the only way to find
|
|
46
|
+
* out why was to open the database by hand.
|
|
47
|
+
*
|
|
48
|
+
* NOTHING HERE MAY SAY ANYTHING ABOUT THE ACCOUNT beyond which condition
|
|
49
|
+
* failed. These names travel to the browser. `UNRECOGNISED_PLAN` deliberately
|
|
50
|
+
* does not carry WHICH plan value was unrecognised — that belongs in the
|
|
51
|
+
* server-side log.
|
|
52
|
+
*/
|
|
53
|
+
exports.ENTITLEMENT_PRECONDITIONS = {
|
|
54
|
+
/** No such user row. Unentitled, not merely unconnected. */
|
|
55
|
+
UNKNOWN_ACCOUNT: "unknown-account",
|
|
56
|
+
/**
|
|
57
|
+
* A plan value the allow-list deliberately refuses: 'TRIALING', 'PAST_DUE',
|
|
58
|
+
* a future tier, or a typo. Split out of NO_ENTITLEMENT on purpose — both
|
|
59
|
+
* stay refused, and the fail-closed property of the allow-list is not
|
|
60
|
+
* touched, but a plan value we do not recognise is a DEFECT and a plain free
|
|
61
|
+
* account is not. Reported separately because that distinction is most of
|
|
62
|
+
* what makes a locked-out account diagnosable.
|
|
63
|
+
*/
|
|
64
|
+
UNRECOGNISED_PLAN: "unrecognised-plan",
|
|
65
|
+
/** No paid plan and no live beta place; or a lapsed payer / lapsed place
|
|
66
|
+
* holder, who keeps read-only access to content we hold but has no claim on
|
|
67
|
+
* a live vendor read. */
|
|
68
|
+
NO_ENTITLEMENT: "no-entitlement",
|
|
69
|
+
/** Entitled, and has connected nothing yet. They need an account, NOT a
|
|
70
|
+
* purchase — the distinction spec 144 drew in prose and nothing recorded. */
|
|
71
|
+
NO_CONNECTION: "no-connection",
|
|
72
|
+
};
|
|
@@ -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
|
+
};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/types/index.js
CHANGED
|
@@ -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
|
@@ -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)", () => {
|
package/src/analytics/index.ts
CHANGED
|
@@ -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
|
|
@@ -29,14 +29,66 @@ export const ACCESS_DENIAL_CODES = {
|
|
|
29
29
|
export type AccessDenialCode =
|
|
30
30
|
(typeof ACCESS_DENIAL_CODES)[keyof typeof ACCESS_DENIAL_CODES];
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* WHICH precondition the entitlement wall refused on (backend#305, spec 170).
|
|
34
|
+
*
|
|
35
|
+
* `ENTITLEMENT_PRECONDITION` above is the CLIENT contract: every cause below
|
|
36
|
+
* wants the same behaviour from a client — render the surface's own empty
|
|
37
|
+
* state, do not redirect, never send a paying user to buy a plan they already
|
|
38
|
+
* own — so a fourth code would force every consumer to re-learn a wall it
|
|
39
|
+
* already handles correctly, to carry information no consumer acts on.
|
|
40
|
+
*
|
|
41
|
+
* This is for whoever is READING. There is one throw site in the backend and
|
|
42
|
+
* four ways to reach it, and until now all four answered with the same string:
|
|
43
|
+
* "has no beta place", "subscription lapsed", "has connected nothing yet" and
|
|
44
|
+
* "was left in a state we do not recognise" were indistinguishable outside a
|
|
45
|
+
* database session. One beta tester was refused across /dashboard, /inbox,
|
|
46
|
+
* /settings and a post page — 21 denials in 30 days — and the only way to find
|
|
47
|
+
* out why was to open the database by hand.
|
|
48
|
+
*
|
|
49
|
+
* NOTHING HERE MAY SAY ANYTHING ABOUT THE ACCOUNT beyond which condition
|
|
50
|
+
* failed. These names travel to the browser. `UNRECOGNISED_PLAN` deliberately
|
|
51
|
+
* does not carry WHICH plan value was unrecognised — that belongs in the
|
|
52
|
+
* server-side log.
|
|
53
|
+
*/
|
|
54
|
+
export const ENTITLEMENT_PRECONDITIONS = {
|
|
55
|
+
/** No such user row. Unentitled, not merely unconnected. */
|
|
56
|
+
UNKNOWN_ACCOUNT: "unknown-account",
|
|
57
|
+
/**
|
|
58
|
+
* A plan value the allow-list deliberately refuses: 'TRIALING', 'PAST_DUE',
|
|
59
|
+
* a future tier, or a typo. Split out of NO_ENTITLEMENT on purpose — both
|
|
60
|
+
* stay refused, and the fail-closed property of the allow-list is not
|
|
61
|
+
* touched, but a plan value we do not recognise is a DEFECT and a plain free
|
|
62
|
+
* account is not. Reported separately because that distinction is most of
|
|
63
|
+
* what makes a locked-out account diagnosable.
|
|
64
|
+
*/
|
|
65
|
+
UNRECOGNISED_PLAN: "unrecognised-plan",
|
|
66
|
+
/** No paid plan and no live beta place; or a lapsed payer / lapsed place
|
|
67
|
+
* holder, who keeps read-only access to content we hold but has no claim on
|
|
68
|
+
* a live vendor read. */
|
|
69
|
+
NO_ENTITLEMENT: "no-entitlement",
|
|
70
|
+
/** Entitled, and has connected nothing yet. They need an account, NOT a
|
|
71
|
+
* purchase — the distinction spec 144 drew in prose and nothing recorded. */
|
|
72
|
+
NO_CONNECTION: "no-connection",
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
export type EntitlementPrecondition =
|
|
76
|
+
(typeof ENTITLEMENT_PRECONDITIONS)[keyof typeof ENTITLEMENT_PRECONDITIONS];
|
|
77
|
+
|
|
32
78
|
/**
|
|
33
79
|
* The body shape those 403s answer with. `statusCode`/`error` are carried
|
|
34
80
|
* explicitly so the response stays byte-identical to Nest's default envelope
|
|
35
81
|
* and `code` is purely additive — no existing consumer sees a change.
|
|
82
|
+
*
|
|
83
|
+
* `precondition` is additive in exactly the same way, and OPTIONAL, which is
|
|
84
|
+
* what makes two promises true by construction: a client that ignores it
|
|
85
|
+
* behaves as it does today, and a response from a backend that predates it is
|
|
86
|
+
* still a valid body rather than something a consumer has to special-case.
|
|
36
87
|
*/
|
|
37
88
|
export interface I_AccessDenialBody {
|
|
38
89
|
statusCode: number;
|
|
39
90
|
error: string;
|
|
40
91
|
message: string;
|
|
41
92
|
code: AccessDenialCode;
|
|
93
|
+
precondition?: EntitlementPrecondition;
|
|
42
94
|
}
|
|
@@ -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,70 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ACCESS_DENIAL_CODES,
|
|
6
|
+
ENTITLEMENT_PRECONDITIONS,
|
|
7
|
+
type EntitlementPrecondition,
|
|
8
|
+
type I_AccessDenialBody,
|
|
9
|
+
} from './I_AccessDenial';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Spec 170 T003. These four strings are a contract between three repos and a
|
|
13
|
+
* PostHog query someone will write against them: a rename is a silent break in
|
|
14
|
+
* whichever of the three does not get rebuilt, and the query just stops
|
|
15
|
+
* matching. Pinned here so a rename has to be a decision.
|
|
16
|
+
*/
|
|
17
|
+
test('the four precondition names are stable strings', () => {
|
|
18
|
+
assert.deepEqual(ENTITLEMENT_PRECONDITIONS, {
|
|
19
|
+
UNKNOWN_ACCOUNT: 'unknown-account',
|
|
20
|
+
UNRECOGNISED_PLAN: 'unrecognised-plan',
|
|
21
|
+
NO_ENTITLEMENT: 'no-entitlement',
|
|
22
|
+
NO_CONNECTION: 'no-connection',
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('the union covers every name and nothing else', () => {
|
|
27
|
+
const all: EntitlementPrecondition[] = Object.values(ENTITLEMENT_PRECONDITIONS);
|
|
28
|
+
assert.equal(new Set(all).size, 4);
|
|
29
|
+
// Exhaustiveness, checked by the compiler: adding a name without handling it
|
|
30
|
+
// here stops building.
|
|
31
|
+
for (const value of all) {
|
|
32
|
+
switch (value) {
|
|
33
|
+
case 'unknown-account':
|
|
34
|
+
case 'unrecognised-plan':
|
|
35
|
+
case 'no-entitlement':
|
|
36
|
+
case 'no-connection':
|
|
37
|
+
break;
|
|
38
|
+
default: {
|
|
39
|
+
const never: never = value;
|
|
40
|
+
throw new Error(`unhandled precondition ${String(never)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('precondition is optional, so an older backend still sends a valid body', () => {
|
|
47
|
+
// The whole point of the field being optional: this has to compile.
|
|
48
|
+
const withoutIt: I_AccessDenialBody = {
|
|
49
|
+
statusCode: 403,
|
|
50
|
+
error: 'Forbidden',
|
|
51
|
+
message: 'Analytics require a paid plan with a connected Late profile',
|
|
52
|
+
code: ACCESS_DENIAL_CODES.ENTITLEMENT_PRECONDITION,
|
|
53
|
+
};
|
|
54
|
+
assert.equal(withoutIt.precondition, undefined);
|
|
55
|
+
|
|
56
|
+
const withIt: I_AccessDenialBody = {
|
|
57
|
+
...withoutIt,
|
|
58
|
+
precondition: ENTITLEMENT_PRECONDITIONS.NO_CONNECTION,
|
|
59
|
+
};
|
|
60
|
+
assert.equal(withIt.precondition, 'no-connection');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the denial codes are untouched', () => {
|
|
64
|
+
// FR-003: this feature adds a field, it does not renegotiate `code`.
|
|
65
|
+
assert.deepEqual(ACCESS_DENIAL_CODES, {
|
|
66
|
+
BETA_REQUIRED: 'beta-required',
|
|
67
|
+
SUBSCRIPTION_REQUIRED: 'subscription-required',
|
|
68
|
+
ENTITLEMENT_PRECONDITION: 'entitlement-precondition',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -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
|
+
});
|
package/src/types/index.ts
CHANGED
|
@@ -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";
|