@centrifuge/sdk 1.16.0 → 1.18.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.
@@ -22,84 +22,190 @@ function migrateApyMode(apy) {
22
22
  function formatMinInvestment(value) {
23
23
  return value.toLocaleString('en-US');
24
24
  }
25
+ /** Tolerates legacy docs that omit (or malform) an array field by treating it as empty. */
26
+ function asArray(value) {
27
+ return Array.isArray(value) ? value : [];
28
+ }
29
+ /**
30
+ * Folds a legacy off-chain `{ headers, data }` holdings blob into a `table` section, preserving
31
+ * every column verbatim (numbers stay numbers, null/undefined -> '', other values stringified +
32
+ * trimmed). Returns null when there are no headers or no data.
33
+ */
34
+ function holdingsTableBlock(holdings) {
35
+ if (!holdings || !Array.isArray(holdings.headers) || holdings.headers.length === 0 || !Array.isArray(holdings.data)) {
36
+ return null;
37
+ }
38
+ const { headers, data } = holdings;
39
+ return {
40
+ type: 'table',
41
+ id: 'holdings',
42
+ title: 'Holdings',
43
+ headers,
44
+ rows: data.map((row) => headers.map((header) => {
45
+ const value = row[header];
46
+ return typeof value === 'number' ? value : value == null ? '' : String(value).trim();
47
+ })),
48
+ };
49
+ }
50
+ /** Normalizes an issuer category `type` for matching (case- and separator-insensitive). */
51
+ function normalizeCategoryType(type) {
52
+ return type.toLowerCase().replace(/[\s_-]/g, '');
53
+ }
54
+ /**
55
+ * Issuer-category `type` values (normalized) that migrate into the "Service providers" group, mapped
56
+ * to their v2 display label (the v1 vocabulary differs from v2, e.g. "Sub-Investment Manager" is the
57
+ * v2 "Portfolio Manager"). Anything not listed stays in "Key facts" with its raw label (the seed is
58
+ * ops-editable, so an unmatched type is low-cost). `administrator` is an alias of Fund Administrator.
59
+ */
60
+ const SERVICE_PROVIDER_CATEGORIES = new Map([
61
+ ['subinvestmentmanager', 'Portfolio Manager'],
62
+ ['custodian', 'Custodian'],
63
+ ['fundadministrator', 'Fund Administrator'],
64
+ ['administrator', 'Fund Administrator'],
65
+ ['auditor', 'Auditor'],
66
+ ]);
67
+ /**
68
+ * Issuer-category `type` values (normalized) dropped from the factsheet because they are already
69
+ * represented elsewhere: "Investment Manager" is absorbed by the `Issuer` key fact (from
70
+ * `issuer.name`), so it is not rendered as its own row.
71
+ */
72
+ const ABSORBED_CATEGORY_TYPES = new Set(['investmentmanager']);
25
73
  /** Document links only carry web/IPFS URIs; anything else (e.g. `javascript:`) is dropped. */
26
74
  function isSafeDocumentUri(uri) {
27
75
  const scheme = uri.trim().toLowerCase();
28
76
  return scheme.startsWith('https://') || scheme.startsWith('ipfs://');
29
77
  }
30
- /** Escapes markdown link-breaking characters so an issuer-supplied label can't alter the link. */
31
- function escapeMarkdownText(text) {
32
- return text.replace(/[\\[\]()]/g, '\\$&').replace(/\s+/g, ' ');
78
+ /**
79
+ * Builds a `documents` section from the rating report files (a tile per safe-URI report), or null
80
+ * when none have a usable report. Each tile targets the report file directly (`kind: 'file'`).
81
+ */
82
+ function ratingDocumentsBlock(poolRatings) {
83
+ const items = asArray(poolRatings)
84
+ .filter((rating) => rating.reportFile?.uri && isSafeDocumentUri(rating.reportFile.uri))
85
+ .map((rating) => ({
86
+ title: `${rating.agency ?? 'Rating'} rating report`,
87
+ target: { kind: 'file', file: rating.reportFile },
88
+ }));
89
+ return items.length > 0 ? { type: 'documents', id: 'documents', title: 'Documents', items } : null;
33
90
  }
34
91
  /**
35
92
  * Builds the seed `factsheet` from the legacy display fields. The migration is content-translating:
36
93
  * a migrated pool must render the same content, so legacy fields are projected into ordered key
37
94
  * facts + body blocks. The output is a sensible, ops-editable seed, not a frozen contract.
38
95
  */
39
- function buildFactsheet(pool, shareClasses) {
96
+ function buildFactsheet(pool, shareClasses, holdings) {
40
97
  const { issuer, asset, poolStructure, expenseRatio, details, poolRatings } = pool;
41
98
  const firstShareClass = Object.values(shareClasses)[0];
42
- const keyFacts = [
43
- { label: 'Issuer', value: issuer.name },
44
- { label: 'Asset type', value: `${asset.class} - ${asset.subClass}` },
45
- { label: 'APY', valueRef: 'apy' },
46
- { label: 'Pool structure', value: poolStructure },
99
+ // Trim text key-fact values (issuer/category strings sometimes carry stray whitespace).
100
+ const text = (value) => ({ kind: 'text', text: value.trim() });
101
+ const keyFactItems = [
102
+ { label: 'Issuer', value: text(issuer.name) },
103
+ { label: 'Asset type', value: text(`${asset.class} - ${asset.subClass}`) },
104
+ { label: 'APY', value: { kind: 'ref', ref: 'apy' } },
105
+ { label: 'Pool structure', value: text(poolStructure) },
47
106
  ];
48
107
  const weightedAverageMaturity = firstShareClass?.weightedAverageMaturity;
49
108
  if (weightedAverageMaturity != null) {
50
- keyFacts.push({ label: 'Average asset maturity', value: `${weightedAverageMaturity} days` });
109
+ keyFactItems.push({ label: 'Average asset maturity', value: text(`${weightedAverageMaturity} days`) });
51
110
  }
52
111
  if (expenseRatio != null && expenseRatio !== '') {
53
- keyFacts.push({ label: 'Expense ratio', value: `${expenseRatio}%` });
112
+ keyFactItems.push({ label: 'Expense ratio', value: text(`${expenseRatio}%`) });
54
113
  }
55
114
  const minInitialInvestment = firstShareClass?.minInitialInvestment;
56
115
  if (minInitialInvestment != null) {
57
- keyFacts.push({ label: 'Min. investment', value: formatMinInvestment(minInitialInvestment) });
116
+ keyFactItems.push({ label: 'Min. investment', value: text(formatMinInvestment(minInitialInvestment)) });
117
+ }
118
+ const serviceProviderItems = [];
119
+ for (const category of asArray(issuer.categories)) {
120
+ const key = normalizeCategoryType(category.type);
121
+ if (ABSORBED_CATEGORY_TYPES.has(key))
122
+ continue;
123
+ const label = SERVICE_PROVIDER_CATEGORIES.get(key);
124
+ if (label) {
125
+ serviceProviderItems.push({ label, value: text(category.value) });
126
+ }
127
+ else {
128
+ keyFactItems.push({ label: category.type, value: text(category.value) });
129
+ }
130
+ }
131
+ // Seeded unconditionally because v1 has no source field for either: the app derives the deployed
132
+ // chains, and wallet infrastructure is currently Fordefi for all pools. Ops can edit/remove after.
133
+ keyFactItems.push({
134
+ label: 'Wallet infrastructure',
135
+ value: { kind: 'icons', icons: [{ source: 'app', key: 'fordefi' }] },
136
+ });
137
+ keyFactItems.push({ label: 'Available networks', value: { kind: 'ref', ref: 'availableNetworks' } });
138
+ if (asArray(poolRatings).length > 0) {
139
+ keyFactItems.push({ label: 'Ratings', value: { kind: 'ref', ref: 'ratings' } });
58
140
  }
59
- for (const category of issuer.categories) {
60
- keyFacts.push({ label: category.type, value: category.value });
141
+ // Right column is groups only (no bare key facts). The "Service providers" group is added only
142
+ // when at least one category mapped to it.
143
+ const keyFacts = [{ type: 'keyFactGroup', id: 'key-facts', title: 'Key facts', items: keyFactItems }];
144
+ if (serviceProviderItems.length > 0) {
145
+ keyFacts.push({
146
+ type: 'keyFactGroup',
147
+ id: 'service-providers',
148
+ title: 'Service providers',
149
+ items: serviceProviderItems,
150
+ });
61
151
  }
62
152
  const body = [];
63
153
  if (issuer.description) {
64
- body.push({ type: 'text', id: 'overview', title: 'Overview', body: issuer.description });
65
- }
66
- ;
67
- (details ?? []).forEach((detail, index) => {
68
- body.push({ type: 'text', id: `detail-${index}`, title: detail.title, body: detail.body });
69
- });
70
- const ratingsWithReports = (poolRatings ?? []).filter((rating) => rating.reportFile?.uri && isSafeDocumentUri(rating.reportFile.uri));
71
- if (ratingsWithReports.length > 0) {
154
+ // Overview card: logo and the "Factsheet" link button are added only when their source field
155
+ // exists, so a missing logo / executiveSummary degrades to a plain card (never a broken one).
156
+ // The Factsheet link references the typed `links.executiveSummary` rather than copying its URI.
72
157
  body.push({
73
158
  type: 'text',
74
- id: 'documents',
75
- title: 'Documents',
76
- body: ratingsWithReports
77
- .map((rating) => `- [${escapeMarkdownText(rating.agency ?? 'Rating')} rating report](${rating.reportFile.uri})`)
78
- .join('\n'),
159
+ id: 'overview',
160
+ title: 'Overview',
161
+ body: issuer.description,
162
+ ...(issuer.logo?.uri ? { logo: issuer.logo } : {}),
163
+ ...(pool.links?.executiveSummary?.uri
164
+ ? { links: [{ label: 'Factsheet', target: { kind: 'linkRef', linkRef: 'executiveSummary' } }] }
165
+ : {}),
79
166
  });
80
167
  }
168
+ asArray(details).forEach((detail, index) => {
169
+ body.push({ type: 'text', id: `detail-${index}`, title: detail.title, body: detail.body });
170
+ });
81
171
  body.push({ type: 'section', id: 'performance', ref: 'onchainMetrics' });
82
- // `sections` is omitted so the invest app renders its default sections.
83
- return { body, keyFacts };
172
+ // Full-width region. Rating reports become a `documents` tile block (ratings are also surfaced
173
+ // as the `ratings` key-fact ref pill above; both read the same typed `poolRatings`). `sections` is
174
+ // omitted (so the invest app renders its defaults) only when there is neither a documents block
175
+ // nor a holdings table.
176
+ const sections = [];
177
+ const documentsBlock = ratingDocumentsBlock(poolRatings);
178
+ if (documentsBlock)
179
+ sections.push(documentsBlock);
180
+ const holdingsBlock = holdingsTableBlock(holdings);
181
+ if (holdingsBlock)
182
+ sections.push(holdingsBlock);
183
+ return sections.length > 0 ? { body, keyFacts, sections } : { body, keyFacts };
84
184
  }
85
185
  /**
86
186
  * Pure, idempotent legacy -> v2 migration. Performs no IPFS/network access so it stays
87
- * unit-testable. Returns the input unchanged when it is already v2.
187
+ * unit-testable. A document already at the current v2 shape is returned unchanged; a v2 document
188
+ * written by an earlier SDK is normalized forward via {@link upgradeLegacyV2}.
88
189
  *
89
190
  * - Strips fields unused by every consumer (`newInvestmentsStatus`, `reports`, `loanTemplates`,
90
- * `issuer.repName/shortDescription`, onboarding extras) and reshapes `issuer`/`poolRatings`.
191
+ * `issuer.repName/shortDescription`, onboarding extras) and reshapes `issuer`. `poolRatings` is
192
+ * preserved verbatim as a typed field (surfaced via the `ratings` key-fact ref).
91
193
  * - Maps legacy APY modes across `shareClasses[*].apy`.
92
- * - Projects the legacy display fields into `pool.factsheet`.
194
+ * - Projects the legacy display fields into `pool.factsheet`, and folds the off-chain `holdings`
195
+ * blob into a `table` section (`id: 'holdings'`); `holdings` is not carried as a top-level field.
93
196
  */
94
197
  export function migratePoolMetadataToV2(legacy) {
95
198
  if (isPoolMetadataV2(legacy))
96
- return legacy;
97
- const { pool, shareClasses, onboarding, merkleProofManager, holdings, addressLabels, workflowPolicies } = legacy;
199
+ return upgradeLegacyV2(legacy);
200
+ const { pool, shareClasses, onboarding, merkleProofManager, holdings, addressLabels, workflowPolicies, withdrawManagers, } = legacy;
98
201
  const { issuer, poolRatings, ...restPool } = pool;
99
- // Explicitly drop fields that v2 does not carry.
202
+ // Explicitly drop fields that v2 does not carry. `report` (singular) and `poolStatus` are stray
203
+ // non-schema fields seen in some legacy documents (the real fields are `reports` and `status`).
100
204
  delete restPool.newInvestmentsStatus;
101
205
  delete restPool.reports;
102
206
  delete restPool.details;
207
+ delete restPool.report;
208
+ delete restPool.poolStatus;
103
209
  const migratedShareClasses = {};
104
210
  for (const [scId, shareClass] of Object.entries(shareClasses)) {
105
211
  migratedShareClasses[scId] = { ...shareClass, apy: migrateApyMode(shareClass.apy) };
@@ -109,8 +215,10 @@ export function migratePoolMetadataToV2(legacy) {
109
215
  pool: {
110
216
  ...restPool,
111
217
  issuer: { name: issuer.name, email: issuer.email, logo: issuer.logo },
112
- poolRatings: poolRatings?.map(({ reportFile: _reportFile, ...rating }) => rating),
113
- factsheet: buildFactsheet(pool, shareClasses),
218
+ // `poolRatings` stays a typed field verbatim (incl. reportFile); surfaced via the `ratings`
219
+ // key-fact ref. `holdings` is folded into the factsheet and not carried as a top-level field.
220
+ poolRatings,
221
+ factsheet: buildFactsheet(pool, shareClasses, holdings),
114
222
  },
115
223
  shareClasses: migratedShareClasses,
116
224
  ...(merkleProofManager !== undefined ? { merkleProofManager } : {}),
@@ -122,11 +230,106 @@ export function migratePoolMetadataToV2(legacy) {
122
230
  },
123
231
  }
124
232
  : {}),
125
- ...(holdings !== undefined ? { holdings } : {}),
126
233
  ...(addressLabels !== undefined ? { addressLabels } : {}),
127
234
  ...(workflowPolicies !== undefined ? { workflowPolicies } : {}),
235
+ ...(withdrawManagers !== undefined ? { withdrawManagers } : {}),
128
236
  };
129
237
  }
238
+ /* ================================================================================================
239
+ * Intra-v2 upgrade: normalize a v2 document written by an earlier SDK to the current v2 shape
240
+ * ============================================================================================== */
241
+ function isKeyFactGroup(value) {
242
+ return typeof value === 'object' && value !== null && value.type === 'keyFactGroup';
243
+ }
244
+ /** Converts an earlier-SDK flat KeyFact (`{ value?: string; valueRef?: 'apy' }`) to the discriminated shape. */
245
+ function upgradeKeyFact(old) {
246
+ const { value, valueRef, ...rest } = old;
247
+ let next;
248
+ if (valueRef === 'apy')
249
+ next = { kind: 'ref', ref: 'apy' };
250
+ else if (value !== null && typeof value === 'object' && 'kind' in value)
251
+ next = value;
252
+ else
253
+ next = { kind: 'text', text: typeof value === 'string' ? value : '' };
254
+ return { ...rest, value: next };
255
+ }
256
+ /** True if `item` is (or, for a tabGroup, contains) a chart still using the `series`/`dataRef` shape. */
257
+ function hasLegacyChart(item) {
258
+ if (typeof item !== 'object' || item === null)
259
+ return false;
260
+ const block = item;
261
+ if (block.type === 'chart') {
262
+ return block.data === undefined && (block.series !== undefined || block.dataRef !== undefined);
263
+ }
264
+ if (block.type === 'tabGroup' && Array.isArray(block.tabs)) {
265
+ return block.tabs.some((tab) => hasLegacyChart(tab?.block));
266
+ }
267
+ return false;
268
+ }
269
+ /** Converts an earlier-SDK chart block (`series?`/`dataRef?`) to the discriminated `data` field, in place. */
270
+ function upgradeChartBlockInPlace(block) {
271
+ if (block.type === 'chart' && block.data === undefined) {
272
+ if (Array.isArray(block.series))
273
+ block.data = { kind: 'inline', series: block.series };
274
+ else if (typeof block.dataRef === 'string')
275
+ block.data = { kind: 'ref', dataRef: block.dataRef };
276
+ delete block.series;
277
+ delete block.dataRef;
278
+ }
279
+ if (block.type === 'tabGroup' && Array.isArray(block.tabs)) {
280
+ for (const tab of block.tabs) {
281
+ const inner = tab?.block;
282
+ if (inner && typeof inner === 'object')
283
+ upgradeChartBlockInPlace(inner);
284
+ }
285
+ }
286
+ }
287
+ /**
288
+ * Normalizes a document already at `version: 2` but written by an earlier SDK whose v2 shape differs:
289
+ * a flat `keyFacts: KeyFact[]` (now `KeyFactGroup[]`), chart blocks with `series`/`dataRef` (now a
290
+ * discriminated `data`), and a top-level `holdings` blob (now a factsheet `table` section). Without
291
+ * this, `update()` would re-validate such a document against the current validator and throw, with no
292
+ * upgrade path. Returns the input unchanged (same reference) when it is already the current shape.
293
+ */
294
+ function upgradeLegacyV2(doc) {
295
+ const raw = doc;
296
+ const pool = (raw.pool ?? {});
297
+ const factsheet = pool.factsheet;
298
+ const keyFacts = factsheet?.keyFacts;
299
+ const layoutItems = [...asArray(factsheet?.body), ...asArray(factsheet?.sections)];
300
+ const needsKeyFactUpgrade = Array.isArray(keyFacts) && keyFacts.some((item) => !isKeyFactGroup(item));
301
+ const needsHoldingsFold = raw.holdings != null;
302
+ const needsChartUpgrade = layoutItems.some(hasLegacyChart);
303
+ if (!needsKeyFactUpgrade && !needsHoldingsFold && !needsChartUpgrade)
304
+ return doc;
305
+ const next = JSON.parse(JSON.stringify(doc));
306
+ const nextPool = next.pool;
307
+ const nextFactsheet = nextPool.factsheet;
308
+ if (nextFactsheet) {
309
+ if (needsKeyFactUpgrade) {
310
+ // Wrap the flat list into one titled "Key facts" group, converting each item's value.
311
+ const items = nextFactsheet.keyFacts.map(upgradeKeyFact);
312
+ nextFactsheet.keyFacts = [{ type: 'keyFactGroup', id: 'key-facts', title: 'Key facts', items }];
313
+ }
314
+ if (needsChartUpgrade) {
315
+ for (const item of [
316
+ ...asArray(nextFactsheet.body),
317
+ ...asArray(nextFactsheet.sections),
318
+ ]) {
319
+ if (item && typeof item === 'object')
320
+ upgradeChartBlockInPlace(item);
321
+ }
322
+ }
323
+ }
324
+ if (needsHoldingsFold) {
325
+ const holdingsBlock = holdingsTableBlock(raw.holdings);
326
+ delete next.holdings;
327
+ if (holdingsBlock && nextFactsheet) {
328
+ nextFactsheet.sections = [...asArray(nextFactsheet.sections), holdingsBlock];
329
+ }
330
+ }
331
+ return next;
332
+ }
130
333
  /* ================================================================================================
131
334
  * Validation: hand-rolled structural checks (mirrors src/utils/catalog.ts; no zod dependency)
132
335
  * ============================================================================================== */
@@ -135,7 +338,9 @@ export function migratePoolMetadataToV2(legacy) {
135
338
  // (see centrifuge/apps-invest#200), but the SDK is the alignment point: an unknown key is rejected
136
339
  // on WRITE so the management app, SDK, and invest app stay in sync (adding a key ships in an SDK
137
340
  // release). The read path does not validate, so a newer document never breaks an older reader.
138
- const VISIBILITIES = new Set(['public', 'whitelisted', 'hidden']);
341
+ const VISIBILITY_SCALARS = new Set(['public', 'hidden', 'whitelisted', 'geo-restricted']);
342
+ const VISIBILITY_GATES = new Set(['whitelisted', 'geo-restricted']);
343
+ const REGION_CODE_RE = /^[A-Za-z]{2}$/;
139
344
  const CHART_TYPES = new Set(['line', 'area', 'bar', 'donut']);
140
345
  const AXIS_FORMATS = new Set(['number', 'percent', 'currency']);
141
346
  const XAXIS_TYPES = new Set(['category', 'time', 'number']);
@@ -143,8 +348,19 @@ const DATA_REFS = new Set(['apyVsBenchmarks', 'maturityDistribution']);
143
348
  const SECTION_REFS = new Set(['onchainMetrics', 'smartContracts']);
144
349
  const LIVE_METRICS = new Set(['tokenPrice', 'nav', 'apy30d', 'navChange', 'monthlyReturn']);
145
350
  const LIVE_DATASETS = new Set(['monthlySummary']);
351
+ const KEY_FACT_REFS = new Set(['apy', 'availableNetworks', 'ratings']);
146
352
  const COLUMN_FORMATS = new Set(['text', 'number', 'percent', 'currency']);
147
- const CONTENT_BLOCK_TYPES = new Set(['text', 'table', 'chart', 'image', 'kpiGroup', 'tabGroup', 'liveTable']);
353
+ const CONTENT_BLOCK_TYPES = new Set([
354
+ 'text',
355
+ 'table',
356
+ 'chart',
357
+ 'image',
358
+ 'kpiGroup',
359
+ 'tabGroup',
360
+ 'liveTable',
361
+ 'documents',
362
+ 'accordion',
363
+ ]);
148
364
  const TAB_BLOCK_TYPES = new Set(['text', 'table', 'chart', 'kpiGroup']);
149
365
  const KPI_TRENDS = new Set(['up', 'down', 'neutral']);
150
366
  function fail(message) {
@@ -160,9 +376,27 @@ function assertString(value, where) {
160
376
  function assertVisibility(value, where) {
161
377
  if (value === undefined)
162
378
  return;
163
- if (typeof value !== 'string' || !VISIBILITIES.has(value)) {
164
- fail(`${where} has invalid visibility "${String(value)}"`);
379
+ if (typeof value === 'string') {
380
+ if (!VISIBILITY_SCALARS.has(value))
381
+ fail(`${where} has invalid visibility "${value}"`);
382
+ return;
165
383
  }
384
+ // An array combines gates with AND. `public`/`hidden` are scalars, never array members.
385
+ if (Array.isArray(value)) {
386
+ if (value.length === 0)
387
+ fail(`${where} visibility array must not be empty`);
388
+ const seen = new Set();
389
+ value.forEach((gate) => {
390
+ if (typeof gate !== 'string' || !VISIBILITY_GATES.has(gate)) {
391
+ fail(`${where} has an invalid visibility gate "${String(gate)}"`);
392
+ }
393
+ if (seen.has(gate))
394
+ fail(`${where} has a duplicate visibility gate "${gate}"`);
395
+ seen.add(gate);
396
+ });
397
+ return;
398
+ }
399
+ fail(`${where} has invalid visibility "${String(value)}"`);
166
400
  }
167
401
  function isPrimitiveCell(value) {
168
402
  return typeof value === 'string' || typeof value === 'number';
@@ -173,20 +407,117 @@ function validateFile(value, where) {
173
407
  assertString(value.uri, `${where}.uri`);
174
408
  assertString(value.mime, `${where}.mime`);
175
409
  }
410
+ const BUILTIN_LINK_KEYS = new Set(['executiveSummary', 'website', 'forum']);
411
+ function validateLinkDocuments(documents) {
412
+ if (!Array.isArray(documents))
413
+ fail('`pool.links.documents` must be an array');
414
+ const seen = new Set();
415
+ documents.forEach((doc, index) => {
416
+ const where = `pool.links.documents[${index}]`;
417
+ if (!isObject(doc))
418
+ fail(`${where} must be an object`);
419
+ if (typeof doc.key !== 'string' || doc.key.length === 0)
420
+ fail(`${where}.key must be a non-empty string`);
421
+ if (typeof doc.label !== 'string' || doc.label.length === 0)
422
+ fail(`${where}.label must be a non-empty string`);
423
+ // Exactly one of file (object) or non-empty href. A null file counts as absent.
424
+ const hasFile = isObject(doc.file);
425
+ const hasHref = typeof doc.href === 'string' && doc.href.length > 0;
426
+ if (hasFile === hasHref)
427
+ fail(`${where} must set exactly one of \`file\` or \`href\``);
428
+ if (hasFile)
429
+ validateFile(doc.file, `${where}.file`);
430
+ // Built-ins resolve first, so a colliding key would be ambiguous (unreachable).
431
+ if (BUILTIN_LINK_KEYS.has(doc.key))
432
+ fail(`${where}.key "${doc.key}" collides with a built-in link key`);
433
+ if (seen.has(doc.key))
434
+ fail(`${where}.key "${doc.key}" is duplicated`);
435
+ seen.add(doc.key);
436
+ });
437
+ }
438
+ function validateLinkTarget(value, where) {
439
+ if (!isObject(value))
440
+ fail(`${where} must be an object`);
441
+ switch (value.kind) {
442
+ case 'file':
443
+ validateFile(value.file, `${where}.file`);
444
+ break;
445
+ case 'href':
446
+ assertString(value.href, `${where}.href`);
447
+ break;
448
+ case 'linkRef':
449
+ // Resolved app-side against the typed `pool.links`; an unknown key hides the link (lenient).
450
+ assertString(value.linkRef, `${where}.linkRef`);
451
+ break;
452
+ default:
453
+ fail(`${where} has an invalid kind "${String(value.kind)}"`);
454
+ }
455
+ }
456
+ function validateIconRef(value, where) {
457
+ if (!isObject(value))
458
+ fail(`${where} must be an object`);
459
+ if (value.label !== undefined)
460
+ assertString(value.label, `${where}.label`);
461
+ switch (value.source) {
462
+ case 'metadata':
463
+ validateFile(value.file, `${where}.file`);
464
+ if (value.alt !== undefined)
465
+ assertString(value.alt, `${where}.alt`);
466
+ break;
467
+ case 'app':
468
+ // The app-owned icon-key registry is not yet enumerated, so validate shape (string) only.
469
+ assertString(value.key, `${where}.key`);
470
+ break;
471
+ default:
472
+ fail(`${where} has an invalid source "${String(value.source)}"`);
473
+ }
474
+ }
475
+ function validateKeyFactValue(value, where) {
476
+ if (!isObject(value))
477
+ fail(`${where} must be an object`);
478
+ switch (value.kind) {
479
+ case 'text':
480
+ assertString(value.text, `${where}.text`);
481
+ break;
482
+ case 'icons':
483
+ if (!Array.isArray(value.icons))
484
+ fail(`${where}.icons must be an array`);
485
+ value.icons.forEach((icon, index) => validateIconRef(icon, `${where}.icons[${index}]`));
486
+ break;
487
+ case 'ref':
488
+ // Closed, SDK-enforced registry (extended via a coordinated SDK release).
489
+ if (typeof value.ref !== 'string' || !KEY_FACT_REFS.has(value.ref)) {
490
+ fail(`${where}.ref is invalid (unknown ref "${String(value.ref)}")`);
491
+ }
492
+ break;
493
+ default:
494
+ fail(`${where} has an invalid kind "${String(value.kind)}"`);
495
+ }
496
+ }
176
497
  function validateKeyFact(value, where) {
177
498
  if (!isObject(value))
178
499
  fail(`${where} must be an object`);
179
500
  assertString(value.label, `${where}.label`);
180
- if (value.value !== undefined)
181
- assertString(value.value, `${where}.value`);
182
- if (value.valueRef !== undefined && value.valueRef !== 'apy')
183
- fail(`${where}.valueRef must be 'apy'`);
501
+ validateKeyFactValue(value.value, `${where}.value`);
184
502
  if (value.tooltip !== undefined)
185
503
  assertString(value.tooltip, `${where}.tooltip`);
186
504
  if (value.href !== undefined)
187
505
  assertString(value.href, `${where}.href`);
188
506
  assertVisibility(value.visibility, where);
189
507
  }
508
+ function validateKeyFactGroup(value, where) {
509
+ if (!isObject(value))
510
+ fail(`${where} must be an object`);
511
+ if (value.type !== 'keyFactGroup')
512
+ fail(`${where}.type must be 'keyFactGroup'`);
513
+ assertString(value.id, `${where}.id`);
514
+ if (value.title !== undefined)
515
+ assertString(value.title, `${where}.title`);
516
+ assertVisibility(value.visibility, where);
517
+ if (!Array.isArray(value.items))
518
+ fail(`${where}.items must be an array`);
519
+ value.items.forEach((item, index) => validateKeyFact(item, `${where}.items[${index}]`));
520
+ }
190
521
  function validateChartSeries(value, where) {
191
522
  if (!isObject(value))
192
523
  fail(`${where} must be an object`);
@@ -235,6 +566,21 @@ function validateContentBlock(block, type, where) {
235
566
  switch (type) {
236
567
  case 'text':
237
568
  assertString(block.body, `${where}.body`);
569
+ // Optional Overview-card extras.
570
+ if (block.logo !== undefined)
571
+ validateFile(block.logo, `${where}.logo`);
572
+ if (block.background !== undefined)
573
+ assertString(block.background, `${where}.background`);
574
+ if (block.links !== undefined) {
575
+ if (!Array.isArray(block.links))
576
+ fail(`${where}.links must be an array`);
577
+ block.links.forEach((link, index) => {
578
+ if (!isObject(link))
579
+ fail(`${where}.links[${index}] must be an object`);
580
+ assertString(link.label, `${where}.links[${index}].label`);
581
+ validateLinkTarget(link.target, `${where}.links[${index}].target`);
582
+ });
583
+ }
238
584
  break;
239
585
  case 'table': {
240
586
  if (!Array.isArray(block.headers) || !block.headers.every((h) => typeof h === 'string')) {
@@ -257,17 +603,23 @@ function validateContentBlock(block, type, where) {
257
603
  if (typeof block.chartType !== 'string' || !CHART_TYPES.has(block.chartType)) {
258
604
  fail(`${where}.chartType is invalid`);
259
605
  }
260
- const hasSeries = block.series !== undefined;
261
- const hasDataRef = block.dataRef !== undefined;
262
- if (hasSeries === hasDataRef)
263
- fail(`${where} must set exactly one of \`series\` or \`dataRef\``);
264
- if (hasSeries) {
265
- if (!Array.isArray(block.series))
266
- fail(`${where}.series must be an array`);
267
- block.series.forEach((s, index) => validateChartSeries(s, `${where}.series[${index}]`));
268
- }
269
- if (hasDataRef && (typeof block.dataRef !== 'string' || !DATA_REFS.has(block.dataRef))) {
270
- fail(`${where}.dataRef is invalid (unknown key "${String(block.dataRef)}")`);
606
+ // Data source is a single discriminated `data` (inline series XOR app/indexer dataRef).
607
+ if (!isObject(block.data))
608
+ fail(`${where}.data must be an object`);
609
+ switch (block.data.kind) {
610
+ case 'inline':
611
+ if (!Array.isArray(block.data.series))
612
+ fail(`${where}.data.series must be an array`);
613
+ block.data.series.forEach((s, index) => validateChartSeries(s, `${where}.data.series[${index}]`));
614
+ break;
615
+ case 'ref':
616
+ // Closed, SDK-enforced registry (extended via a coordinated SDK release).
617
+ if (typeof block.data.dataRef !== 'string' || !DATA_REFS.has(block.data.dataRef)) {
618
+ fail(`${where}.data.dataRef is invalid (unknown key "${String(block.data.dataRef)}")`);
619
+ }
620
+ break;
621
+ default:
622
+ fail(`${where}.data has an invalid kind "${String(block.data.kind)}"`);
271
623
  }
272
624
  if (isObject(block.xAxis) && block.xAxis.type !== undefined && !XAXIS_TYPES.has(block.xAxis.type)) {
273
625
  fail(`${where}.xAxis.type is invalid`);
@@ -307,14 +659,7 @@ function validateContentBlock(block, type, where) {
307
659
  if (!isObject(tab))
308
660
  fail(`${where}.tabs[${index}] must be an object`);
309
661
  assertString(tab.label, `${where}.tabs[${index}].label`);
310
- if (!isObject(tab.block))
311
- fail(`${where}.tabs[${index}].block must be an object`);
312
- const tabBlock = tab.block;
313
- if (typeof tabBlock.type !== 'string' || !TAB_BLOCK_TYPES.has(tabBlock.type)) {
314
- fail(`${where}.tabs[${index}].block has an invalid type "${String(tabBlock.type)}"`);
315
- }
316
- assertString(tabBlock.id, `${where}.tabs[${index}].block.id`);
317
- validateContentBlock(tabBlock, tabBlock.type, `${where}.tabs[${index}].block`);
662
+ validateTabBlock(tab.block, `${where}.tabs[${index}].block`);
318
663
  });
319
664
  break;
320
665
  }
@@ -327,10 +672,45 @@ function validateContentBlock(block, type, where) {
327
672
  block.columns.forEach((column, index) => validateLiveColumn(column, `${where}.columns[${index}]`));
328
673
  break;
329
674
  }
675
+ case 'documents': {
676
+ if (!Array.isArray(block.items))
677
+ fail(`${where}.items must be an array`);
678
+ block.items.forEach((item, index) => {
679
+ if (!isObject(item))
680
+ fail(`${where}.items[${index}] must be an object`);
681
+ assertString(item.title, `${where}.items[${index}].title`);
682
+ validateLinkTarget(item.target, `${where}.items[${index}].target`);
683
+ });
684
+ break;
685
+ }
686
+ case 'accordion': {
687
+ if (!Array.isArray(block.items))
688
+ fail(`${where}.items must be an array`);
689
+ block.items.forEach((item, index) => {
690
+ if (!isObject(item))
691
+ fail(`${where}.items[${index}] must be an object`);
692
+ assertString(item.title, `${where}.items[${index}].title`);
693
+ if (item.defaultOpen !== undefined && typeof item.defaultOpen !== 'boolean') {
694
+ fail(`${where}.items[${index}].defaultOpen must be a boolean`);
695
+ }
696
+ validateTabBlock(item.block, `${where}.items[${index}].block`);
697
+ });
698
+ break;
699
+ }
330
700
  default:
331
701
  fail(`${where} has an unknown block type "${type}"`);
332
702
  }
333
703
  }
704
+ /** Validates a leaf block nested in a `tabGroup` tab or `accordion` item (the {@link TabBlock} set). */
705
+ function validateTabBlock(value, where) {
706
+ if (!isObject(value))
707
+ fail(`${where} must be an object`);
708
+ if (typeof value.type !== 'string' || !TAB_BLOCK_TYPES.has(value.type)) {
709
+ fail(`${where} has an invalid type "${String(value.type)}"`);
710
+ }
711
+ assertString(value.id, `${where}.id`);
712
+ validateContentBlock(value, value.type, where);
713
+ }
334
714
  function validateLayoutItem(item, where) {
335
715
  if (!isObject(item))
336
716
  fail(`${where} must be an object`);
@@ -357,7 +737,7 @@ function validateFactsheet(factsheet) {
357
737
  factsheet.body.forEach((item, index) => validateLayoutItem(item, `pool.factsheet.body[${index}]`));
358
738
  if (!Array.isArray(factsheet.keyFacts))
359
739
  fail('`pool.factsheet.keyFacts` must be an array');
360
- factsheet.keyFacts.forEach((item, index) => validateKeyFact(item, `pool.factsheet.keyFacts[${index}]`));
740
+ factsheet.keyFacts.forEach((group, index) => validateKeyFactGroup(group, `pool.factsheet.keyFacts[${index}]`));
361
741
  if (factsheet.sections !== undefined) {
362
742
  if (!Array.isArray(factsheet.sections))
363
743
  fail('`pool.factsheet.sections` must be an array');
@@ -368,7 +748,7 @@ function validateFactsheet(factsheet) {
368
748
  * Hand-rolled structural validator for v2 pool metadata, in the style of
369
749
  * {@link parseMarketplaceCatalog}. Validates the discriminant (`version === 2`), `pool.name`, and
370
750
  * the full `pool.factsheet` content model (the layout the SDK is responsible for). Engine fields
371
- * (`shareClasses`, `workflowPolicies`, `holdings`, `addressLabels`, …) are passed through
751
+ * (`shareClasses`, `workflowPolicies`, `addressLabels`, …) are passed through
372
752
  * unchecked here — they are validated where they are consumed (e.g. workflows via
373
753
  * {@link parseMarketplaceCatalog}). Throws `pool metadata v2: …` on the first malformed field;
374
754
  * tolerates missing optionals (visibility defaults to `'public'` app-side). Returns the input
@@ -382,8 +762,58 @@ export function parsePoolMetadataV2(raw) {
382
762
  if (!isObject(raw.pool))
383
763
  fail('`pool` must be an object');
384
764
  assertString(raw.pool.name, '`pool.name`');
765
+ if (raw.pool.geoRestrictions !== undefined)
766
+ validateGeoRestrictions(raw.pool.geoRestrictions);
767
+ if (isObject(raw.pool.links) && raw.pool.links.documents !== undefined) {
768
+ validateLinkDocuments(raw.pool.links.documents);
769
+ }
385
770
  if (raw.pool.factsheet !== undefined)
386
771
  validateFactsheet(raw.pool.factsheet);
387
772
  return raw;
388
773
  }
774
+ /**
775
+ * Resolves a {@link LinkTarget} to a `{ href }` or `{ file }` against a pool's `links`. `linkRef`
776
+ * checks the built-in keys first (`executiveSummary` -> file, `website`/`forum` -> url), then
777
+ * `links.documents` by `key`. Returns `null` on no match (callers self-blank; never throw). Shared
778
+ * so the SDK and the invest app resolve references identically.
779
+ */
780
+ export function resolveLinkTarget(links, target) {
781
+ switch (target.kind) {
782
+ case 'file':
783
+ return { file: target.file };
784
+ case 'href':
785
+ return { href: target.href };
786
+ case 'linkRef': {
787
+ const key = target.linkRef;
788
+ if (key === 'executiveSummary')
789
+ return links?.executiveSummary ? { file: links.executiveSummary } : null;
790
+ if (key === 'website')
791
+ return links?.website ? { href: links.website } : null;
792
+ if (key === 'forum')
793
+ return links?.forum ? { href: links.forum } : null;
794
+ const doc = links?.documents?.find((d) => d.key === key);
795
+ if (!doc)
796
+ return null;
797
+ if (doc.file)
798
+ return { file: doc.file };
799
+ if (doc.href)
800
+ return { href: doc.href };
801
+ return null;
802
+ }
803
+ default:
804
+ return null;
805
+ }
806
+ }
807
+ /** Validates `pool.geoRestrictions.regions` as ISO 3166-1 alpha-2 codes (format only, strict on write). */
808
+ function validateGeoRestrictions(geoRestrictions) {
809
+ if (!isObject(geoRestrictions))
810
+ fail('`pool.geoRestrictions` must be an object');
811
+ if (!Array.isArray(geoRestrictions.regions))
812
+ fail('`pool.geoRestrictions.regions` must be an array');
813
+ geoRestrictions.regions.forEach((region, index) => {
814
+ if (typeof region !== 'string' || !REGION_CODE_RE.test(region)) {
815
+ fail(`pool.geoRestrictions.regions[${index}] must be an ISO 3166-1 alpha-2 code, got "${String(region)}"`);
816
+ }
817
+ });
818
+ }
389
819
  //# sourceMappingURL=poolMetadataMigration.js.map