ad2app-lib 1.42.0 → 1.44.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 +61 -0
- package/dist/analytics/index.js +31 -0
- package/dist/types/I_AccessDenial.d.ts +55 -0
- package/dist/types/I_AccessDenial.js +56 -0
- package/dist/types/I_AnalyticsConsent.d.ts +47 -0
- package/dist/types/I_AnalyticsConsent.js +34 -0
- package/dist/types/agency.d.ts +260 -0
- package/dist/types/agency.js +200 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +3 -0
- package/package.json +1 -1
- package/src/analytics/index.test.ts +54 -0
- package/src/analytics/index.ts +79 -0
- package/src/types/I_AccessDenial.ts +58 -0
- package/src/types/I_AnalyticsConsent.ts +50 -0
- package/src/types/access-denial.test.ts +31 -7
- package/src/types/agency.test.ts +80 -0
- package/src/types/agency.ts +382 -0
- package/src/types/analytics-consent.test.ts +52 -0
- package/src/types/index.ts +4 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Agency domain — organizations, memberships and creator access grants (spec 175).
|
|
4
|
+
*
|
|
5
|
+
* The shape behind "an agency sees its creators' numbers, because the creators
|
|
6
|
+
* said yes". Two doors, one product: a creator account is unchanged by any of
|
|
7
|
+
* this, and gains at most one grant.
|
|
8
|
+
*
|
|
9
|
+
* The one idea worth carrying into every consumer: a GRANT IS READ-ONLY AND
|
|
10
|
+
* CREATOR-OWNED. The agency asks for scopes; only a creator's own act ever
|
|
11
|
+
* writes `grantedScopes` (FR-008, SC-002). Nothing here can express a write on
|
|
12
|
+
* a creator's account, and that is deliberate — the absence is the feature.
|
|
13
|
+
*
|
|
14
|
+
* See specs/175-agency-roster/contracts/lib-types.md.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.AdminSetOrganizationStatusDto = exports.AgencyAccessRequestDto = exports.AgencyDiscoverQueryDto = exports.SponsorCreatorDto = exports.UpdateGrantScopesDto = exports.AcceptGrantDto = exports.AcceptAgencyMemberInviteDto = exports.RedeemAgencyInviteDto = exports.InviteAgencyMemberDto = exports.CreateAgencyOrganizationDto = exports.AGENCY_ROSTER_NEEDS_ATTENTION_STATES = exports.AGENCY_ROSTER_ROW_STATES = exports.CREATOR_ACCESS_GRANT_STATES = exports.AGENCY_ORGANIZATION_STATUSES = exports.isAgencyGrantScope = exports.AGENCY_GRANT_SCOPES = exports.AGENCY_ROUTE_PATHS = void 0;
|
|
18
|
+
// ── Cross-repo route constants ───────────────────────────────────────────────
|
|
19
|
+
/**
|
|
20
|
+
* Paths that BOTH repos have to agree on, with one home here.
|
|
21
|
+
*
|
|
22
|
+
* The backend mails this path; the frontend registers it. They deploy
|
|
23
|
+
* separately, so nothing else notices them drifting — and the first version of
|
|
24
|
+
* that link pointed at a route no repo served, which is how an entire user
|
|
25
|
+
* story shipped with its headline capability unreachable.
|
|
26
|
+
*
|
|
27
|
+
* A test in each repo asserts its own side against these constants. The first
|
|
28
|
+
* attempt at that guard read across the filesystem into a sibling checkout,
|
|
29
|
+
* which worked on one laptop and threw ENOENT in CI; a shared constant is the
|
|
30
|
+
* version that actually runs.
|
|
31
|
+
*/
|
|
32
|
+
exports.AGENCY_ROUTE_PATHS = {
|
|
33
|
+
/** A colleague accepting a team invitation (FR-005). */
|
|
34
|
+
joinTeam: '/agency/join-team',
|
|
35
|
+
/** A creator consenting to an agency's access (FR-007). */
|
|
36
|
+
join: '/agency/join',
|
|
37
|
+
/** A creator answering an access request (FR-016). */
|
|
38
|
+
requests: '/agency/requests',
|
|
39
|
+
};
|
|
40
|
+
// ── Scopes ───────────────────────────────────────────────────────────────────
|
|
41
|
+
/**
|
|
42
|
+
* The complete set of reads a grant may carry. A CEILING, not a starting point.
|
|
43
|
+
*
|
|
44
|
+
* All three are reads of what the creator already sees on their own screens.
|
|
45
|
+
* Private messages, comment replies and moderation, publishing, connection
|
|
46
|
+
* changes and settings are absent by construction (FR-010) — there is no scope
|
|
47
|
+
* string that could name them, so a widened grant is a spec change rather than
|
|
48
|
+
* a config change.
|
|
49
|
+
*/
|
|
50
|
+
exports.AGENCY_GRANT_SCOPES = [
|
|
51
|
+
/** the creator's own five analytics reads, full history, no date filtering */
|
|
52
|
+
'analytics',
|
|
53
|
+
/** comments under the creator's posts, READ only — the one inbox read a grant reaches */
|
|
54
|
+
'comments',
|
|
55
|
+
/** the creator's published posts with their own numbers — never drafts or the queue */
|
|
56
|
+
'posts',
|
|
57
|
+
];
|
|
58
|
+
/**
|
|
59
|
+
* Narrowing guard — an unrecognised scope must fail closed.
|
|
60
|
+
*
|
|
61
|
+
* Scope strings arrive from request bodies (the agency's ask, the creator's
|
|
62
|
+
* answer), so this is a trust boundary, not a convenience.
|
|
63
|
+
*/
|
|
64
|
+
const isAgencyGrantScope = (value) => exports.AGENCY_GRANT_SCOPES.includes(value);
|
|
65
|
+
exports.isAgencyGrantScope = isAgencyGrantScope;
|
|
66
|
+
/**
|
|
67
|
+
* `closed` was added by the 2026-09-21 requirements review (FR-004b).
|
|
68
|
+
*
|
|
69
|
+
* It exists so closure has an end state that is NOT a deleted row: grants are
|
|
70
|
+
* `on delete cascade` from the organization, so hard-deleting one would destroy
|
|
71
|
+
* the consent records FR-009 says are never deleted — and which are also the
|
|
72
|
+
* invoice basis.
|
|
73
|
+
*/
|
|
74
|
+
exports.AGENCY_ORGANIZATION_STATUSES = [
|
|
75
|
+
'pending',
|
|
76
|
+
'approved',
|
|
77
|
+
'rejected',
|
|
78
|
+
'closed',
|
|
79
|
+
];
|
|
80
|
+
// ── Grant ────────────────────────────────────────────────────────────────────
|
|
81
|
+
/**
|
|
82
|
+
* The five states the data model pins.
|
|
83
|
+
*
|
|
84
|
+
* `declined` and `expired` belong to the US5 request path (FR-016) and were
|
|
85
|
+
* missing from the spec's own Key Entities until the 2026-09-21 review. There
|
|
86
|
+
* is no transition OUT of `revoked`, `declined` or `expired`: a new consent is
|
|
87
|
+
* a new row, so the record of what was agreed is never overwritten.
|
|
88
|
+
*/
|
|
89
|
+
exports.CREATOR_ACCESS_GRANT_STATES = [
|
|
90
|
+
'requested',
|
|
91
|
+
'active',
|
|
92
|
+
'declined',
|
|
93
|
+
'expired',
|
|
94
|
+
'revoked',
|
|
95
|
+
];
|
|
96
|
+
// ── Roster ───────────────────────────────────────────────────────────────────
|
|
97
|
+
/**
|
|
98
|
+
* Roster row states, DECLARED IN PRECEDENCE ORDER (data-model §Derived).
|
|
99
|
+
*
|
|
100
|
+
* A row holds exactly one state: the first of these that applies, most-blocking
|
|
101
|
+
* first. `revoked` is deliberately absent — a revoked creator leaves the roster
|
|
102
|
+
* entirely for the settings history (FR-011).
|
|
103
|
+
*/
|
|
104
|
+
exports.AGENCY_ROSTER_ROW_STATES = [
|
|
105
|
+
/** the agency asked, the creator has not answered */
|
|
106
|
+
'requested',
|
|
107
|
+
/** the creator's own plan has lapsed */
|
|
108
|
+
'lapsed',
|
|
109
|
+
/** the creator has no live platform connection */
|
|
110
|
+
'disconnected',
|
|
111
|
+
/** no successful daily sync in 24 hours (FR-011, Jan 2026-09-21) */
|
|
112
|
+
'stale',
|
|
113
|
+
'active',
|
|
114
|
+
];
|
|
115
|
+
/**
|
|
116
|
+
* What "needs attention" means — ONE home for the KPI tile, the roster segment
|
|
117
|
+
* and the rail badge, so the three can never drift apart (FR-011).
|
|
118
|
+
*
|
|
119
|
+
* `requested` is excluded on purpose: it waits on the CREATOR, and FR-004a says
|
|
120
|
+
* the badge means something waits on the agency.
|
|
121
|
+
*/
|
|
122
|
+
exports.AGENCY_ROSTER_NEEDS_ATTENTION_STATES = [
|
|
123
|
+
'lapsed',
|
|
124
|
+
'disconnected',
|
|
125
|
+
'stale',
|
|
126
|
+
];
|
|
127
|
+
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
|
128
|
+
class CreateAgencyOrganizationDto {
|
|
129
|
+
constructor(data) {
|
|
130
|
+
this.name = data.name;
|
|
131
|
+
this.website = data.website;
|
|
132
|
+
this.registryId = data.registryId;
|
|
133
|
+
this.registryCountry = data.registryCountry;
|
|
134
|
+
this.contactEmail = data.contactEmail;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
exports.CreateAgencyOrganizationDto = CreateAgencyOrganizationDto;
|
|
138
|
+
class InviteAgencyMemberDto {
|
|
139
|
+
constructor(data) {
|
|
140
|
+
this.email = data.email;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
exports.InviteAgencyMemberDto = InviteAgencyMemberDto;
|
|
144
|
+
class RedeemAgencyInviteDto {
|
|
145
|
+
constructor(data) {
|
|
146
|
+
this.token = data.token;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
exports.RedeemAgencyInviteDto = RedeemAgencyInviteDto;
|
|
150
|
+
class AcceptAgencyMemberInviteDto {
|
|
151
|
+
constructor(data) {
|
|
152
|
+
this.token = data.token;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
exports.AcceptAgencyMemberInviteDto = AcceptAgencyMemberInviteDto;
|
|
156
|
+
/**
|
|
157
|
+
* Accepting consent. Either arm identifies the grant being answered; `scopes`
|
|
158
|
+
* is the creator's answer and defaults to the requested set when absent.
|
|
159
|
+
*/
|
|
160
|
+
class AcceptGrantDto {
|
|
161
|
+
constructor(data) {
|
|
162
|
+
this.token = data.token;
|
|
163
|
+
this.grantId = data.grantId;
|
|
164
|
+
this.scopes = data.scopes;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
exports.AcceptGrantDto = AcceptGrantDto;
|
|
168
|
+
/** The creator narrowing or widening a live grant — the only other write path. */
|
|
169
|
+
class UpdateGrantScopesDto {
|
|
170
|
+
constructor(data) {
|
|
171
|
+
this.scopes = data.scopes;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
exports.UpdateGrantScopesDto = UpdateGrantScopesDto;
|
|
175
|
+
class SponsorCreatorDto {
|
|
176
|
+
constructor(data) {
|
|
177
|
+
this.tier = data.tier;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
exports.SponsorCreatorDto = SponsorCreatorDto;
|
|
181
|
+
class AgencyDiscoverQueryDto {
|
|
182
|
+
constructor(data) {
|
|
183
|
+
this.q = data.q;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
exports.AgencyDiscoverQueryDto = AgencyDiscoverQueryDto;
|
|
187
|
+
class AgencyAccessRequestDto {
|
|
188
|
+
constructor(data) {
|
|
189
|
+
this.userId = data.userId;
|
|
190
|
+
this.scopes = data.scopes;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
exports.AgencyAccessRequestDto = AgencyAccessRequestDto;
|
|
194
|
+
class AdminSetOrganizationStatusDto {
|
|
195
|
+
constructor(data) {
|
|
196
|
+
this.status = data.status;
|
|
197
|
+
this.reason = data.reason;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
exports.AdminSetOrganizationStatusDto = AdminSetOrganizationStatusDto;
|
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";
|
|
@@ -36,4 +37,5 @@ export * from "./I_Publish";
|
|
|
36
37
|
export * from "./I_SM_Platform";
|
|
37
38
|
export * from "./scheduling";
|
|
38
39
|
export * from "./agent";
|
|
40
|
+
export * from "./agency";
|
|
39
41
|
export * from "./I_AccessDenial";
|
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);
|
|
@@ -54,5 +55,7 @@ __exportStar(require("./I_SM_Platform"), exports);
|
|
|
54
55
|
__exportStar(require("./scheduling"), exports);
|
|
55
56
|
// ── Agent domain (delegated authority + direct media upload, spec 163) ───────
|
|
56
57
|
__exportStar(require("./agent"), exports);
|
|
58
|
+
// ── Agency domain (organizations, memberships, creator access grants, spec 175)
|
|
59
|
+
__exportStar(require("./agency"), exports);
|
|
57
60
|
// ── Access control ────────────────────────────────────────────────────────────
|
|
58
61
|
__exportStar(require("./I_AccessDenial"), exports);
|
package/package.json
CHANGED
|
@@ -154,8 +154,62 @@ 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]: {},
|
|
162
|
+
// 175 — the agency set. Every witness carries organization_id + actor_role,
|
|
163
|
+
// because those are the two mandatory fields of AgencyEventProperties.
|
|
164
|
+
[EVENTS.AGENCY_SIGNUP_SUBMITTED]: { organization_id: "org-1", actor_role: "owner" },
|
|
165
|
+
[EVENTS.AGENCY_APPROVED]: { organization_id: "org-1", actor_role: "admin" },
|
|
166
|
+
[EVENTS.AGENCY_REJECTED]: { organization_id: "org-1", actor_role: "admin" },
|
|
167
|
+
[EVENTS.AGENCY_MEMBER_INVITED]: { organization_id: "org-1", actor_role: "owner" },
|
|
168
|
+
[EVENTS.AGENCY_INVITE_LINK_ROTATED]: { organization_id: "org-1", actor_role: "owner" },
|
|
169
|
+
[EVENTS.AGENCY_INVITE_OPENED]: { organization_id: "org-1", actor_role: "creator", origin: "invite_link" },
|
|
170
|
+
[EVENTS.AGENCY_CONSENT_ACCEPTED]: {
|
|
171
|
+
organization_id: "org-1",
|
|
172
|
+
grant_id: "g-1",
|
|
173
|
+
actor_role: "creator",
|
|
174
|
+
terms_version: "draft-2026-09-21",
|
|
175
|
+
},
|
|
176
|
+
[EVENTS.AGENCY_CONSENT_DECLINED]: { organization_id: "org-1", grant_id: "g-1", actor_role: "creator" },
|
|
177
|
+
[EVENTS.AGENCY_GRANT_REVOKED]: { organization_id: "org-1", grant_id: "g-1", actor_role: "creator" },
|
|
178
|
+
[EVENTS.AGENCY_ROSTER_VIEWED]: { organization_id: "org-1", actor_role: "member" },
|
|
179
|
+
[EVENTS.AGENCY_CREATOR_VIEWED]: {
|
|
180
|
+
organization_id: "org-1",
|
|
181
|
+
grant_id: "g-1",
|
|
182
|
+
actor_role: "member",
|
|
183
|
+
creator_user_id: "u-1",
|
|
184
|
+
},
|
|
185
|
+
[EVENTS.AGENCY_SPONSORSHIP_STARTED]: {
|
|
186
|
+
organization_id: "org-1",
|
|
187
|
+
grant_id: "g-1",
|
|
188
|
+
actor_role: "owner",
|
|
189
|
+
tier: "pro",
|
|
190
|
+
},
|
|
191
|
+
[EVENTS.AGENCY_SPONSORSHIP_ENDED]: { organization_id: "org-1", grant_id: "g-1", actor_role: "system" },
|
|
192
|
+
[EVENTS.AGENCY_DISCOVER_SEARCHED]: { organization_id: "org-1", actor_role: "member", matched: false },
|
|
193
|
+
[EVENTS.AGENCY_ACCESS_REQUESTED]: {
|
|
194
|
+
organization_id: "org-1",
|
|
195
|
+
grant_id: "g-1",
|
|
196
|
+
actor_role: "member",
|
|
197
|
+
origin: "agency_request",
|
|
198
|
+
},
|
|
157
199
|
};
|
|
158
200
|
|
|
201
|
+
test("no agency event may carry the discover query — only whether it matched (FR-015)", () => {
|
|
202
|
+
const agencyEvents = Object.values(EVENTS).filter((name) => name.startsWith("agency_"));
|
|
203
|
+
assert.equal(agencyEvents.length, 15, "the 175 set is 15 events");
|
|
204
|
+
for (const name of agencyEvents) {
|
|
205
|
+
const props = EVENT_PROPERTY_WITNESS[name as keyof typeof EVENT_PROPERTY_WITNESS];
|
|
206
|
+
for (const key of Object.keys(props)) {
|
|
207
|
+
assert.notEqual(key, "q", `${name} must never carry the search term`);
|
|
208
|
+
assert.notEqual(key, "query", `${name} must never carry the search term`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
159
213
|
test("EVENTS values are 1:1 with EventProperties keys (no missing or typo'd event)", () => {
|
|
160
214
|
const eventValues = Object.values(EVENTS).sort();
|
|
161
215
|
const propertyKeys = Object.keys(EVENT_PROPERTY_WITNESS).sort();
|
package/src/analytics/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { AccessDenialCode } from "../types/I_AccessDenial";
|
|
13
|
+
import type { SponsoredTier } from "../types/agency";
|
|
13
14
|
|
|
14
15
|
/** Canonical PostHog event names. */
|
|
15
16
|
export const EVENTS = {
|
|
@@ -43,6 +44,15 @@ export const EVENTS = {
|
|
|
43
44
|
|
|
44
45
|
// Activation (web app)
|
|
45
46
|
SIGNED_UP: 'signed_up', // server-owned (backend, on user creation)
|
|
47
|
+
// 171 (backend#307): the analytics answer itself, emitted by the BACKEND so
|
|
48
|
+
// it arrives for the people who declined — a decline recorded only by the
|
|
49
|
+
// browser that declined is a fact nobody can read. Carries the answer and
|
|
50
|
+
// nothing else.
|
|
51
|
+
ANALYTICS_CONSENT_RECORDED: 'analytics_consent_recorded', // server-owned (backend)
|
|
52
|
+
// 171: the first authenticated request after signup. Server-owned and
|
|
53
|
+
// consent-independent, in the same spirit as T046/T054 — it is what tells
|
|
54
|
+
// "used the product, refused tracking" apart from "never came back".
|
|
55
|
+
FIRST_AUTHENTICATED: 'first_authenticated', // server-owned (backend)
|
|
46
56
|
PROFILE_COMPLETED: 'profile_completed', // the /complete-profile step (influencers)
|
|
47
57
|
LOGGED_IN: 'logged_in',
|
|
48
58
|
SOCIAL_ACCOUNT_CONNECTED: 'social_account_connected',
|
|
@@ -158,6 +168,29 @@ export const EVENTS = {
|
|
|
158
168
|
NATIVE_SESSION_LAUNCHED: 'native_session_launched',
|
|
159
169
|
NATIVE_SESSION_RENEWED: 'native_session_renewed',
|
|
160
170
|
NATIVE_SESSION_ENDED: 'native_session_ended',
|
|
171
|
+
|
|
172
|
+
// Agency domain (175). Every one of these is captured under the acting
|
|
173
|
+
// person's own distinct id: agency-side events under the MEMBER's, with
|
|
174
|
+
// creator_user_id as a property, never under the creator's (audit F17) --
|
|
175
|
+
// otherwise an agency's activity would rewrite the creator's own timeline.
|
|
176
|
+
AGENCY_SIGNUP_SUBMITTED: 'agency_signup_submitted',
|
|
177
|
+
AGENCY_APPROVED: 'agency_approved', // server-owned (backend)
|
|
178
|
+
AGENCY_REJECTED: 'agency_rejected', // server-owned (backend)
|
|
179
|
+
AGENCY_MEMBER_INVITED: 'agency_member_invited',
|
|
180
|
+
AGENCY_INVITE_LINK_ROTATED: 'agency_invite_link_rotated',
|
|
181
|
+
AGENCY_INVITE_OPENED: 'agency_invite_opened',
|
|
182
|
+
AGENCY_CONSENT_ACCEPTED: 'agency_consent_accepted',
|
|
183
|
+
AGENCY_CONSENT_DECLINED: 'agency_consent_declined',
|
|
184
|
+
AGENCY_GRANT_REVOKED: 'agency_grant_revoked',
|
|
185
|
+
AGENCY_ROSTER_VIEWED: 'agency_roster_viewed',
|
|
186
|
+
AGENCY_CREATOR_VIEWED: 'agency_creator_viewed',
|
|
187
|
+
AGENCY_SPONSORSHIP_STARTED: 'agency_sponsorship_started',
|
|
188
|
+
AGENCY_SPONSORSHIP_ENDED: 'agency_sponsorship_ended',
|
|
189
|
+
// Carries `matched` and NEVER the query (FR-015, audit F17): the search term
|
|
190
|
+
// is the one field that would turn product analytics into a record of which
|
|
191
|
+
// creators an agency went looking for.
|
|
192
|
+
AGENCY_DISCOVER_SEARCHED: 'agency_discover_searched',
|
|
193
|
+
AGENCY_ACCESS_REQUESTED: 'agency_access_requested',
|
|
161
194
|
} as const;
|
|
162
195
|
|
|
163
196
|
export type EventName = (typeof EVENTS)[keyof typeof EVENTS];
|
|
@@ -371,6 +404,29 @@ export type PublishFailureReason =
|
|
|
371
404
|
| 'unknown'; // no error detail or unclassifiable
|
|
372
405
|
|
|
373
406
|
/** Property shape per event. Keeps emitters honest across repos. */
|
|
407
|
+
/**
|
|
408
|
+
* The shared shape every agency event carries (spec 175, contract lib-types.md).
|
|
409
|
+
*
|
|
410
|
+
* `organization_id` is mandatory because every agency event is an act BY an
|
|
411
|
+
* organization — an agency event without one cannot be attributed, and the
|
|
412
|
+
* whole point of the set is reading adoption per agency after launch (FR-018).
|
|
413
|
+
*/
|
|
414
|
+
export interface AgencyEventProperties {
|
|
415
|
+
organization_id: string;
|
|
416
|
+
/** Present once a specific consent is the subject of the act. */
|
|
417
|
+
grant_id?: string;
|
|
418
|
+
actor_role: 'owner' | 'member' | 'creator' | 'admin' | 'system';
|
|
419
|
+
origin?: 'invite_link' | 'agency_request';
|
|
420
|
+
terms_version?: string;
|
|
421
|
+
tier?: SponsoredTier;
|
|
422
|
+
/**
|
|
423
|
+
* The creator the act concerns, as a PROPERTY. Never the distinct id an
|
|
424
|
+
* agency-side event is captured under (audit F17): an agency browsing its
|
|
425
|
+
* roster must not write events into its creators' own timelines.
|
|
426
|
+
*/
|
|
427
|
+
creator_user_id?: string;
|
|
428
|
+
}
|
|
429
|
+
|
|
374
430
|
export interface EventProperties {
|
|
375
431
|
[EVENTS.LANDING_CTA_CLICKED]: {
|
|
376
432
|
location:
|
|
@@ -405,6 +461,8 @@ export interface EventProperties {
|
|
|
405
461
|
[EVENTS.PLAYBOOK_OPENED]: { edition_version?: string };
|
|
406
462
|
[EVENTS.PLAYBOOK_DOWNLOADED]: { edition_version?: string; format?: string };
|
|
407
463
|
[EVENTS.SIGNED_UP]: { method: 'email' | 'google'; role: string };
|
|
464
|
+
[EVENTS.ANALYTICS_CONSENT_RECORDED]: { state: 'granted' | 'declined' | 'unset' };
|
|
465
|
+
[EVENTS.FIRST_AUTHENTICATED]: Record<string, never>;
|
|
408
466
|
[EVENTS.PROFILE_COMPLETED]: { role: string };
|
|
409
467
|
// Spec 161: widened to include 'apple' — the value was already missing from
|
|
410
468
|
// this union (Google/email only) even though native Apple sign-in has
|
|
@@ -596,6 +654,27 @@ export interface EventProperties {
|
|
|
596
654
|
[EVENTS.NATIVE_SESSION_LAUNCHED]: { outcome: 'restored' | 'none' | 'degraded' };
|
|
597
655
|
[EVENTS.NATIVE_SESSION_RENEWED]: Record<string, never>;
|
|
598
656
|
[EVENTS.NATIVE_SESSION_ENDED]: { cause: 'rejected' | 'sign_out' };
|
|
657
|
+
|
|
658
|
+
// Agency domain (175, contract lib-types.md). One shared shape: who acted
|
|
659
|
+
// (`actor_role`), on whose behalf (`organization_id`) and against which
|
|
660
|
+
// consent (`grant_id`). `creator_user_id` is a PROPERTY of an agency-side
|
|
661
|
+
// event, never the distinct id it is captured under (audit F17).
|
|
662
|
+
[EVENTS.AGENCY_SIGNUP_SUBMITTED]: AgencyEventProperties;
|
|
663
|
+
[EVENTS.AGENCY_APPROVED]: AgencyEventProperties;
|
|
664
|
+
[EVENTS.AGENCY_REJECTED]: AgencyEventProperties;
|
|
665
|
+
[EVENTS.AGENCY_MEMBER_INVITED]: AgencyEventProperties;
|
|
666
|
+
[EVENTS.AGENCY_INVITE_LINK_ROTATED]: AgencyEventProperties;
|
|
667
|
+
[EVENTS.AGENCY_INVITE_OPENED]: AgencyEventProperties;
|
|
668
|
+
[EVENTS.AGENCY_CONSENT_ACCEPTED]: AgencyEventProperties;
|
|
669
|
+
[EVENTS.AGENCY_CONSENT_DECLINED]: AgencyEventProperties;
|
|
670
|
+
[EVENTS.AGENCY_GRANT_REVOKED]: AgencyEventProperties;
|
|
671
|
+
[EVENTS.AGENCY_ROSTER_VIEWED]: AgencyEventProperties;
|
|
672
|
+
[EVENTS.AGENCY_CREATOR_VIEWED]: AgencyEventProperties;
|
|
673
|
+
[EVENTS.AGENCY_SPONSORSHIP_STARTED]: AgencyEventProperties;
|
|
674
|
+
[EVENTS.AGENCY_SPONSORSHIP_ENDED]: AgencyEventProperties;
|
|
675
|
+
// `matched` only. The query itself is never sent (FR-015).
|
|
676
|
+
[EVENTS.AGENCY_DISCOVER_SEARCHED]: AgencyEventProperties & { matched: boolean };
|
|
677
|
+
[EVENTS.AGENCY_ACCESS_REQUESTED]: AgencyEventProperties;
|
|
599
678
|
}
|
|
600
679
|
|
|
601
680
|
/** Canonical person property keys (set via identify / $set). */
|
|
@@ -24,6 +24,64 @@ export const ACCESS_DENIAL_CODES = {
|
|
|
24
24
|
* for this one and do NOT redirect.
|
|
25
25
|
*/
|
|
26
26
|
ENTITLEMENT_PRECONDITION: "entitlement-precondition",
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The caller is not an admin.
|
|
30
|
+
*
|
|
31
|
+
* `RoleGuard` answered a 401 with Polish prose until 2026-09-21, which the
|
|
32
|
+
* frontend's session handling reads as an expired session — so a non-admin
|
|
33
|
+
* who opened an admin screen could be signed out rather than refused. A 403
|
|
34
|
+
* with a code says "you may not", which is the true statement.
|
|
35
|
+
*/
|
|
36
|
+
ADMIN_REQUIRED: "admin-required",
|
|
37
|
+
|
|
38
|
+
// ── Agency domain (spec 175) ──────────────────────────────────────────────
|
|
39
|
+
/** The caller holds no accepted membership in any organization. */
|
|
40
|
+
AGENCY_NOT_MEMBER: "agency-not-member",
|
|
41
|
+
/** Their organization is still awaiting a human decision (FR-002). */
|
|
42
|
+
AGENCY_PENDING: "agency-pending",
|
|
43
|
+
/** Their organization was rejected; the reason travels in the body. */
|
|
44
|
+
AGENCY_REJECTED: "agency-rejected",
|
|
45
|
+
/** No live subscription: the roster is dark until the plan is paid (FR-013a). */
|
|
46
|
+
AGENCY_UNPAID: "agency-unpaid",
|
|
47
|
+
/**
|
|
48
|
+
* ONE code for EVERY "cannot see this creator" case — no grant, another
|
|
49
|
+
* organization's creator, a non-existent id, a non-discoverable account.
|
|
50
|
+
*
|
|
51
|
+
* The single code IS the privacy control (FR-011, SC-005, audit F6). Distinct
|
|
52
|
+
* codes would let an agency learn that an account exists by the shape of its
|
|
53
|
+
* refusal, which is the same existence oracle the discover query is
|
|
54
|
+
* structurally built to avoid.
|
|
55
|
+
*/
|
|
56
|
+
AGENCY_NO_ACCESS: "agency-no-access",
|
|
57
|
+
/**
|
|
58
|
+
* The grant exists but does not carry the scope this read needs.
|
|
59
|
+
*
|
|
60
|
+
* Returned ONLY for a creator the caller already holds on the roster — for
|
|
61
|
+
* anyone else it would prove a grant exists, so they get AGENCY_NO_ACCESS
|
|
62
|
+
* instead (SC-012).
|
|
63
|
+
*/
|
|
64
|
+
AGENCY_SCOPE_NOT_GRANTED: "agency-scope-not-granted",
|
|
65
|
+
/** The consent screen is off in this environment (FR-020, the legal gate). */
|
|
66
|
+
AGENCY_CONSENT_DISABLED: "agency-consent-disabled",
|
|
67
|
+
/** The roster invite link expired, was rotated, or its organization is not approved. */
|
|
68
|
+
AGENCY_LINK_INVALID: "agency-link-invalid",
|
|
69
|
+
/** An admin may not decide on an organization they belong to (FR-017). */
|
|
70
|
+
AGENCY_ADMIN_SELF_APPROVAL: "agency-admin-self-approval",
|
|
71
|
+
/**
|
|
72
|
+
* A sole owner tried to leave or delete their account. Refused until they
|
|
73
|
+
* hand ownership over or close the organization explicitly (FR-004): closing
|
|
74
|
+
* carries a fan-out that cancels a subscription and ends sponsorships, and
|
|
75
|
+
* that must be a decision rather than a side effect.
|
|
76
|
+
*/
|
|
77
|
+
AGENCY_SOLE_OWNER_BLOCKED: "agency-sole-owner-blocked",
|
|
78
|
+
/** A member invitation token that is expired, already used, or revoked (FR-005). */
|
|
79
|
+
AGENCY_MEMBER_INVITE_INVALID: "agency-member-invite-invalid",
|
|
80
|
+
/**
|
|
81
|
+
* The invited or accepting person already belongs to an organization (one
|
|
82
|
+
* per person, FR-004). Names the case and NOTHING else about that account.
|
|
83
|
+
*/
|
|
84
|
+
AGENCY_ALREADY_IN_ORGANIZATION: "agency-already-in-organization",
|
|
27
85
|
} as const;
|
|
28
86
|
|
|
29
87
|
export type AccessDenialCode =
|
|
@@ -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
|
+
}
|
|
@@ -60,11 +60,35 @@ test('precondition is optional, so an older backend still sends a valid body', (
|
|
|
60
60
|
assert.equal(withIt.precondition, 'no-connection');
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
test('the denial codes
|
|
64
|
-
// FR-003
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
test('the three original denial codes keep their exact values', () => {
|
|
64
|
+
// Spec 170 FR-003 locked these because that feature added a FIELD and must
|
|
65
|
+
// not renegotiate `code`. The lock is on the VALUES, not on the set ever
|
|
66
|
+
// growing: these three strings are what shipped clients branch on, so a
|
|
67
|
+
// rename is a breaking change however harmless it looks in a diff.
|
|
68
|
+
assert.equal(ACCESS_DENIAL_CODES.BETA_REQUIRED, 'beta-required');
|
|
69
|
+
assert.equal(ACCESS_DENIAL_CODES.SUBSCRIPTION_REQUIRED, 'subscription-required');
|
|
70
|
+
assert.equal(ACCESS_DENIAL_CODES.ENTITLEMENT_PRECONDITION, 'entitlement-precondition');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('every code is a unique kebab-case string', () => {
|
|
74
|
+
const values = Object.values(ACCESS_DENIAL_CODES);
|
|
75
|
+
|
|
76
|
+
assert.equal(new Set(values).size, values.length, 'duplicate denial code');
|
|
77
|
+
for (const value of values) assert.match(value, /^[a-z]+(-[a-z]+)*$/, `not kebab-case: ${value}`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('every AGENCY code is namespaced, so a new domain cannot shadow an old wall', () => {
|
|
81
|
+
// Spec 175 added twelve. A prefix per domain is what keeps "cannot see this
|
|
82
|
+
// creator" from ever colliding with an entitlement wall a client already
|
|
83
|
+
// handles correctly — the two want completely different behaviour.
|
|
84
|
+
// `admin-required` is deliberately NOT agency-prefixed: RoleGuard is generic
|
|
85
|
+
// and guards the legacy marketplace controllers too.
|
|
86
|
+
const agencyCodes = Object.entries(ACCESS_DENIAL_CODES).filter(([key]) =>
|
|
87
|
+
key.startsWith('AGENCY_'),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
assert.equal(agencyCodes.length, 12);
|
|
91
|
+
for (const [, value] of agencyCodes) {
|
|
92
|
+
assert.match(value, /^agency-/, `un-namespaced agency code: ${value}`);
|
|
93
|
+
}
|
|
70
94
|
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AGENCY_GRANT_SCOPES,
|
|
6
|
+
AGENCY_ORGANIZATION_STATUSES,
|
|
7
|
+
CREATOR_ACCESS_GRANT_STATES,
|
|
8
|
+
AGENCY_ROSTER_ROW_STATES,
|
|
9
|
+
AGENCY_ROSTER_NEEDS_ATTENTION_STATES,
|
|
10
|
+
isAgencyGrantScope,
|
|
11
|
+
CreateAgencyOrganizationDto,
|
|
12
|
+
AgencyAccessRequestDto,
|
|
13
|
+
} from "./agency";
|
|
14
|
+
|
|
15
|
+
test("the grant scope set is exactly the three reads the spec allows", () => {
|
|
16
|
+
assert.deepEqual([...AGENCY_GRANT_SCOPES], ["analytics", "comments", "posts"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("isAgencyGrantScope fails closed on anything outside the set", () => {
|
|
20
|
+
for (const scope of AGENCY_GRANT_SCOPES) assert.equal(isAgencyGrantScope(scope), true);
|
|
21
|
+
// The refusals that matter: every one of these is a WRITE the grant must never reach
|
|
22
|
+
// (FR-010), and a scope string arriving from a request body is untrusted input.
|
|
23
|
+
for (const notAScope of ["messages", "dms", "publish", "reply", "moderate", "accounts", ""]) {
|
|
24
|
+
assert.equal(isAgencyGrantScope(notAScope), false, `must refuse: ${notAScope}`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("organization statuses carry `closed` — a closed organization's row is retained, never deleted", () => {
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
[...AGENCY_ORGANIZATION_STATUSES],
|
|
31
|
+
["pending", "approved", "rejected", "closed"],
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("grant states are the five the data model pins, including declined and expired", () => {
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
[...CREATOR_ACCESS_GRANT_STATES],
|
|
38
|
+
["requested", "active", "declined", "expired", "revoked"],
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("roster row states include `stale`, and `revoked` is NOT one of them", () => {
|
|
43
|
+
assert.deepEqual(
|
|
44
|
+
[...AGENCY_ROSTER_ROW_STATES],
|
|
45
|
+
["requested", "lapsed", "disconnected", "stale", "active"],
|
|
46
|
+
);
|
|
47
|
+
assert.equal(
|
|
48
|
+
(AGENCY_ROSTER_ROW_STATES as readonly string[]).includes("revoked"),
|
|
49
|
+
false,
|
|
50
|
+
"a revoked creator leaves the roster for the settings history (FR-011)",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("needs-attention is disconnected + lapsed + stale, and never `requested` (FR-011)", () => {
|
|
55
|
+
assert.deepEqual([...AGENCY_ROSTER_NEEDS_ATTENTION_STATES], ["lapsed", "disconnected", "stale"]);
|
|
56
|
+
assert.equal(
|
|
57
|
+
(AGENCY_ROSTER_NEEDS_ATTENTION_STATES as readonly string[]).includes("requested"),
|
|
58
|
+
false,
|
|
59
|
+
"a requested grant waits on the CREATOR, so it never lights the agency's badge",
|
|
60
|
+
);
|
|
61
|
+
// Every needs-attention state must be a real roster state, or the tile counts a
|
|
62
|
+
// state no row can ever hold.
|
|
63
|
+
for (const state of AGENCY_ROSTER_NEEDS_ATTENTION_STATES) {
|
|
64
|
+
assert.ok((AGENCY_ROSTER_ROW_STATES as readonly string[]).includes(state));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("DTOs construct from a plain object (the house `new I_DTO()` shape)", () => {
|
|
69
|
+
const org = new CreateAgencyOrganizationDto({
|
|
70
|
+
name: "Studio Kot",
|
|
71
|
+
registryId: "5252445767",
|
|
72
|
+
registryCountry: "PL",
|
|
73
|
+
contactEmail: "biuro@studiokot.pl",
|
|
74
|
+
});
|
|
75
|
+
assert.equal(org.name, "Studio Kot");
|
|
76
|
+
assert.equal(org.website, undefined);
|
|
77
|
+
|
|
78
|
+
const request = new AgencyAccessRequestDto({ userId: "u-1", scopes: ["analytics"] });
|
|
79
|
+
assert.deepEqual(request.scopes, ["analytics"]);
|
|
80
|
+
});
|