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.
@@ -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;
@@ -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";
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.42.0",
3
+ "version": "1.44.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -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();
@@ -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 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
- });
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
+ });