@withpica/mcp-sdk 3.23.1 → 3.25.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/index.js CHANGED
@@ -1,5 +1,10 @@
1
1
  // Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
+ /**
4
+ * Lightweight PICA SDK client for MCP server
5
+ * Uses the PICA API directly without external dependencies
6
+ */
7
+ import { AsyncLocalStorage } from "node:async_hooks";
3
8
  // --- Request infrastructure ---
4
9
  const DEFAULT_TIMEOUT_MS = 30_000;
5
10
  const LONG_TIMEOUT_MS = 120_000;
@@ -32,6 +37,98 @@ export class ApiError extends Error {
32
37
  this.name = "ApiError";
33
38
  }
34
39
  }
40
+ /**
41
+ * ADR-333 D7 — acting for a client catalogue, one call at a time.
42
+ *
43
+ * An organisation's assistant names the client catalogue on each tool call
44
+ * (the optional `catalogue` argument). The MCP server runs that call inside
45
+ * `withCatalogue(id, …)`, and every request the call makes carries
46
+ * `x-pica-catalogue: <id>`. The app resolves it through the relationship on
47
+ * every request (PR 1.3) and answers with `x-pica-catalogue-resolved: <id>`.
48
+ *
49
+ * A per-call context, not a field on the client: the stdio server shares one
50
+ * client across concurrent tool calls, so a field would leak one call's
51
+ * catalogue into another's requests.
52
+ *
53
+ * Fails closed: a 2xx answer without a matching echo means the route did not
54
+ * act for that catalogue (it may have used the caller's own), so the call
55
+ * throws `CatalogueNotHonouredError` instead of reporting success. This is the
56
+ * client's check only; stopping such a route from writing at all is the app's
57
+ * job (PR 1.3 refuses a catalogue header it did not resolve).
58
+ */
59
+ export const CATALOGUE_HEADER = "x-pica-catalogue";
60
+ export const CATALOGUE_RESOLVED_HEADER = "x-pica-catalogue-resolved";
61
+ const catalogueContext = new AsyncLocalStorage();
62
+ /** Run `fn` with every SDK request in it acting for `catalogueId`. */
63
+ export function withCatalogue(catalogueId, fn) {
64
+ return catalogueId
65
+ ? runInCatalogue(catalogueId, fn).then((r) => r.value)
66
+ : fn();
67
+ }
68
+ /**
69
+ * `withCatalogue`, also returning the call's report. The report is the one
70
+ * the call's requests wrote to; it is complete once `fn` settles.
71
+ */
72
+ export async function runInCatalogue(catalogueId, fn) {
73
+ const report = {
74
+ catalogueId,
75
+ answered: 0,
76
+ refused: [],
77
+ };
78
+ const value = await catalogueContext.run(report, fn);
79
+ return { value, report };
80
+ }
81
+ /** The catalogue the current call acts for, if any. */
82
+ export function currentCatalogue() {
83
+ return catalogueContext.getStore()?.catalogueId;
84
+ }
85
+ /**
86
+ * Run `fn` in the current client catalogue (its requests still name it), but
87
+ * leave its requests off the current call's report: for a check PICA makes
88
+ * for itself (the billing gate reading the client's plan), which must not
89
+ * read as the tool's own work having been answered (the MCP server judges a
90
+ * call "every part refused" by that report).
91
+ */
92
+ export function asideFromReport(fn) {
93
+ const current = catalogueContext.getStore();
94
+ if (!current)
95
+ return fn();
96
+ return catalogueContext.run({ catalogueId: current.catalogueId, answered: 0, refused: [] }, fn);
97
+ }
98
+ /**
99
+ * Record a relationship refusal on the current call's report (no-op outside
100
+ * one). Every client-catalogue refusal, from the proxy or a route, answers
101
+ * `{ success: false, error: { code, message, details: { reason_code } } }`
102
+ * (catalogueRefusalBody, lib/auth/relationship-refused-error.ts; pinned by
103
+ * lib/auth/__tests__/catalogue-refusal-shape.test.ts).
104
+ */
105
+ function recordCatalogueRefusal(url, body) {
106
+ const report = catalogueContext.getStore();
107
+ if (!report)
108
+ return;
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(body);
112
+ }
113
+ catch {
114
+ return;
115
+ }
116
+ if (parsed?.error?.code !== "RELATIONSHIP_REFUSED")
117
+ return;
118
+ const reason = parsed.error.details?.reason_code;
119
+ report.refused.push({
120
+ path: new URL(url).pathname,
121
+ reason: typeof reason === "string" ? reason : null,
122
+ });
123
+ }
124
+ export class CatalogueNotHonouredError extends ApiError {
125
+ catalogueId;
126
+ constructor(catalogueId, path) {
127
+ super(`This action can't be done for a client's catalogue yet, so nothing was done for catalogue ${catalogueId} (${path}).`, 0, false);
128
+ this.catalogueId = catalogueId;
129
+ this.name = "CatalogueNotHonouredError";
130
+ }
131
+ }
35
132
  /**
36
133
  * Thrown when the API responds 404 — the entity at the given id either
37
134
  * doesn't exist or doesn't belong to the caller's org. Distinguished from
@@ -54,6 +151,14 @@ function getTimeoutForPath(path) {
54
151
  ? LONG_TIMEOUT_MS
55
152
  : DEFAULT_TIMEOUT_MS;
56
153
  }
154
+ /** The `offset` query parameter of a request path (0 when absent). */
155
+ function offsetOf(path) {
156
+ const q = path.indexOf("?");
157
+ if (q === -1)
158
+ return 0;
159
+ const n = Number(new URLSearchParams(path.slice(q + 1)).get("offset"));
160
+ return Number.isFinite(n) && n > 0 ? n : 0;
161
+ }
57
162
  function sleep(ms) {
58
163
  return new Promise((resolve) => setTimeout(resolve, ms));
59
164
  }
