ad2app-lib 1.43.0 → 1.44.1

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,299 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ 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;
13
+ const class_validator_1 = require("class-validator");
14
+ // ── Cross-repo route constants ───────────────────────────────────────────────
15
+ /**
16
+ * Paths that BOTH repos have to agree on, with one home here.
17
+ *
18
+ * The backend mails this path; the frontend registers it. They deploy
19
+ * separately, so nothing else notices them drifting — and the first version of
20
+ * that link pointed at a route no repo served, which is how an entire user
21
+ * story shipped with its headline capability unreachable.
22
+ *
23
+ * A test in each repo asserts its own side against these constants. The first
24
+ * attempt at that guard read across the filesystem into a sibling checkout,
25
+ * which worked on one laptop and threw ENOENT in CI; a shared constant is the
26
+ * version that actually runs.
27
+ */
28
+ exports.AGENCY_ROUTE_PATHS = {
29
+ /** A colleague accepting a team invitation (FR-005). */
30
+ joinTeam: '/agency/join-team',
31
+ /** A creator consenting to an agency's access (FR-007). */
32
+ join: '/agency/join',
33
+ /** A creator answering an access request (FR-016). */
34
+ requests: '/agency/requests',
35
+ };
36
+ // ── Scopes ───────────────────────────────────────────────────────────────────
37
+ /**
38
+ * The complete set of reads a grant may carry. A CEILING, not a starting point.
39
+ *
40
+ * All three are reads of what the creator already sees on their own screens.
41
+ * Private messages, comment replies and moderation, publishing, connection
42
+ * changes and settings are absent by construction (FR-010) — there is no scope
43
+ * string that could name them, so a widened grant is a spec change rather than
44
+ * a config change.
45
+ */
46
+ exports.AGENCY_GRANT_SCOPES = [
47
+ /** the creator's own five analytics reads, full history, no date filtering */
48
+ 'analytics',
49
+ /** comments under the creator's posts, READ only — the one inbox read a grant reaches */
50
+ 'comments',
51
+ /** the creator's published posts with their own numbers — never drafts or the queue */
52
+ 'posts',
53
+ ];
54
+ /**
55
+ * Narrowing guard — an unrecognised scope must fail closed.
56
+ *
57
+ * Scope strings arrive from request bodies (the agency's ask, the creator's
58
+ * answer), so this is a trust boundary, not a convenience.
59
+ */
60
+ const isAgencyGrantScope = (value) => exports.AGENCY_GRANT_SCOPES.includes(value);
61
+ exports.isAgencyGrantScope = isAgencyGrantScope;
62
+ /**
63
+ * `closed` was added by the 2026-09-21 requirements review (FR-004b).
64
+ *
65
+ * It exists so closure has an end state that is NOT a deleted row: grants are
66
+ * `on delete cascade` from the organization, so hard-deleting one would destroy
67
+ * the consent records FR-009 says are never deleted — and which are also the
68
+ * invoice basis.
69
+ */
70
+ exports.AGENCY_ORGANIZATION_STATUSES = [
71
+ 'pending',
72
+ 'approved',
73
+ 'rejected',
74
+ 'closed',
75
+ ];
76
+ // ── Grant ────────────────────────────────────────────────────────────────────
77
+ /**
78
+ * The five states the data model pins.
79
+ *
80
+ * `declined` and `expired` belong to the US5 request path (FR-016) and were
81
+ * missing from the spec's own Key Entities until the 2026-09-21 review. There
82
+ * is no transition OUT of `revoked`, `declined` or `expired`: a new consent is
83
+ * a new row, so the record of what was agreed is never overwritten.
84
+ */
85
+ exports.CREATOR_ACCESS_GRANT_STATES = [
86
+ 'requested',
87
+ 'active',
88
+ 'declined',
89
+ 'expired',
90
+ 'revoked',
91
+ ];
92
+ // ── Roster ───────────────────────────────────────────────────────────────────
93
+ /**
94
+ * Roster row states, DECLARED IN PRECEDENCE ORDER (data-model §Derived).
95
+ *
96
+ * A row holds exactly one state: the first of these that applies, most-blocking
97
+ * first. `revoked` is deliberately absent — a revoked creator leaves the roster
98
+ * entirely for the settings history (FR-011).
99
+ */
100
+ exports.AGENCY_ROSTER_ROW_STATES = [
101
+ /** the agency asked, the creator has not answered */
102
+ 'requested',
103
+ /** the creator's own plan has lapsed */
104
+ 'lapsed',
105
+ /** the creator has no live platform connection */
106
+ 'disconnected',
107
+ /** no successful daily sync in 24 hours (FR-011, Jan 2026-09-21) */
108
+ 'stale',
109
+ 'active',
110
+ ];
111
+ /**
112
+ * What "needs attention" means — ONE home for the KPI tile, the roster segment
113
+ * and the rail badge, so the three can never drift apart (FR-011).
114
+ *
115
+ * `requested` is excluded on purpose: it waits on the CREATOR, and FR-004a says
116
+ * the badge means something waits on the agency.
117
+ */
118
+ exports.AGENCY_ROSTER_NEEDS_ATTENTION_STATES = [
119
+ 'lapsed',
120
+ 'disconnected',
121
+ 'stale',
122
+ ];
123
+ // ── DTOs ─────────────────────────────────────────────────────────────────────
124
+ /**
125
+ * Every DTO below carries class-validator decorators and a constructor that
126
+ * survives being called with nothing.
127
+ *
128
+ * Both are load-bearing and were missing until 2026-09-22. The backend mounts
129
+ * a global `ValidationPipe`, which validates the decorated metadata of the
130
+ * class a handler's `@Body()` is typed with — an undecorated class, or a body
131
+ * typed as a plain interface, is simply passed through unchecked. And the pipe
132
+ * instantiates the class with NO arguments, so a constructor that reads
133
+ * `data.name` would throw a TypeError on every request rather than validate
134
+ * anything. The rest of this library's DTOs take `data?: Partial<T>` for that
135
+ * reason; these now do too.
136
+ */
137
+ class CreateAgencyOrganizationDto {
138
+ constructor(data) {
139
+ this.name = data?.name ?? '';
140
+ this.website = data?.website;
141
+ this.registryId = data?.registryId ?? '';
142
+ this.registryCountry = data?.registryCountry ?? '';
143
+ this.contactEmail = data?.contactEmail ?? '';
144
+ }
145
+ }
146
+ exports.CreateAgencyOrganizationDto = CreateAgencyOrganizationDto;
147
+ __decorate([
148
+ (0, class_validator_1.IsString)(),
149
+ (0, class_validator_1.IsNotEmpty)(),
150
+ __metadata("design:type", String)
151
+ ], CreateAgencyOrganizationDto.prototype, "name", void 0);
152
+ __decorate([
153
+ (0, class_validator_1.IsOptional)(),
154
+ (0, class_validator_1.IsUrl)({ protocols: ['http', 'https'], require_protocol: true }),
155
+ __metadata("design:type", String)
156
+ ], CreateAgencyOrganizationDto.prototype, "website", void 0);
157
+ __decorate([
158
+ (0, class_validator_1.IsString)(),
159
+ (0, class_validator_1.IsNotEmpty)(),
160
+ __metadata("design:type", String)
161
+ ], CreateAgencyOrganizationDto.prototype, "registryId", void 0);
162
+ __decorate([
163
+ (0, class_validator_1.IsString)(),
164
+ (0, class_validator_1.Length)(2, 2),
165
+ __metadata("design:type", String)
166
+ ], CreateAgencyOrganizationDto.prototype, "registryCountry", void 0);
167
+ __decorate([
168
+ (0, class_validator_1.IsEmail)(),
169
+ __metadata("design:type", String)
170
+ ], CreateAgencyOrganizationDto.prototype, "contactEmail", void 0);
171
+ class InviteAgencyMemberDto {
172
+ constructor(data) {
173
+ this.email = data?.email ?? '';
174
+ }
175
+ }
176
+ exports.InviteAgencyMemberDto = InviteAgencyMemberDto;
177
+ __decorate([
178
+ (0, class_validator_1.IsEmail)(),
179
+ __metadata("design:type", String)
180
+ ], InviteAgencyMemberDto.prototype, "email", void 0);
181
+ class RedeemAgencyInviteDto {
182
+ constructor(data) {
183
+ this.token = data?.token ?? '';
184
+ }
185
+ }
186
+ exports.RedeemAgencyInviteDto = RedeemAgencyInviteDto;
187
+ __decorate([
188
+ (0, class_validator_1.IsString)(),
189
+ (0, class_validator_1.IsNotEmpty)(),
190
+ __metadata("design:type", String)
191
+ ], RedeemAgencyInviteDto.prototype, "token", void 0);
192
+ class AcceptAgencyMemberInviteDto {
193
+ constructor(data) {
194
+ this.token = data?.token ?? '';
195
+ }
196
+ }
197
+ exports.AcceptAgencyMemberInviteDto = AcceptAgencyMemberInviteDto;
198
+ __decorate([
199
+ (0, class_validator_1.IsString)(),
200
+ (0, class_validator_1.IsNotEmpty)(),
201
+ __metadata("design:type", String)
202
+ ], AcceptAgencyMemberInviteDto.prototype, "token", void 0);
203
+ /**
204
+ * Accepting consent. Either arm identifies the grant being answered; `scopes`
205
+ * is the creator's answer and defaults to the requested set when absent.
206
+ */
207
+ class AcceptGrantDto {
208
+ constructor(data) {
209
+ this.token = data?.token;
210
+ this.grantId = data?.grantId;
211
+ this.scopes = data?.scopes;
212
+ }
213
+ }
214
+ exports.AcceptGrantDto = AcceptGrantDto;
215
+ __decorate([
216
+ (0, class_validator_1.IsOptional)(),
217
+ (0, class_validator_1.IsString)(),
218
+ (0, class_validator_1.IsNotEmpty)(),
219
+ __metadata("design:type", String)
220
+ ], AcceptGrantDto.prototype, "token", void 0);
221
+ __decorate([
222
+ (0, class_validator_1.IsOptional)(),
223
+ (0, class_validator_1.IsUUID)(),
224
+ __metadata("design:type", String)
225
+ ], AcceptGrantDto.prototype, "grantId", void 0);
226
+ __decorate([
227
+ (0, class_validator_1.IsOptional)(),
228
+ (0, class_validator_1.IsArray)(),
229
+ (0, class_validator_1.ArrayNotEmpty)(),
230
+ (0, class_validator_1.IsIn)(exports.AGENCY_GRANT_SCOPES, { each: true }),
231
+ __metadata("design:type", Array)
232
+ ], AcceptGrantDto.prototype, "scopes", void 0);
233
+ /** The creator narrowing or widening a live grant — the only other write path. */
234
+ class UpdateGrantScopesDto {
235
+ constructor(data) {
236
+ this.scopes = data?.scopes ?? [];
237
+ }
238
+ }
239
+ exports.UpdateGrantScopesDto = UpdateGrantScopesDto;
240
+ __decorate([
241
+ (0, class_validator_1.IsArray)(),
242
+ (0, class_validator_1.ArrayNotEmpty)(),
243
+ (0, class_validator_1.IsIn)(exports.AGENCY_GRANT_SCOPES, { each: true }),
244
+ __metadata("design:type", Array)
245
+ ], UpdateGrantScopesDto.prototype, "scopes", void 0);
246
+ class SponsorCreatorDto {
247
+ constructor(data) {
248
+ this.tier = data?.tier ?? 'starter';
249
+ }
250
+ }
251
+ exports.SponsorCreatorDto = SponsorCreatorDto;
252
+ __decorate([
253
+ (0, class_validator_1.IsIn)(['starter', 'pro']),
254
+ __metadata("design:type", String)
255
+ ], SponsorCreatorDto.prototype, "tier", void 0);
256
+ class AgencyDiscoverQueryDto {
257
+ constructor(data) {
258
+ this.q = data?.q ?? '';
259
+ }
260
+ }
261
+ exports.AgencyDiscoverQueryDto = AgencyDiscoverQueryDto;
262
+ __decorate([
263
+ (0, class_validator_1.IsString)(),
264
+ (0, class_validator_1.IsNotEmpty)(),
265
+ __metadata("design:type", String)
266
+ ], AgencyDiscoverQueryDto.prototype, "q", void 0);
267
+ class AgencyAccessRequestDto {
268
+ constructor(data) {
269
+ this.userId = data?.userId ?? '';
270
+ this.scopes = data?.scopes ?? [];
271
+ }
272
+ }
273
+ exports.AgencyAccessRequestDto = AgencyAccessRequestDto;
274
+ __decorate([
275
+ (0, class_validator_1.IsUUID)(),
276
+ __metadata("design:type", String)
277
+ ], AgencyAccessRequestDto.prototype, "userId", void 0);
278
+ __decorate([
279
+ (0, class_validator_1.IsArray)(),
280
+ (0, class_validator_1.ArrayNotEmpty)(),
281
+ (0, class_validator_1.IsIn)(exports.AGENCY_GRANT_SCOPES, { each: true }),
282
+ __metadata("design:type", Array)
283
+ ], AgencyAccessRequestDto.prototype, "scopes", void 0);
284
+ class AdminSetOrganizationStatusDto {
285
+ constructor(data) {
286
+ this.status = data?.status ?? 'approved';
287
+ this.reason = data?.reason;
288
+ }
289
+ }
290
+ exports.AdminSetOrganizationStatusDto = AdminSetOrganizationStatusDto;
291
+ __decorate([
292
+ (0, class_validator_1.IsIn)(['approved', 'rejected']),
293
+ __metadata("design:type", Object)
294
+ ], AdminSetOrganizationStatusDto.prototype, "status", void 0);
295
+ __decorate([
296
+ (0, class_validator_1.IsOptional)(),
297
+ (0, class_validator_1.IsString)(),
298
+ __metadata("design:type", String)
299
+ ], AdminSetOrganizationStatusDto.prototype, "reason", void 0);
@@ -37,4 +37,5 @@ export * from "./I_Publish";
37
37
  export * from "./I_SM_Platform";
