ad2app-lib 1.28.0 → 1.30.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 +7 -1
- package/dist/types/I_AccessDenial.d.ts +39 -0
- package/dist/types/I_AccessDenial.js +30 -0
- package/dist/types/I_User.d.ts +31 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +2 -0
- package/package.json +2 -2
- package/src/analytics/index.ts +8 -1
- package/src/types/I_AccessDenial.ts +42 -0
- package/src/types/I_User.ts +33 -0
- package/src/types/index.ts +3 -0
- package/src/types/onboarding-state.test.ts +66 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Naming convention: snake_case, object_verb_past_tense.
|
|
9
9
|
* Do NOT rename events after they ship — historical data does not migrate.
|
|
10
10
|
*/
|
|
11
|
+
import type { AccessDenialCode } from "../types/I_AccessDenial";
|
|
11
12
|
/** Canonical PostHog event names. */
|
|
12
13
|
export declare const EVENTS: {
|
|
13
14
|
readonly LANDING_CTA_CLICKED: "landing_cta_clicked";
|
|
@@ -270,7 +271,12 @@ export interface EventProperties {
|
|
|
270
271
|
app_locale: AppLocale;
|
|
271
272
|
};
|
|
272
273
|
[EVENTS.ACCESS_DENIED]: {
|
|
273
|
-
|
|
274
|
+
/**
|
|
275
|
+
* The backend's own machine-readable code, not a client guess — one union
|
|
276
|
+
* shared by the API body and this event (types/I_AccessDenial.ts), so a
|
|
277
|
+
* new wall cannot be counted under a reason that does not exist.
|
|
278
|
+
*/
|
|
279
|
+
reason: AccessDenialCode;
|
|
274
280
|
path?: string;
|
|
275
281
|
app_locale: AppLocale;
|
|
276
282
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a 403 happened, as a value a client can branch on.
|
|
3
|
+
*
|
|
4
|
+
* The frontend used to recognise these walls by comparing the backend's
|
|
5
|
+
* English message text, and that broke silently the first time a guard's
|
|
6
|
+
* wording changed: expected entitlement 403s were captured as $exception at a
|
|
7
|
+
* rate of ten in two minutes of ordinary tapping, burying real errors in the
|
|
8
|
+
* telemetry that exists to surface them (AD2-1320, 2026-08-19). Prose is copy
|
|
9
|
+
* and copy changes; this code is contract.
|
|
10
|
+
*
|
|
11
|
+
* Every 403 the product EXPECTS carries one of these as `code` in its response
|
|
12
|
+
* body, alongside the human-readable `message`, which is unchanged.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ACCESS_DENIAL_CODES: {
|
|
15
|
+
/** No plan and no beta slot — nothing of their own to look at yet. */
|
|
16
|
+
readonly BETA_REQUIRED: "beta-required";
|
|
17
|
+
/** A churned payer attempting a write; they can still read their own data. */
|
|
18
|
+
readonly SUBSCRIPTION_REQUIRED: "subscription-required";
|
|
19
|
+
/**
|
|
20
|
+
* Needs a paid plan AND a connected Late profile. Deliberately distinct from
|
|
21
|
+
* SUBSCRIPTION_REQUIRED, because it names two preconditions at once: a
|
|
22
|
+
* paying user who has simply not connected an account yet must never be sent
|
|
23
|
+
* to buy a plan they already own. Clients show the surface's own empty state
|
|
24
|
+
* for this one and do NOT redirect.
|
|
25
|
+
*/
|
|
26
|
+
readonly ENTITLEMENT_PRECONDITION: "entitlement-precondition";
|
|
27
|
+
};
|
|
28
|
+
export type AccessDenialCode = (typeof ACCESS_DENIAL_CODES)[keyof typeof ACCESS_DENIAL_CODES];
|
|
29
|
+
/**
|
|
30
|
+
* The body shape those 403s answer with. `statusCode`/`error` are carried
|
|
31
|
+
* explicitly so the response stays byte-identical to Nest's default envelope
|
|
32
|
+
* and `code` is purely additive — no existing consumer sees a change.
|
|
33
|
+
*/
|
|
34
|
+
export interface I_AccessDenialBody {
|
|
35
|
+
statusCode: number;
|
|
36
|
+
error: string;
|
|
37
|
+
message: string;
|
|
38
|
+
code: AccessDenialCode;
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ACCESS_DENIAL_CODES = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Why a 403 happened, as a value a client can branch on.
|
|
6
|
+
*
|
|
7
|
+
* The frontend used to recognise these walls by comparing the backend's
|
|
8
|
+
* English message text, and that broke silently the first time a guard's
|
|
9
|
+
* wording changed: expected entitlement 403s were captured as $exception at a
|
|
10
|
+
* rate of ten in two minutes of ordinary tapping, burying real errors in the
|
|
11
|
+
* telemetry that exists to surface them (AD2-1320, 2026-08-19). Prose is copy
|
|
12
|
+
* and copy changes; this code is contract.
|
|
13
|
+
*
|
|
14
|
+
* Every 403 the product EXPECTS carries one of these as `code` in its response
|
|
15
|
+
* body, alongside the human-readable `message`, which is unchanged.
|
|
16
|
+
*/
|
|
17
|
+
exports.ACCESS_DENIAL_CODES = {
|
|
18
|
+
/** No plan and no beta slot — nothing of their own to look at yet. */
|
|
19
|
+
BETA_REQUIRED: "beta-required",
|
|
20
|
+
/** A churned payer attempting a write; they can still read their own data. */
|
|
21
|
+
SUBSCRIPTION_REQUIRED: "subscription-required",
|
|
22
|
+
/**
|
|
23
|
+
* Needs a paid plan AND a connected Late profile. Deliberately distinct from
|
|
24
|
+
* SUBSCRIPTION_REQUIRED, because it names two preconditions at once: a
|
|
25
|
+
* paying user who has simply not connected an account yet must never be sent
|
|
26
|
+
* to buy a plan they already own. Clients show the surface's own empty state
|
|
27
|
+
* for this one and do NOT redirect.
|
|
28
|
+
*/
|
|
29
|
+
ENTITLEMENT_PRECONDITION: "entitlement-precondition",
|
|
30
|
+
};
|
package/dist/types/I_User.d.ts
CHANGED
|
@@ -10,8 +10,39 @@ export declare class I_User {
|
|
|
10
10
|
has_connected_platforms?: boolean;
|
|
11
11
|
influencer?: I_Influencer;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The answer an account has recorded about the onboarding tour.
|
|
15
|
+
*
|
|
16
|
+
* Three values, and the difference between the last two is the whole point:
|
|
17
|
+
* 'skipped' | 'completed' — a recorded answer. Never re-teach this user.
|
|
18
|
+
* null — no record yet. A genuinely new user; show it.
|
|
19
|
+
* ABSENT (the field is missing entirely) — the server could not read the
|
|
20
|
+
* column. Decide NOTHING.
|
|
21
|
+
*
|
|
22
|
+
* Absent is deliberately not `null`. Collapsing them shows the tour to every
|
|
23
|
+
* existing user in the window between a deploy and its manual migration
|
|
24
|
+
* (migrations here are run by hand, never on deploy).
|
|
25
|
+
*/
|
|
26
|
+
export type OnboardingState = 'skipped' | 'completed';
|
|
13
27
|
export declare class I_SignedUser extends I_User {
|
|
14
28
|
accessToken: string;
|
|
29
|
+
/**
|
|
30
|
+
* AD2-1331 AC3 (Maciej's review, 2026-08-25). Every response that hands back
|
|
31
|
+
* a session carries this, not just `authenticate` — the client replaces its
|
|
32
|
+
* authed slice wholesale with whichever payload arrives, so a path that omits
|
|
33
|
+
* it leaves the answer unknown for the rest of the session (AD2-1329 AC4).
|
|
34
|
+
*
|
|
35
|
+
* It lives HERE because AC3 chose an echoing PATCH over a failing one
|
|
36
|
+
* specifically to remove the forward-only rule from the client, and that only
|
|
37
|
+
* works if both sides agree on the shape. Until this shipped, the field was
|
|
38
|
+
* written by the backend, read by the frontend, and declared by neither: the
|
|
39
|
+
* frontend had to cast through `unknown` to see it, and a rename on either
|
|
40
|
+
* side would have type-checked clean while the onboarding funnel silently
|
|
41
|
+
* went dark.
|
|
42
|
+
*
|
|
43
|
+
* Optional on purpose — see OnboardingState: absent means "could not read".
|
|
44
|
+
*/
|
|
45
|
+
onboardingState?: OnboardingState | null;
|
|
15
46
|
}
|
|
16
47
|
export declare enum UserRoles {
|
|
17
48
|
ADMIN = "ADMIN",
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/index.js
CHANGED
|
@@ -52,3 +52,5 @@ __exportStar(require("./I_Publish"), exports);
|
|
|
52
52
|
__exportStar(require("./I_SM_Platform"), exports);
|
|
53
53
|
// ── Scheduling domain ─────────────────────────────────────────────────────────
|
|
54
54
|
__exportStar(require("./scheduling"), exports);
|
|
55
|
+
// ── Access control ────────────────────────────────────────────────────────────
|
|
56
|
+
__exportStar(require("./I_AccessDenial"), exports);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ad2app-lib",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"prepare": "npm run build"
|
|
48
48
|
},
|
|
49
49
|
"keywords": [],
|
|
50
|
-
"author": "Maciej
|
|
50
|
+
"author": "Maciej G\u00f3rski@ad2.app",
|
|
51
51
|
"license": "ISC",
|
|
52
52
|
"description": "Package to share types and utils across the ad2app projects",
|
|
53
53
|
"dependencies": {
|
package/src/analytics/index.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* Do NOT rename events after they ship — historical data does not migrate.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import type { AccessDenialCode } from "../types/I_AccessDenial";
|
|
13
|
+
|
|
12
14
|
/** Canonical PostHog event names. */
|
|
13
15
|
export const EVENTS = {
|
|
14
16
|
// Acquisition (landing)
|
|
@@ -325,7 +327,12 @@ export interface EventProperties {
|
|
|
325
327
|
// different 403s the backend sends. `path` is the request path, never a URL
|
|
326
328
|
// with query params (no PII in analytics).
|
|
327
329
|
[EVENTS.ACCESS_DENIED]: {
|
|
328
|
-
|
|
330
|
+
/**
|
|
331
|
+
* The backend's own machine-readable code, not a client guess — one union
|
|
332
|
+
* shared by the API body and this event (types/I_AccessDenial.ts), so a
|
|
333
|
+
* new wall cannot be counted under a reason that does not exist.
|
|
334
|
+
*/
|
|
335
|
+
reason: AccessDenialCode;
|
|
329
336
|
path?: string;
|
|
330
337
|
app_locale: AppLocale;
|
|
331
338
|
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a 403 happened, as a value a client can branch on.
|
|
3
|
+
*
|
|
4
|
+
* The frontend used to recognise these walls by comparing the backend's
|
|
5
|
+
* English message text, and that broke silently the first time a guard's
|
|
6
|
+
* wording changed: expected entitlement 403s were captured as $exception at a
|
|
7
|
+
* rate of ten in two minutes of ordinary tapping, burying real errors in the
|
|
8
|
+
* telemetry that exists to surface them (AD2-1320, 2026-08-19). Prose is copy
|
|
9
|
+
* and copy changes; this code is contract.
|
|
10
|
+
*
|
|
11
|
+
* Every 403 the product EXPECTS carries one of these as `code` in its response
|
|
12
|
+
* body, alongside the human-readable `message`, which is unchanged.
|
|
13
|
+
*/
|
|
14
|
+
export const ACCESS_DENIAL_CODES = {
|
|
15
|
+
/** No plan and no beta slot — nothing of their own to look at yet. */
|
|
16
|
+
BETA_REQUIRED: "beta-required",
|
|
17
|
+
/** A churned payer attempting a write; they can still read their own data. */
|
|
18
|
+
SUBSCRIPTION_REQUIRED: "subscription-required",
|
|
19
|
+
/**
|
|
20
|
+
* Needs a paid plan AND a connected Late profile. Deliberately distinct from
|
|
21
|
+
* SUBSCRIPTION_REQUIRED, because it names two preconditions at once: a
|
|
22
|
+
* paying user who has simply not connected an account yet must never be sent
|
|
23
|
+
* to buy a plan they already own. Clients show the surface's own empty state
|
|
24
|
+
* for this one and do NOT redirect.
|
|
25
|
+
*/
|
|
26
|
+
ENTITLEMENT_PRECONDITION: "entitlement-precondition",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export type AccessDenialCode =
|
|
30
|
+
(typeof ACCESS_DENIAL_CODES)[keyof typeof ACCESS_DENIAL_CODES];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The body shape those 403s answer with. `statusCode`/`error` are carried
|
|
34
|
+
* explicitly so the response stays byte-identical to Nest's default envelope
|
|
35
|
+
* and `code` is purely additive — no existing consumer sees a change.
|
|
36
|
+
*/
|
|
37
|
+
export interface I_AccessDenialBody {
|
|
38
|
+
statusCode: number;
|
|
39
|
+
error: string;
|
|
40
|
+
message: string;
|
|
41
|
+
code: AccessDenialCode;
|
|
42
|
+
}
|
package/src/types/I_User.ts
CHANGED
|
@@ -13,8 +13,41 @@ export class I_User {
|
|
|
13
13
|
influencer?: I_Influencer;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The answer an account has recorded about the onboarding tour.
|
|
18
|
+
*
|
|
19
|
+
* Three values, and the difference between the last two is the whole point:
|
|
20
|
+
* 'skipped' | 'completed' — a recorded answer. Never re-teach this user.
|
|
21
|
+
* null — no record yet. A genuinely new user; show it.
|
|
22
|
+
* ABSENT (the field is missing entirely) — the server could not read the
|
|
23
|
+
* column. Decide NOTHING.
|
|
24
|
+
*
|
|
25
|
+
* Absent is deliberately not `null`. Collapsing them shows the tour to every
|
|
26
|
+
* existing user in the window between a deploy and its manual migration
|
|
27
|
+
* (migrations here are run by hand, never on deploy).
|
|
28
|
+
*/
|
|
29
|
+
export type OnboardingState = 'skipped' | 'completed';
|
|
30
|
+
|
|
16
31
|
export class I_SignedUser extends I_User {
|
|
17
32
|
accessToken: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* AD2-1331 AC3 (Maciej's review, 2026-08-25). Every response that hands back
|
|
36
|
+
* a session carries this, not just `authenticate` — the client replaces its
|
|
37
|
+
* authed slice wholesale with whichever payload arrives, so a path that omits
|
|
38
|
+
* it leaves the answer unknown for the rest of the session (AD2-1329 AC4).
|
|
39
|
+
*
|
|
40
|
+
* It lives HERE because AC3 chose an echoing PATCH over a failing one
|
|
41
|
+
* specifically to remove the forward-only rule from the client, and that only
|
|
42
|
+
* works if both sides agree on the shape. Until this shipped, the field was
|
|
43
|
+
* written by the backend, read by the frontend, and declared by neither: the
|
|
44
|
+
* frontend had to cast through `unknown` to see it, and a rename on either
|
|
45
|
+
* side would have type-checked clean while the onboarding funnel silently
|
|
46
|
+
* went dark.
|
|
47
|
+
*
|
|
48
|
+
* Optional on purpose — see OnboardingState: absent means "could not read".
|
|
49
|
+
*/
|
|
50
|
+
onboardingState?: OnboardingState | null;
|
|
18
51
|
}
|
|
19
52
|
|
|
20
53
|
export enum UserRoles {
|
package/src/types/index.ts
CHANGED
|
@@ -36,3 +36,6 @@ export * from "./I_Publish";
|
|
|
36
36
|
export * from "./I_SM_Platform";
|
|
37
37
|
// ── Scheduling domain ─────────────────────────────────────────────────────────
|
|
38
38
|
export * from "./scheduling";
|
|
39
|
+
|
|
40
|
+
// ── Access control ────────────────────────────────────────────────────────────
|
|
41
|
+
export * from "./I_AccessDenial";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { I_SignedUser, OnboardingState, UserRoles } from './I_User';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AD2-1331 AC3 — the shared shape, pinned.
|
|
8
|
+
*
|
|
9
|
+
* These are mostly COMPILE-time assertions: the value of this file is that
|
|
10
|
+
* `tsc` fails if `onboardingState` is renamed, narrowed, or made required on
|
|
11
|
+
* one side of the wire. Before it existed the field was written by the backend,
|
|
12
|
+
* read by the frontend, and declared by neither, so either side could rename it
|
|
13
|
+
* and every type-check would still pass while the onboarding funnel went dark.
|
|
14
|
+
*
|
|
15
|
+
* The runtime assertions below exist so the file is also a real test rather
|
|
16
|
+
* than a silent type-only artefact that a test runner skips over.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
test('a signed user may carry a recorded answer', () => {
|
|
20
|
+
const user: I_SignedUser = {
|
|
21
|
+
id: 'u-1',
|
|
22
|
+
role: UserRoles.INFLUENCER,
|
|
23
|
+
accessToken: 'tok',
|
|
24
|
+
onboardingState: 'completed',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
assert.equal(user.onboardingState, 'completed');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('null is a value, and means "no record yet" — a new user', () => {
|
|
31
|
+
const user: I_SignedUser = {
|
|
32
|
+
id: 'u-1',
|
|
33
|
+
role: UserRoles.INFLUENCER,
|
|
34
|
+
accessToken: 'tok',
|
|
35
|
+
onboardingState: null,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// null must survive as null: the client tells it apart from absent, and
|
|
39
|
+
// only null means "show the tour".
|
|
40
|
+
assert.equal(user.onboardingState, null);
|
|
41
|
+
assert.ok('onboardingState' in user);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('ABSENT is distinct from null — the field may be omitted entirely', () => {
|
|
45
|
+
// The migration-pending case. If this ever stops compiling because the
|
|
46
|
+
// field was made required, the deploy window that follows would show the
|
|
47
|
+
// onboarding tour to the entire existing user base.
|
|
48
|
+
const user: I_SignedUser = {
|
|
49
|
+
id: 'u-1',
|
|
50
|
+
role: UserRoles.INFLUENCER,
|
|
51
|
+
accessToken: 'tok',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
assert.equal('onboardingState' in user, false);
|
|
55
|
+
assert.equal(user.onboardingState, undefined);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the state union is exactly the two recorded answers', () => {
|
|
59
|
+
const every: OnboardingState[] = ['skipped', 'completed'];
|
|
60
|
+
|
|
61
|
+
assert.deepEqual(every, ['skipped', 'completed']);
|
|
62
|
+
// @ts-expect-error 'pending' is not a recorded answer; null/absent cover
|
|
63
|
+
// "no answer", and a third string would be a state nobody handles.
|
|
64
|
+
const invalid: OnboardingState = 'pending';
|
|
65
|
+
assert.equal(invalid, 'pending');
|
|
66
|
+
});
|