@@ -98,9 +203,30 @@ class BaseResource {
98
203
  const timingOn = typeof process !== "undefined" && process.env?.PICA_SDK_TIMING === "1";
99
204
  const t0 = timingOn ? Date.now() : 0;
100
205
  let statusForTiming = 0;
206
+ const catalogueId = currentCatalogue();
207
+ const headers = catalogueId
208
+ ? {
209
+ ...init.headers,
210
+ [CATALOGUE_HEADER]: catalogueId,
211
+ }
212
+ : init.headers;
101
213
  try {
102
- const res = await fetch(url, { ...init, signal: controller.signal });
214
+ const res = await fetch(url, {
215
+ ...init,
216
+ headers,
217
+ signal: controller.signal,
218
+ });
103
219
  statusForTiming = res.status;
220
+ if (catalogueId &&
221
+ res.ok &&
222
+ res.headers.get(CATALOGUE_RESOLVED_HEADER) !== catalogueId) {
223
+ throw new CatalogueNotHonouredError(catalogueId, new URL(url).pathname);
224
+ }
225
+ if (catalogueId && res.ok) {
226
+ const report = catalogueContext.getStore();
227
+ if (report)
228
+ report.answered += 1;
229
+ }
104
230
  return res;
105
231
  }
106
232
  catch (err) {
@@ -134,6 +260,7 @@ class BaseResource {
134
260
  if (response.ok)
135
261
  return response;
136
262
  const errorText = await response.text();
263
+ recordCatalogueRefusal(url, errorText);
137
264
  // Auth errors — fail fast, never retry
138
265
  if (response.status === 401 || response.status === 403) {
139
266
  throw new ApiError(`Authentication failed: ${response.status} ${errorText}`, response.status, false);
@@ -312,8 +439,18 @@ class BaseResource {
312
439
  // `total`/`hasMore` (e.g. GET /admin/recordings/search, ADR unified
313
440
  // catalog search). Check nested first, then flat, then fall back to
314
441
  // `count` / items.length so untouched callers keep their prior behaviour.
315
- const total = json.pagination?.total ?? json.total ?? json.count ?? items.length;
316
- const hasMore = json.pagination?.hasMore ?? json.hasMore ?? total > items.length;
442
+ // An explicit `pagination.total: null` means the server does not know
443
+ // the total — keep it null rather than falling through to the page
444
+ // length, which would read as "that's all of them".
445
+ const total = json.pagination && json.pagination.total === null
446
+ ? null
447
+ : (json.pagination?.total ?? json.total ?? json.count ?? items.length);
448
+ // With no server-side hasMore, the answer is offset + this page < total.
449
+ // `total > items.length` alone ignored the offset, so every LAST page of
450
+ // a multi-page result said "more" (row-cap T3).
451
+ const hasMore = json.pagination?.hasMore ??
452
+ json.hasMore ??
453
+ (total !== null && offsetOf(path) + items.length < total);
317
454
  // Carry the server's zero-result guidance through — silently dropping it
318
455
  // here left agents staring at a bare empty list (ops_issue 689a8e9a).
319
456
  const recovery_hint = typeof json.recovery_hint === "string" ? json.recovery_hint : undefined;
@@ -406,6 +543,15 @@ class WorksResource extends BaseResource {
406
543
  const qs = qp.toString();
407
544
  return this.requestPaginated("GET", `/admin/works${qs ? `?${qs}` : ""}`);
408
545
  }
546
+ /**
547
+ * The agreements linked to ONE work (GET /admin/works/:id/agreements).
548
+ * Inspect used `agreements.list()`, which returned the org's first page of
549
+ * agreements under a work's heading (row-cap T3).
550
+ */
551
+ async getAgreements(workId) {
552
+ const res = await this.request("GET", `/admin/works/${workId}/agreements`);
553
+ return Array.isArray(res) ? res : [];
554
+ }
409
555
  async get(id) {
410
556
  return this.request("GET", `/admin/works/${id}`);
411
557
  }
@@ -517,6 +663,22 @@ class WorksResource extends BaseResource {
517
663
  async addWriters(workId, names) {
518
664
  return this.request("POST", `/admin/works/${workId}/writers`, { names });
519
665
  }
666
+ /** A song's directory topics: approved, proposed, moods awaiting review, settings. */
667
+ async getThemes(workId) {
668
+ return this.request("GET", `/admin/works/${encodeURIComponent(workId)}/themes`);
669
+ }
670
+ /** Set the song's approved topics directly, and/or its switch and lyric visibility. */
671
+ async setThemes(workId, body) {
672
+ return this.request("PUT", `/admin/works/${encodeURIComponent(workId)}/themes`, body);
673
+ }
674
+ /** Propose topics (and recording moods) for the owner to review. Nothing goes public. */
675
+ async proposeThemes(workId, body) {
676
+ return this.request("POST", `/admin/works/${encodeURIComponent(workId)}/themes/proposals`, body);
677
+ }
678
+ /** The owner's review of proposed topics and moods. */
679
+ async reviewThemes(workId, body) {
680
+ return this.request("PATCH", `/admin/works/${encodeURIComponent(workId)}/themes/proposals`, body);
681
+ }
520
682
  }
521
683
  class PeopleResource extends BaseResource {
522
684
  async list(params) {
@@ -628,6 +790,23 @@ class PeopleResource extends BaseResource {
628
790
  return this.request("GET", `/admin/people/triangulate?${qp.toString()}`);
629
791
  }
630
792
  }
793
+ function licenseEnquiryListPath(params) {
794
+ const queryParams = new URLSearchParams();
795
+ if (params?.work_id)
796
+ queryParams.set("work_id", params.work_id);
797
+ if (params?.status)
798
+ queryParams.set("status", params.status);
799
+ if (params?.project_type)
800
+ queryParams.set("project_type", params.project_type);
801
+ if (params?.territory)
802
+ queryParams.set("territory", params.territory);
803
+ if (params?.limit !== undefined)
804
+ queryParams.set("limit", String(params.limit));
805
+ if (params?.offset !== undefined)
806
+ queryParams.set("offset", String(params.offset));
807
+ const query = queryParams.toString();
808
+ return `/admin/license-enquiries${query ? `?${query}` : ""}`;
809
+ }
631
810
  class LicensingResource extends BaseResource {
632
811
  /**
633
812
  * Search for sync-licensable tracks by audio characteristics
@@ -673,33 +852,26 @@ class LicensingResource extends BaseResource {
673
852
  return this.request("POST", "/admin/license-enquiries", enquiry);
674
853
  }
675
854
  /**
676
- * List license enquiries with optional filters
855
+ * List license enquiries with optional filters (one page, the bare array;
856
+ * see listEnquiriesPage for the real total).
677
857
  */
678
858
  async listEnquiries(params) {
679
- const queryParams = new URLSearchParams();
680
- if (params?.work_id)
681
- queryParams.set("work_id", params.work_id);
682
- if (params?.status)
683
- queryParams.set("status", params.status);
684
- if (params?.project_type)
685
- queryParams.set("project_type", params.project_type);
686
- if (params?.territory)
687
- queryParams.set("territory", params.territory);
688
- if (params?.limit !== undefined)
689
- queryParams.set("limit", String(params.limit));
690
- if (params?.offset !== undefined)
691
- queryParams.set("offset", String(params.offset));
692
- const query = queryParams.toString();
693
- const path = `/admin/license-enquiries${query ? `?${query}` : ""}`;
694
- // The admin route returns successResponse({ data: [...], count }); after
695
- // request() unwraps the outer { success, data } envelope the payload is the
696
- // { data, count } object, not the array. Tolerate both shapes (mirrors
697
- // listDiscoveriesRecent + the other list resources) so the caller never
698
- // calls `.map` on a non-array — the "t.map is not a function" crash that
699
- // made pica_list_license_enquiries fail on every call shape.
700
- const res = (await this.request("GET", path));
859
+ // Older servers nested the list as `{ data: { data: [...], count } }`, so
860
+ // after request() unwraps the outer envelope the payload was an object,
861
+ // not the array. Tolerate both shapes so the caller never calls `.map` on
862
+ // a non-array — the "t.map is not a function" crash that made
863
+ // pica_list_license_enquiries fail on every call shape.
864
+ const res = (await this.request("GET", licenseEnquiryListPath(params)));
701
865
  return Array.isArray(res) ? res : (res?.data ?? []);
702
866
  }
867
+ /**
868
+ * One page of license enquiries WITH the real total and has_more
869
+ * (row-cap T3): `listEnquiries()` returns the bare array, so callers
870
+ * reported the page length as the total.
871
+ */
872
+ async listEnquiriesPage(params) {
873
+ return this.requestPaginated("GET", licenseEnquiryListPath(params));
874
+ }
703
875
  /**
704
876
  * Get a specific license enquiry by ID
705
877
  */
@@ -722,34 +894,40 @@ class LicensingResource extends BaseResource {
722
894
  return this.request("PATCH", `/admin/license-enquiries/${id}`, { counterOffer, notes });
723
895
  }
724
896
  }
725
- /**
726
- * ADR-289 Wave C1 — booking enquiries (internal-team session/production
727
- * bookings, distinct from `LicensingResource`'s sync-license enquiries).
728
- * Wraps `/admin/bookings` + `/admin/bookings/[id]`.
729
- */
897
+ function bookingListPath(params) {
898
+ const qp = new URLSearchParams();
899
+ if (params?.status)
900
+ qp.set("status", params.status);
901
+ if (params?.priority)
902
+ qp.set("priority", params.priority);
903
+ if (params?.query)
904
+ qp.set("query", params.query);
905
+ if (params?.enquiry_type)
906
+ qp.set("enquiry_type", params.enquiry_type);
907
+ if (params?.follow_up_required !== undefined)
908
+ qp.set("follow_up_required", String(params.follow_up_required));
909
+ if (params?.limit !== undefined)
910
+ qp.set("limit", String(params.limit));
911
+ if (params?.offset !== undefined)
912
+ qp.set("offset", String(params.offset));
913
+ const qs = qp.toString();
914
+ return `/admin/bookings${qs ? `?${qs}` : ""}`;
915
+ }
730
916
  class BookingsResource extends BaseResource {
731
917
  /**
732
- * List/search booking enquiries. `GET /admin/bookings` returns the raw
733
- * array (not `{data, count}`) — `request()` unwraps to that array.
918
+ * One page of booking enquiries (the bare array; see listPage for the real
919
+ * total). `request()` unwraps the route's `{ data, count }` envelope.
734
920
  */
735
921
  async list(params) {
736
- const qp = new URLSearchParams();
737
- if (params?.status)
738
- qp.set("status", params.status);
739
- if (params?.priority)
740
- qp.set("priority", params.priority);
741
- if (params?.query)
742
- qp.set("query", params.query);
743
- if (params?.enquiry_type)
744
- qp.set("enquiry_type", params.enquiry_type);
745
- if (params?.follow_up_required !== undefined)
746
- qp.set("follow_up_required", String(params.follow_up_required));
747
- if (params?.limit !== undefined)
748
- qp.set("limit", String(params.limit));
749
- if (params?.offset !== undefined)
750
- qp.set("offset", String(params.offset));
751
- const qs = qp.toString();
752
- return this.request("GET", `/admin/bookings${qs ? `?${qs}` : ""}`);
922
+ return this.request("GET", bookingListPath(params));
923
+ }
924
+ /**
925
+ * One page of booking enquiries WITH the real total and has_more
926
+ * (row-cap T3): `list()` returns the bare array, so callers reported the
927
+ * page length as the total.
928
+ */
929
+ async listPage(params) {
930
+ return this.requestPaginated("GET", bookingListPath(params));
753
931
  }
754
932
  async get(id) {
755
933
  return this.request("GET", `/admin/bookings/${encodeURIComponent(id)}`);
@@ -1084,39 +1262,51 @@ class MultimediaResource extends BaseResource {
1084
1262
  });
1085
1263
  }
1086
1264
  }
1265
+ function agreementListPath(params) {
1266
+ const queryParams = new URLSearchParams();
1267
+ if (params?.query)
1268
+ queryParams.set("query", params.query);
1269
+ if (params?.agreement_type)
1270
+ queryParams.set("agreement_type", params.agreement_type);
1271
+ if (params?.status)
1272
+ queryParams.set("status", params.status);
1273
+ if (params?.party_name)
1274
+ queryParams.set("party_name", params.party_name);
1275
+ if (params?.includeWorkCounts)
1276
+ queryParams.set("includeWorkCounts", "true");
1277
+ if (params?.awaiting_signature)
1278
+ queryParams.set("awaiting_signature", "true");
1279
+ if (params?.start_date_from)
1280
+ queryParams.set("start_date_from", params.start_date_from);
1281
+ if (params?.start_date_to)
1282
+ queryParams.set("start_date_to", params.start_date_to);
1283
+ if (params?.end_date_from)
1284
+ queryParams.set("end_date_from", params.end_date_from);
1285
+ if (params?.end_date_to)
1286
+ queryParams.set("end_date_to", params.end_date_to);
1287
+ if (params?.expiring_within_days !== undefined)
1288
+ queryParams.set("expiring_within_days", String(params.expiring_within_days));
1289
+ if (params?.has_end_date !== undefined)
1290
+ queryParams.set("has_end_date", String(params.has_end_date));
1291
+ if (params?.limit !== undefined)
1292
+ queryParams.set("limit", String(params.limit));
1293
+ if (params?.offset !== undefined)
1294
+ queryParams.set("offset", String(params.offset));
1295
+ const query = queryParams.toString();
1296
+ return `/admin/agreements${query ? `?${query}` : ""}`;
1297
+ }
1087
1298
  class AgreementsResource extends BaseResource {
1299
+ /** One page of agreements (the bare array; see listPage for the real total). */
1088
1300
  async list(params) {
1089
- const queryParams = new URLSearchParams();
1090
- if (params?.query)
1091
- queryParams.set("query", params.query);
1092
- if (params?.agreement_type)
1093
- queryParams.set("agreement_type", params.agreement_type);
1094
- if (params?.status)
1095
- queryParams.set("status", params.status);
1096
- if (params?.party_name)
1097
- queryParams.set("party_name", params.party_name);
1098
- if (params?.includeWorkCounts)
1099
- queryParams.set("includeWorkCounts", "true");
1100
- if (params?.awaiting_signature)
1101
- queryParams.set("awaiting_signature", "true");
1102
- if (params?.start_date_from)
1103
- queryParams.set("start_date_from", params.start_date_from);
1104
- if (params?.start_date_to)
1105
- queryParams.set("start_date_to", params.start_date_to);
1106
- if (params?.end_date_from)
1107
- queryParams.set("end_date_from", params.end_date_from);
1108
- if (params?.end_date_to)
1109
- queryParams.set("end_date_to", params.end_date_to);
1110
- if (params?.expiring_within_days !== undefined)
1111
- queryParams.set("expiring_within_days", String(params.expiring_within_days));
1112
- if (params?.has_end_date !== undefined)
1113
- queryParams.set("has_end_date", String(params.has_end_date));
1114
- if (params?.limit !== undefined)
1115
- queryParams.set("limit", String(params.limit));
1116
- if (params?.offset !== undefined)
1117
- queryParams.set("offset", String(params.offset));
1118
- const query = queryParams.toString();
1119
- return this.request("GET", `/admin/agreements${query ? `?${query}` : ""}`);
1301
+ return this.request("GET", agreementListPath(params));
1302
+ }
1303
+ /**
1304
+ * One page of agreements WITH the real total and has_more (row-cap T3):
1305
+ * `list()` returns the bare array, so callers reported the page length
1306
+ * as the total.
1307
+ */
1308
+ async listPage(params) {
1309
+ return this.requestPaginated("GET", agreementListPath(params));
1120
1310
  }
1121
1311
  async get(id) {
1122
1312
  return this.request("GET", `/admin/agreements/${id}`);
@@ -1189,10 +1379,28 @@ class AgreementsResource extends BaseResource {
1189
1379
  return this.request("POST", `/admin/agreements/${id}/send-for-signature`, { expiresInDays: params?.expires_in_days });
1190
1380
  }
1191
1381
  }
1192
- // ADR-222 — sync placements as a first-class domain. REST routes implemented
1193
- // by W4 at /api/admin/sync-placements (paired PR on the same parent feature
1194
- // branch). All methods scope to the caller's organisation server-side via
1195
- // requireOrganisationContext — no orgId param needed on the SDK surface.
1382
+ /** Sync fit: GET /admin/sync-fit, the same read as /inspect/sync. */
1383
+ class SyncFitResource extends BaseResource {
1384
+ async query(params) {
1385
+ const q = new URLSearchParams();
1386
+ if (params?.work_id)
1387
+ q.set("work_id", params.work_id);
1388
+ if (params?.screen_project_id)
1389
+ q.set("screen_project_id", params.screen_project_id);
1390
+ if (params?.song_facts)
1391
+ q.set("song_facts", "true");
1392
+ if (params?.limit)
1393
+ q.set("limit", String(params.limit));
1394
+ if (params?.offset)
1395
+ q.set("offset", String(params.offset));
1396
+ const qs = q.toString();
1397
+ const res = await this.request("GET", `/admin/sync-fit${qs ? `?${qs}` : ""}`);
1398
+ if (res && typeof res === "object" && "data" in res && res.data) {
1399
+ return res.data;
1400
+ }
1401
+ return res;
1402
+ }
1403
+ }
1196
1404
  class SyncPlacementsResource extends BaseResource {
1197
1405
  async list(params) {
1198
1406
  const queryParams = new URLSearchParams();
@@ -1735,6 +1943,16 @@ class EnrichmentResource extends BaseResource {
1735
1943
  async bulkRejectProposals(input) {
1736
1944
  return this.request("POST", "/admin/enrichment-proposals/bulk-reject", input);
1737
1945
  }
1946
+ /**
1947
+ * What bulkRejectProposals would reject, without rejecting: the route
1948
+ * resolves the same pending set the real call would (row-cap T3).
1949
+ */
1950
+ async previewBulkRejectProposals(input) {
1951
+ return this.request("POST", "/admin/enrichment-proposals/bulk-reject", {
1952
+ ...input,
1953
+ dry_run: true,
1954
+ });
1955
+ }
1738
1956
  /**
1739
1957
  * Apply many pending proposals in one call — the accept side of the
1740
1958
  * /inspect/found queue.
@@ -2676,6 +2894,24 @@ class DuplicatesResource extends BaseResource {
2676
2894
  loser_ids: loserIds,
2677
2895
  });
2678
2896
  }
2897
+ /**
2898
+ * "They're different": mark every pair among `ids` (2–25 recordings or
2899
+ * releases of this catalogue) as not duplicates, so findDuplicates stops
2900
+ * offering them. Idempotent. Resolves {entity_type, ids, pairs, changed}.
2901
+ */
2902
+ async markDifferent(entityType, ids) {
2903
+ return this.request("POST", "/admin/catalog/duplicates/exclusions", {
2904
+ entity_type: entityType,
2905
+ ids,
2906
+ });
2907
+ }
2908
+ /** Take back markDifferent for every pair among `ids`. */
2909
+ async unmarkDifferent(entityType, ids) {
2910
+ return this.request("DELETE", "/admin/catalog/duplicates/exclusions", {
2911
+ entity_type: entityType,
2912
+ ids,
2913
+ });
2914
+ }
2679
2915
  }
2680
2916
  // --- Entity Context Resource (ADR-116 Phase 3) ---
2681
2917
  class EntityContextResource extends BaseResource {
@@ -2943,6 +3179,40 @@ class CollaboratorsResource extends BaseResource {
2943
3179
  const qs = qp.toString();
2944
3180
  return this.request("GET", `/admin/collaborators/invites${qs ? `?${qs}` : ""}`);
2945
3181
  }
3182
+ /**
3183
+ * One page of the invites this org sent WITH the real total and has_more
3184
+ * (row-cap T3). `listInvites()` is typed as an array but the route nests
3185
+ * its rows as `data: { data, count }`, so it never carried a usable total.
3186
+ */
3187
+ async listInvitesPage(params) {
3188
+ const qp = new URLSearchParams();
3189
+ if (params?.status)
3190
+ qp.set("status", params.status);
3191
+ if (params?.workId)
3192
+ qp.set("workId", params.workId);
3193
+ if (params?.limit !== undefined)
3194
+ qp.set("limit", String(params.limit));
3195
+ if (params?.offset)
3196
+ qp.set("offset", String(params.offset));
3197
+ const qs = qp.toString();
3198
+ return this.requestNestedPage(`/admin/collaborators/invites${qs ? `?${qs}` : ""}`, params?.offset ?? 0);
3199
+ }
3200
+ /**
3201
+ * Reads a collaborator list route, whose rows sit at `data.data` (kept
3202
+ * for published SDKs) with the total in the top-level `pagination`.
3203
+ */
3204
+ async requestNestedPage(path, offset) {
3205
+ const json = await this.requestWithEnvelope("GET", path);
3206
+ const inner = json.data;
3207
+ const rows = Array.isArray(inner) ? inner : (inner?.data ?? []);
3208
+ const pagination = json.pagination;
3209
+ const total = pagination?.total ?? rows.length;
3210
+ return {
3211
+ data: rows,
3212
+ total,
3213
+ hasMore: pagination?.hasMore ?? offset + rows.length < total,
3214
+ };
3215
+ }
2946
3216
  async resendInvite(inviteId) {
2947
3217
  return this.request("POST", `/admin/collaborators/invites/${inviteId}/resend`);
2948
3218
  }
@@ -2961,6 +3231,21 @@ class CollaboratorsResource extends BaseResource {
2961
3231
  const res = await this.request("GET", `/admin/collaborators/incoming${qs ? `?${qs}` : ""}`);
2962
3232
  return res.data;
2963
3233
  }
3234
+ /**
3235
+ * One page of the invites addressed to the caller WITH the real total and
3236
+ * has_more (row-cap T3: `listIncoming()` returns only the rows).
3237
+ */
3238
+ async listIncomingPage(params) {
3239
+ const qp = new URLSearchParams();
3240
+ if (params?.status)
3241
+ qp.set("status", params.status);
3242
+ if (params?.limit !== undefined)
3243
+ qp.set("limit", String(params.limit));
3244
+ if (params?.offset)
3245
+ qp.set("offset", String(params.offset));
3246
+ const qs = qp.toString();
3247
+ return this.requestNestedPage(`/admin/collaborators/incoming${qs ? `?${qs}` : ""}`, params?.offset ?? 0);
3248
+ }
2964
3249
  /**
2965
3250
  * ADR-157 warm path — accept an invite addressed to the authenticated
2966
3251
  * user, and flip its status to confirmed. The credit write is
@@ -3192,6 +3477,10 @@ class DirectoryResource extends BaseResource {
3192
3477
  async updateSettings(updates) {
3193
3478
  return this.request("PATCH", "/admin/directory/settings", updates);
3194
3479
  }
3480
+ /** Listed songs with lyrics and topics on, but no approved topic yet. */
3481
+ async untaggedThemes() {
3482
+ return this.request("GET", "/admin/directory/themes/untagged");
3483
+ }
3195
3484
  }
3196
3485
  class ProjectsResource extends BaseResource {
3197
3486
  async list(params) {
@@ -3438,6 +3727,21 @@ class RecordingSplitsResource extends BaseResource {
3438
3727
  }
3439
3728
  }
3440
3729
  class PublishersResource extends BaseResource {
3730
+ /**
3731
+ * One page of the publisher registry with an honest has_more. The total
3732
+ * is null: the route does not count the platform-wide registry.
3733
+ */
3734
+ async listPage(params) {
3735
+ const queryParams = new URLSearchParams();
3736
+ if (params?.query)
3737
+ queryParams.set("query", params.query);
3738
+ if (params?.limit)
3739
+ queryParams.set("limit", String(params.limit));
3740
+ if (params?.offset)
3741
+ queryParams.set("offset", String(params.offset));
3742
+ const qs = queryParams.toString();
3743
+ return this.requestPaginated("GET", `/admin/publishers${qs ? `?${qs}` : ""}`);
3744
+ }
3441
3745
  async list(params) {
3442
3746
  const queryParams = new URLSearchParams();
3443
3747
  if (params?.query)
@@ -3452,6 +3756,25 @@ class PublishersResource extends BaseResource {
3452
3756
  }
3453
3757
  }
3454
3758
  class LabelsResource extends BaseResource {
3759
+ /**
3760
+ * One page of label/distributor organisations with an honest has_more.
3761
+ * The total is null: the route does not count platform-wide rows.
3762
+ */
3763
+ async listPage(params) {
3764
+ const queryParams = new URLSearchParams();
3765
+ if (params?.query)
3766
+ queryParams.set("query", params.query);
3767
+ if (params?.limit)
3768
+ queryParams.set("limit", String(params.limit));
3769
+ if (params?.offset)
3770
+ queryParams.set("offset", String(params.offset));
3771
+ if (params?.org_type)
3772
+ queryParams.set("org_type", params.org_type);
3773
+ if (params?.ids?.length)
3774
+ queryParams.set("ids", params.ids.join(","));
3775
+ const qs = queryParams.toString();
3776
+ return this.requestPaginated("GET", `/admin/labels${qs ? `?${qs}` : ""}`);
3777
+ }
3455
3778
  async list(params) {
3456
3779
  const queryParams = new URLSearchParams();
3457
3780
  if (params?.query)
@@ -3480,14 +3803,20 @@ class LabelsResource extends BaseResource {
3480
3803
  return (res ?? null);
3481
3804
  }
3482
3805
  }
3483
- /**
3484
- * ADR-268 WS1 — party org claim flow. `invite()` sends an artist-anchored
3485
- * claim invite for an unclaimed label/distributor/publisher org;
3486
- * `list()` reads the claims the calling org has sent.
3487
- *
3488
- * Typed against the UNWRAPPED reality (ADR-265 lesson): BaseResource.request
3489
- * auto-unwraps the `{success,data}` envelope, so methods see `data` itself.
3490
- */
3806
+ function partyClaimListPath(params) {
3807
+ const queryParams = new URLSearchParams();
3808
+ if (params?.target_organisation_id) {
3809
+ queryParams.set("target_organisation_id", params.target_organisation_id);
3810
+ }
3811
+ if (params?.status)
3812
+ queryParams.set("status", params.status);
3813
+ if (params?.limit)
3814
+ queryParams.set("limit", String(params.limit));
3815
+ if (params?.offset)
3816
+ queryParams.set("offset", String(params.offset));
3817
+ const qs = queryParams.toString();
3818
+ return `/admin/party-claims${qs ? `?${qs}` : ""}`;
3819
+ }
3491
3820
  class PartyClaimsResource extends BaseResource {
3492
3821
  async invite(params) {
3493
3822
  const res = await this.request("POST", "/admin/party-claims", params);
@@ -3499,20 +3828,19 @@ class PartyClaimsResource extends BaseResource {
3499
3828
  return (res ?? null);
3500
3829
  }
3501
3830
  async list(params) {
3502
- const queryParams = new URLSearchParams();
3503
- if (params?.target_organisation_id) {
3504
- queryParams.set("target_organisation_id", params.target_organisation_id);
3505
- }
3506
- if (params?.status)
3507
- queryParams.set("status", params.status);
3508
- if (params?.limit)
3509
- queryParams.set("limit", String(params.limit));
3510
- const qs = queryParams.toString();
3511
- const res = await this.request("GET", `/admin/party-claims${qs ? `?${qs}` : ""}`);
3831
+ const res = await this.request("GET", partyClaimListPath(params));
3512
3832
  if (Array.isArray(res))
3513
3833
  return res;
3514
3834
  return (res?.data ?? []);
3515
3835
  }
3836
+ /**
3837
+ * One page of sent claim invites WITH the real total and has_more
3838
+ * (row-cap T3): `list()` returns the bare array, so callers reported the
3839
+ * page length as the total.
3840
+ */
3841
+ async listPage(params) {
3842
+ return this.requestPaginated("GET", partyClaimListPath(params));
3843
+ }
3516
3844
  }
3517
3845
  /**
3518
3846
  * Claim your profile: invite people to claim their profile (a preview
@@ -3544,25 +3872,38 @@ class ProfileClaimsResource extends BaseResource {
3544
3872
  };
3545
3873
  }
3546
3874
  }
3875
+ function releaseListPath(params) {
3876
+ const parts = [];
3877
+ if (params?.limit != null)
3878
+ parts.push(`limit=${params.limit}`);
3879
+ if (params?.offset != null)
3880
+ parts.push(`offset=${params.offset}`);
3881
+ if (params?.query)
3882
+ parts.push(`query=${encodeURIComponent(params.query)}`);
3883
+ if (params?.upc)
3884
+ parts.push(`upc=${encodeURIComponent(params.upc)}`);
3885
+ if (params?.spotify_album_id)
3886
+ parts.push(`spotify_album_id=${encodeURIComponent(params.spotify_album_id)}`);
3887
+ if (params?.release_date_from)
3888
+ parts.push(`release_date_from=${encodeURIComponent(params.release_date_from)}`);
3889
+ if (params?.release_date_to)
3890
+ parts.push(`release_date_to=${encodeURIComponent(params.release_date_to)}`);
3891
+ const qs = parts.length > 0 ? `?${parts.join("&")}` : "";
3892
+ return `/admin/releases${qs}`;
3893
+ }
3547
3894
  class ReleasesResource extends BaseResource {
3895
+ /** One page of releases (the bare array; see listPage for the real total). */
3548
3896
  async list(params) {
3549
- const parts = [];
3550
- if (params?.limit != null)
3551
- parts.push(`limit=${params.limit}`);
3552
- if (params?.offset != null)
3553
- parts.push(`offset=${params.offset}`);
3554
- if (params?.query)
3555
- parts.push(`query=${encodeURIComponent(params.query)}`);
3556
- if (params?.upc)
3557
- parts.push(`upc=${encodeURIComponent(params.upc)}`);
3558
- if (params?.spotify_album_id)
3559
- parts.push(`spotify_album_id=${encodeURIComponent(params.spotify_album_id)}`);
3560
- if (params?.release_date_from)
3561
- parts.push(`release_date_from=${encodeURIComponent(params.release_date_from)}`);
3562
- if (params?.release_date_to)
3563
- parts.push(`release_date_to=${encodeURIComponent(params.release_date_to)}`);
3564
- const qs = parts.length > 0 ? `?${parts.join("&")}` : "";
3565
- return this.request("GET", `/admin/releases${qs}`);
3897
+ return this.request("GET", releaseListPath(params));
3898
+ }
3899
+ /**
3900
+ * One page of releases WITH the real total and has_more (row-cap T3):
3901
+ * `list()` returns the bare array, so callers guessed has_more from "the
3902
+ * page came back full". `total` is null when the server cannot count (a
3903
+ * title search past its match cap); has_more is the server's own answer.
3904
+ */
3905
+ async listPage(params) {
3906
+ return this.requestPaginated("GET", releaseListPath(params));
3566
3907
  }
3567
3908
  async get(id) {
3568
3909
  return this.request("GET", `/admin/releases/${id}`);
@@ -3681,15 +4022,27 @@ class ReleasesResource extends BaseResource {
3681
4022
  return this.request("POST", "/admin/releases/create-with-tracks", data);
3682
4023
  }
3683
4024
  }
4025
+ function sessionListPath(params) {
4026
+ const query = new URLSearchParams();
4027
+ if (params?.limit)
4028
+ query.set("limit", String(params.limit));
4029
+ if (params?.offset)
4030
+ query.set("offset", String(params.offset));
4031
+ const qs = query.toString();
4032
+ return `/admin/sessions${qs ? `?${qs}` : ""}`;
4033
+ }
3684
4034
  class SessionsResource extends BaseResource {
4035
+ /** One page of upcoming sessions (see listPage for the real total). */
3685
4036
  async list(params) {
3686
- const query = new URLSearchParams();
3687
- if (params?.limit)
3688
- query.set("limit", String(params.limit));
3689
- if (params?.offset)
3690
- query.set("offset", String(params.offset));
3691
- const qs = query.toString();
3692
- return this.request("GET", `/admin/sessions${qs ? `?${qs}` : ""}`);
4037
+ return this.request("GET", sessionListPath(params));
4038
+ }
4039
+ /**
4040
+ * One page of the next 7 days' sessions WITH the real total and has_more
4041
+ * (row-cap T3): `list()` returns the bare array, so callers could only
4042
+ * guess has_more from "the page came back full".
4043
+ */
4044
+ async listPage(params) {
4045
+ return this.requestPaginated("GET", sessionListPath(params));
3693
4046
  }
3694
4047
  async get(id) {
3695
4048
  return this.request("GET", `/admin/sessions/${id}`);
@@ -3824,6 +4177,113 @@ class RightsResource extends BaseResource {
3824
4177
  return this.request("GET", "/admin/rights-statements/measures");
3825
4178
  }
3826
4179
  }
4180
+ function pageQuery(params) {
4181
+ const qp = new URLSearchParams();
4182
+ for (const [key, value] of Object.entries(params)) {
4183
+ if (value === undefined)
4184
+ continue;
4185
+ if (Array.isArray(value))
4186
+ value.forEach((v) => qp.append(key, v));
4187
+ else
4188
+ qp.set(key, String(value));
4189
+ }
4190
+ const qs = qp.toString();
4191
+ return qs ? `?${qs}` : "";
4192
+ }
4193
+ /**
4194
+ * Catalogues an organisation builds for a client, who accepts to own them
4195
+ * (ADR-333 D5). Builder side only: the client answers on screen, signed in.
4196
+ */
4197
+ class HandoversResource extends BaseResource {
4198
+ /**
4199
+ * POST /admin/handovers — create a catalogue for a client
4200
+ * (catalogue_name), or a new hand-over on one this organisation built whose
4201
+ * last one closed (catalogue_org_id). Nothing is sent.
4202
+ */
4203
+ async create(body) {
4204
+ return this.request("POST", "/admin/handovers", body);
4205
+ }
4206
+ /** POST /admin/handovers/{id}/send { expected_version? } — email the client. */
4207
+ async send(id, body) {
4208
+ return this.request("POST", `/admin/handovers/${encodeURIComponent(id)}/send`, body);
4209
+ }
4210
+ }
4211
+ /**
4212
+ * /admin/relationships/** — both sides of a relationship. Refusals arrive as
4213
+ * `{ success: false, error: { code, message, details? } }` (read them with
4214
+ * the MCP package's parseApiErrorBody); a step whose terms changed since they
4215
+ * were read is a 409 `status_changed` carrying `details.relationship`.
4216
+ */
4217
+ class RelationshipsResource extends BaseResource {
4218
+ /** GET /admin/relationships?side=owner|organisation&status=&cursor=&limit= */
4219
+ async list(params) {
4220
+ return this.request("GET", `/admin/relationships${pageQuery({
4221
+ side: params.side,
4222
+ status: params.statuses,
4223
+ cursor: params.cursor,
4224
+ limit: params.limit,
4225
+ })}`);
4226
+ }
4227
+ /** GET /admin/relationships/{id} */
4228
+ async get(id) {
4229
+ return this.request("GET", `/admin/relationships/${encodeURIComponent(id)}`);
4230
+ }
4231
+ /**
4232
+ * POST /admin/relationships { as, email, type, scope? } — invite someone by
4233
+ * email. as="owner" asks someone at an organisation to look after this
4234
+ * catalogue; as="organisation" asks a client to let this organisation look
4235
+ * after theirs. The reply is the same whether or not the address is on PICA.
4236
+ */
4237
+ async invite(body) {
4238
+ return this.request("POST", "/admin/relationships", body);
4239
+ }
4240
+ /** PATCH /admin/relationships/{id} { action: accept | decline | edit, expected_version, … } */
4241
+ async answer(id, body) {
4242
+ return this.request("PATCH", `/admin/relationships/${encodeURIComponent(id)}`, body);
4243
+ }
4244
+ /** DELETE /admin/relationships/{id} { expected_version, reason? } — the owner ends it. */
4245
+ async revoke(id, body) {
4246
+ return this.request("DELETE", `/admin/relationships/${encodeURIComponent(id)}`, body);
4247
+ }
4248
+ /** POST /admin/relationships/{id}/step-down { expected_version, reason? } — the organisation ends its side. */
4249
+ async stepDown(id, body) {
4250
+ return this.request("POST", `/admin/relationships/${encodeURIComponent(id)}/step-down`, body);
4251
+ }
4252
+ /** GET /admin/relationships/{id}/activity — one relationship's history, either party. */
4253
+ async events(id, params = {}) {
4254
+ return this.request("GET", `/admin/relationships/${encodeURIComponent(id)}/activity${pageQuery(params)}`);
4255
+ }
4256
+ /**
4257
+ * GET /admin/relationships/activity?side=&org= — side=owner: what
4258
+ * organisations did in this catalogue (org = one organisation);
4259
+ * side=organisation: this organisation's own actions (org = one catalogue).
4260
+ */
4261
+ async activity(params) {
4262
+ return this.request("GET", `/admin/relationships/activity${pageQuery(params)}`);
4263
+ }
4264
+ /**
4265
+ * PATCH /admin/relationships/invites/{id} { action: "accept", organisation_id, type?, scope? }
4266
+ * — answer yes to an invitation for one of the person's organisations.
4267
+ */
4268
+ async acceptInvite(inviteId, body) {
4269
+ return this.request("PATCH", `/admin/relationships/invites/${encodeURIComponent(inviteId)}`, { action: "accept", ...body });
4270
+ }
4271
+ }
4272
+ /**
4273
+ * /admin/portfolio — the client catalogues an organisation looks after. A
4274
+ * page may hold fewer rows than `limit` and still not be the last: follow
4275
+ * `nextCursor` until it is null.
4276
+ */
4277
+ class PortfolioResource extends BaseResource {
4278
+ /** GET /admin/portfolio?cursor=&limit= (limit ≤ 25) */
4279
+ async list(params = {}) {
4280
+ return this.request("GET", `/admin/portfolio${pageQuery(params)}`);
4281
+ }
4282
+ /** GET /admin/portfolio/briefing?cursor=&limit= — the list plus what needs attention. */
4283
+ async briefing(params = {}) {
4284
+ return this.request("GET", `/admin/portfolio/briefing${pageQuery(params)}`);
4285
+ }
4286
+ }
3827
4287
  class RoyaltiesResource extends BaseResource {
3828
4288
  async payments(params) {
3829
4289
  const qp = new URLSearchParams();
@@ -4044,6 +4504,11 @@ class CustodyResource extends BaseResource {
4044
4504
  async respond(body) {
4045
4505
  return this.request("POST", "/admin/custody/respond", body);
4046
4506
  }
4507
+ /**
4508
+ * Custody claims, one page per direction. Since row-cap T3 the result also
4509
+ * carries `totals: { incoming, outgoing }` (null for a direction not asked
4510
+ * for), `has_more: { incoming, outgoing }`, `limit` and `offset`.
4511
+ */
4047
4512
  async list(params) {
4048
4513
  const qp = new URLSearchParams();
4049
4514
  if (params?.direction)
@@ -4052,6 +4517,8 @@ class CustodyResource extends BaseResource {
4052
4517
  qp.set("status", params.status);
4053
4518
  if (params?.limit !== undefined)
4054
4519
  qp.set("limit", String(params.limit));
4520
+ if (params?.offset)
4521
+ qp.set("offset", String(params.offset));
4055
4522
  const qs = qp.toString();
4056
4523
  return this.request("GET", `/admin/custody/list${qs ? `?${qs}` : ""}`);
4057
4524
  }
@@ -4270,6 +4737,7 @@ export class PicaClient {
4270
4737
  agreements;
4271
4738
  // ADR-222 — sync placements (W3, paired with W4 REST routes).
4272
4739
  syncPlacements;
4740
+ syncFit;
4273
4741
  workspace;
4274
4742
  memory;
4275
4743
  audit;
@@ -4318,6 +4786,11 @@ export class PicaClient {
4318
4786
  producerAgreements;
4319
4787
  workForHire;
4320
4788
  royalties;
4789
+ /** ADR-333 — who looks after whose catalogue, from either side. */
4790
+ relationships;
4791
+ handovers;
4792
+ /** ADR-333 — the client catalogues an organisation looks after. */
4793
+ portfolio;
4321
4794
  /** ADR-321 Phase 2 — programmable rights (propose / status / inspect). */
4322
4795
  rights;
4323
4796
  statements;
@@ -4382,6 +4855,7 @@ export class PicaClient {
4382
4855
  this.multimedia = new MultimediaResource(baseUrl, config.apiKey, debug);
4383
4856
  this.agreements = new AgreementsResource(baseUrl, config.apiKey, debug);
4384
4857
  this.syncPlacements = new SyncPlacementsResource(baseUrl, config.apiKey, debug);
4858
+ this.syncFit = new SyncFitResource(baseUrl, config.apiKey, debug);
4385
4859
  this.workspace = new WorkspaceResource(baseUrl, config.apiKey, debug);
4386
4860
  this.memory = new MemoryResource(baseUrl, config.apiKey, debug);
4387
4861
  this.audit = new AuditResource(baseUrl, config.apiKey, debug);
@@ -4428,6 +4902,9 @@ export class PicaClient {
4428
4902
  this.producerAgreements = new ProducerAgreementsResource(baseUrl, config.apiKey, debug);
4429
4903
  this.workForHire = new WorkForHireResource(baseUrl, config.apiKey, debug);
4430
4904
  this.royalties = new RoyaltiesResource(baseUrl, config.apiKey, debug);
4905
+ this.relationships = new RelationshipsResource(baseUrl, config.apiKey, debug);
4906
+ this.handovers = new HandoversResource(baseUrl, config.apiKey, debug);
4907
+ this.portfolio = new PortfolioResource(baseUrl, config.apiKey, debug);
4431
4908
  this.rights = new RightsResource(baseUrl, config.apiKey, debug);
4432
4909
  this.statements = new StatementsResource(baseUrl, config.apiKey, debug);
4433
4910
  this.selections = new SelectionsResource(baseUrl, config.apiKey, debug);