38
38
  export * from "./scheduling";
39
39
  export * from "./agent";
40
+ export * from "./agency";
40
41
  export * from "./I_AccessDenial";
@@ -55,5 +55,7 @@ __exportStar(require("./I_SM_Platform"), exports);
55
55
  __exportStar(require("./scheduling"), exports);
56
56
  // ── Agent domain (delegated authority + direct media upload, spec 163) ───────
57
57
  __exportStar(require("./agent"), exports);
58
+ // ── Agency domain (organizations, memberships, creator access grants, spec 175)
59
+ __exportStar(require("./agency"), exports);
58
60
  // ── Access control ────────────────────────────────────────────────────────────
59
61
  __exportStar(require("./I_AccessDenial"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.43.0",
3
+ "version": "1.44.1",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -51,7 +51,7 @@
51
51
  "prepare": "npm run build"
52
52
  },
53
53
  "keywords": [],
54
- "author": "Maciej Górski@ad2.app",
54
+ "author": "Maciej G\u00f3rski@ad2.app",
55
55
  "license": "ISC",
56
56
  "description": "Package to share types and utils across the ad2app projects",
57
57
  "dependencies": {
@@ -159,8 +159,57 @@ const EVENT_PROPERTY_WITNESS: { [E in keyof EventProperties]: EventProperties[E]
159
159
  // the browser that declined is a fact nobody can read.
160
160
  [EVENTS.ANALYTICS_CONSENT_RECORDED]: { state: "declined" },
161
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
+ },
162
199
  };
163
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
+
164
213
  test("EVENTS values are 1:1 with EventProperties keys (no missing or typo'd event)", () => {
165
214
  const eventValues = Object.values(EVENTS).sort();
166
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 = {
@@ -167,6 +168,29 @@ export const EVENTS = {
167
168
  NATIVE_SESSION_LAUNCHED: 'native_session_launched',
168
169
  NATIVE_SESSION_RENEWED: 'native_session_renewed',
169
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',
170
194
  } as const;
171
195
 
172
196
  export type EventName = (typeof EVENTS)[keyof typeof EVENTS];
@@ -380,6 +404,29 @@ export type PublishFailureReason =
380
404
  | 'unknown'; // no error detail or unclassifiable
381
405
 
382
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
+
383
430
  export interface EventProperties {
384
431
  [EVENTS.LANDING_CTA_CLICKED]: {
385
432
  location:
@@ -607,6 +654,27 @@ export interface EventProperties {
607
654
  [EVENTS.NATIVE_SESSION_LAUNCHED]: { outcome: 'restored' | 'none' | 'degraded' };
608
655
  [EVENTS.NATIVE_SESSION_RENEWED]: Record<string, never>;
609
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;
610
678
  }
611
679
 
612
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 =
@@ -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
  });