@centrifuge/sdk 1.15.3 → 1.17.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.
Files changed (38) hide show
  1. package/dist/Centrifuge.d.ts.map +1 -1
  2. package/dist/Centrifuge.js +4 -23
  3. package/dist/Centrifuge.js.map +1 -1
  4. package/dist/Centrifuge.test.js +0 -3
  5. package/dist/Centrifuge.test.js.map +1 -1
  6. package/dist/entities/Pool.d.ts +12 -1
  7. package/dist/entities/Pool.d.ts.map +1 -1
  8. package/dist/entities/Pool.js +51 -32
  9. package/dist/entities/Pool.js.map +1 -1
  10. package/dist/entities/Pool.metadataGate.test.d.ts +2 -0
  11. package/dist/entities/Pool.metadataGate.test.d.ts.map +1 -0
  12. package/dist/entities/Pool.metadataGate.test.js +23 -0
  13. package/dist/entities/Pool.metadataGate.test.js.map +1 -0
  14. package/dist/entities/Pool.test.js +26 -3
  15. package/dist/entities/Pool.test.js.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/tests/mocks/mockPoolMetadataV2.d.ts +7 -0
  21. package/dist/tests/mocks/mockPoolMetadataV2.d.ts.map +1 -0
  22. package/dist/tests/mocks/mockPoolMetadataV2.js +200 -0
  23. package/dist/tests/mocks/mockPoolMetadataV2.js.map +1 -0
  24. package/dist/types/poolInput.d.ts +17 -21
  25. package/dist/types/poolInput.d.ts.map +1 -1
  26. package/dist/types/poolMetadata.d.ts +301 -1
  27. package/dist/types/poolMetadata.d.ts.map +1 -1
  28. package/dist/types/poolMetadata.js +7 -1
  29. package/dist/types/poolMetadata.js.map +1 -1
  30. package/dist/utils/poolMetadataMigration.d.ts +26 -0
  31. package/dist/utils/poolMetadataMigration.d.ts.map +1 -0
  32. package/dist/utils/poolMetadataMigration.js +633 -0
  33. package/dist/utils/poolMetadataMigration.js.map +1 -0
  34. package/dist/utils/poolMetadataMigration.test.d.ts +2 -0
  35. package/dist/utils/poolMetadataMigration.test.d.ts.map +1 -0
  36. package/dist/utils/poolMetadataMigration.test.js +484 -0
  37. package/dist/utils/poolMetadataMigration.test.js.map +1 -0
  38. package/package.json +2 -2
@@ -0,0 +1,26 @@
1
+ import { type PoolMetadata, type PoolMetadataV2 } from '../types/poolMetadata.js';
2
+ /**
3
+ * Pure, idempotent legacy -> v2 migration. Performs no IPFS/network access so it stays
4
+ * unit-testable. A document already at the current v2 shape is returned unchanged; a v2 document
5
+ * written by an earlier SDK is normalized forward via {@link upgradeLegacyV2}.
6
+ *
7
+ * - Strips fields unused by every consumer (`newInvestmentsStatus`, `reports`, `loanTemplates`,
8
+ * `issuer.repName/shortDescription`, onboarding extras) and reshapes `issuer`. `poolRatings` is
9
+ * preserved verbatim as a typed field (surfaced via the `ratings` key-fact ref).
10
+ * - Maps legacy APY modes across `shareClasses[*].apy`.
11
+ * - Projects the legacy display fields into `pool.factsheet`, and folds the off-chain `holdings`
12
+ * blob into a `table` section (`id: 'holdings'`); `holdings` is not carried as a top-level field.
13
+ */
14
+ export declare function migratePoolMetadataToV2(legacy: PoolMetadata): PoolMetadataV2;
15
+ /**
16
+ * Hand-rolled structural validator for v2 pool metadata, in the style of
17
+ * {@link parseMarketplaceCatalog}. Validates the discriminant (`version === 2`), `pool.name`, and
18
+ * the full `pool.factsheet` content model (the layout the SDK is responsible for). Engine fields
19
+ * (`shareClasses`, `workflowPolicies`, `addressLabels`, …) are passed through
20
+ * unchecked here — they are validated where they are consumed (e.g. workflows via
21
+ * {@link parseMarketplaceCatalog}). Throws `pool metadata v2: …` on the first malformed field;
22
+ * tolerates missing optionals (visibility defaults to `'public'` app-side). Returns the input
23
+ * narrowed to {@link PoolMetadataV2} on success.
24
+ */
25
+ export declare function parsePoolMetadataV2(raw: unknown): PoolMetadataV2;
26
+ //# sourceMappingURL=poolMetadataMigration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"poolMetadataMigration.d.ts","sourceRoot":"","sources":["../../src/utils/poolMetadataMigration.ts"],"names":[],"mappings":"AACA,OAAO,EAeL,KAAK,YAAY,EAEjB,KAAK,cAAc,EAIpB,MAAM,0BAA0B,CAAA;AAoMjC;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,YAAY,GAAG,cAAc,CAmD5E;AA4XD;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,cAAc,CAOhE"}
@@ -0,0 +1,633 @@
1
+ import { isPoolMetadataV2, } from '../types/poolMetadata.js';
2
+ /* ================================================================================================
3
+ * Migration: legacy (v1) -> v2
4
+ * ============================================================================================== */
5
+ /**
6
+ * Legacy APY display modes that the invest app no longer recognizes, mapped to their nearest v2
7
+ * equivalent. `target` becomes the hardcoded `fixed` display; the rolling windows pick the 365-day
8
+ * basis; `automatic` defaults to the 30-day window.
9
+ */
10
+ const LEGACY_APY_MODE_MAP = {
11
+ target: 'fixed',
12
+ '7day': '7d365',
13
+ '30day': '30d365',
14
+ '90day': '90d365',
15
+ automatic: '30d365',
16
+ };
17
+ function migrateApyMode(apy) {
18
+ if (apy == null)
19
+ return apy;
20
+ return LEGACY_APY_MODE_MAP[apy] ?? apy;
21
+ }
22
+ function formatMinInvestment(value) {
23
+ return value.toLocaleString('en-US');
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']);
73
+ /** Document links only carry web/IPFS URIs; anything else (e.g. `javascript:`) is dropped. */
74
+ function isSafeDocumentUri(uri) {
75
+ const scheme = uri.trim().toLowerCase();
76
+ return scheme.startsWith('https://') || scheme.startsWith('ipfs://');
77
+ }
78
+ /** Escapes markdown link-breaking characters so an issuer-supplied label can't alter the link. */
79
+ function escapeMarkdownText(text) {
80
+ return text.replace(/[\\[\]()]/g, '\\$&').replace(/\s+/g, ' ');
81
+ }
82
+ /**
83
+ * Builds the seed `factsheet` from the legacy display fields. The migration is content-translating:
84
+ * a migrated pool must render the same content, so legacy fields are projected into ordered key
85
+ * facts + body blocks. The output is a sensible, ops-editable seed, not a frozen contract.
86
+ */
87
+ function buildFactsheet(pool, shareClasses, holdings) {
88
+ const { issuer, asset, poolStructure, expenseRatio, details, poolRatings } = pool;
89
+ const firstShareClass = Object.values(shareClasses)[0];
90
+ // Trim text key-fact values (issuer/category strings sometimes carry stray whitespace).
91
+ const text = (value) => ({ kind: 'text', text: value.trim() });
92
+ const keyFactItems = [
93
+ { label: 'Issuer', value: text(issuer.name) },
94
+ { label: 'Asset type', value: text(`${asset.class} - ${asset.subClass}`) },
95
+ { label: 'APY', value: { kind: 'ref', ref: 'apy' } },
96
+ { label: 'Pool structure', value: text(poolStructure) },
97
+ ];
98
+ const weightedAverageMaturity = firstShareClass?.weightedAverageMaturity;
99
+ if (weightedAverageMaturity != null) {
100
+ keyFactItems.push({ label: 'Average asset maturity', value: text(`${weightedAverageMaturity} days`) });
101
+ }
102
+ if (expenseRatio != null && expenseRatio !== '') {
103
+ keyFactItems.push({ label: 'Expense ratio', value: text(`${expenseRatio}%`) });
104
+ }
105
+ const minInitialInvestment = firstShareClass?.minInitialInvestment;
106
+ if (minInitialInvestment != null) {
107
+ keyFactItems.push({ label: 'Min. investment', value: text(formatMinInvestment(minInitialInvestment)) });
108
+ }
109
+ const serviceProviderItems = [];
110
+ for (const category of asArray(issuer.categories)) {
111
+ const key = normalizeCategoryType(category.type);
112
+ if (ABSORBED_CATEGORY_TYPES.has(key))
113
+ continue;
114
+ const label = SERVICE_PROVIDER_CATEGORIES.get(key);
115
+ if (label) {
116
+ serviceProviderItems.push({ label, value: text(category.value) });
117
+ }
118
+ else {
119
+ keyFactItems.push({ label: category.type, value: text(category.value) });
120
+ }
121
+ }
122
+ // Seeded unconditionally because v1 has no source field for either: the app derives the deployed
123
+ // chains, and wallet infrastructure is currently Fordefi for all pools. Ops can edit/remove after.
124
+ keyFactItems.push({
125
+ label: 'Wallet infrastructure',
126
+ value: { kind: 'icons', icons: [{ source: 'app', key: 'fordefi' }] },
127
+ });
128
+ keyFactItems.push({ label: 'Available networks', value: { kind: 'ref', ref: 'availableNetworks' } });
129
+ if (asArray(poolRatings).length > 0) {
130
+ keyFactItems.push({ label: 'Ratings', value: { kind: 'ref', ref: 'ratings' } });
131
+ }
132
+ // Right column is groups only (no bare key facts). The "Service providers" group is added only
133
+ // when at least one category mapped to it.
134
+ const keyFacts = [{ type: 'keyFactGroup', id: 'key-facts', title: 'Key facts', items: keyFactItems }];
135
+ if (serviceProviderItems.length > 0) {
136
+ keyFacts.push({
137
+ type: 'keyFactGroup',
138
+ id: 'service-providers',
139
+ title: 'Service providers',
140
+ items: serviceProviderItems,
141
+ });
142
+ }
143
+ const body = [];
144
+ if (issuer.description) {
145
+ body.push({ type: 'text', id: 'overview', title: 'Overview', body: issuer.description });
146
+ }
147
+ asArray(details).forEach((detail, index) => {
148
+ body.push({ type: 'text', id: `detail-${index}`, title: detail.title, body: detail.body });
149
+ });
150
+ // Ratings reports are surfaced two ways: a Documents body block (markdown links to the report
151
+ // PDFs) and the `ratings` key-fact ref pill above. Both read the same typed `poolRatings` field.
152
+ const ratingsWithReports = asArray(poolRatings).filter((rating) => rating.reportFile?.uri && isSafeDocumentUri(rating.reportFile.uri));
153
+ if (ratingsWithReports.length > 0) {
154
+ body.push({
155
+ type: 'text',
156
+ id: 'documents',
157
+ title: 'Documents',
158
+ body: ratingsWithReports
159
+ .map((rating) => `- [${escapeMarkdownText(rating.agency ?? 'Rating')} rating report](${rating.reportFile.uri})`)
160
+ .join('\n'),
161
+ });
162
+ }
163
+ body.push({ type: 'section', id: 'performance', ref: 'onchainMetrics' });
164
+ const holdingsBlock = holdingsTableBlock(holdings);
165
+ // `sections` is omitted (so the invest app renders its defaults) unless we have a holdings table.
166
+ return holdingsBlock ? { body, keyFacts, sections: [holdingsBlock] } : { body, keyFacts };
167
+ }
168
+ /**
169
+ * Pure, idempotent legacy -> v2 migration. Performs no IPFS/network access so it stays
170
+ * unit-testable. A document already at the current v2 shape is returned unchanged; a v2 document
171
+ * written by an earlier SDK is normalized forward via {@link upgradeLegacyV2}.
172
+ *
173
+ * - Strips fields unused by every consumer (`newInvestmentsStatus`, `reports`, `loanTemplates`,
174
+ * `issuer.repName/shortDescription`, onboarding extras) and reshapes `issuer`. `poolRatings` is
175
+ * preserved verbatim as a typed field (surfaced via the `ratings` key-fact ref).
176
+ * - Maps legacy APY modes across `shareClasses[*].apy`.
177
+ * - Projects the legacy display fields into `pool.factsheet`, and folds the off-chain `holdings`
178
+ * blob into a `table` section (`id: 'holdings'`); `holdings` is not carried as a top-level field.
179
+ */
180
+ export function migratePoolMetadataToV2(legacy) {
181
+ if (isPoolMetadataV2(legacy))
182
+ return upgradeLegacyV2(legacy);
183
+ const { pool, shareClasses, onboarding, merkleProofManager, holdings, addressLabels, workflowPolicies, withdrawManagers, } = legacy;
184
+ const { issuer, poolRatings, ...restPool } = pool;
185
+ // Explicitly drop fields that v2 does not carry. `report` (singular) and `poolStatus` are stray
186
+ // non-schema fields seen in some legacy documents (the real fields are `reports` and `status`).
187
+ delete restPool.newInvestmentsStatus;
188
+ delete restPool.reports;
189
+ delete restPool.details;
190
+ delete restPool.report;
191
+ delete restPool.poolStatus;
192
+ const migratedShareClasses = {};
193
+ for (const [scId, shareClass] of Object.entries(shareClasses)) {
194
+ migratedShareClasses[scId] = { ...shareClass, apy: migrateApyMode(shareClass.apy) };
195
+ }
196
+ return {
197
+ version: 2,
198
+ pool: {
199
+ ...restPool,
200
+ issuer: { name: issuer.name, email: issuer.email, logo: issuer.logo },
201
+ // `poolRatings` stays a typed field verbatim (incl. reportFile); surfaced via the `ratings`
202
+ // key-fact ref. `holdings` is folded into the factsheet and not carried as a top-level field.
203
+ poolRatings,
204
+ factsheet: buildFactsheet(pool, shareClasses, holdings),
205
+ },
206
+ shareClasses: migratedShareClasses,
207
+ ...(merkleProofManager !== undefined ? { merkleProofManager } : {}),
208
+ ...(onboarding !== undefined
209
+ ? {
210
+ onboarding: {
211
+ kycRestrictedCountries: onboarding.kycRestrictedCountries,
212
+ kybRestrictedCountries: onboarding.kybRestrictedCountries,
213
+ },
214
+ }
215
+ : {}),
216
+ ...(addressLabels !== undefined ? { addressLabels } : {}),
217
+ ...(workflowPolicies !== undefined ? { workflowPolicies } : {}),
218
+ ...(withdrawManagers !== undefined ? { withdrawManagers } : {}),
219
+ };
220
+ }
221
+ /* ================================================================================================
222
+ * Intra-v2 upgrade: normalize a v2 document written by an earlier SDK to the current v2 shape
223
+ * ============================================================================================== */
224
+ function isKeyFactGroup(value) {
225
+ return typeof value === 'object' && value !== null && value.type === 'keyFactGroup';
226
+ }
227
+ /** Converts an earlier-SDK flat KeyFact (`{ value?: string; valueRef?: 'apy' }`) to the discriminated shape. */
228
+ function upgradeKeyFact(old) {
229
+ const { value, valueRef, ...rest } = old;
230
+ let next;
231
+ if (valueRef === 'apy')
232
+ next = { kind: 'ref', ref: 'apy' };
233
+ else if (value !== null && typeof value === 'object' && 'kind' in value)
234
+ next = value;
235
+ else
236
+ next = { kind: 'text', text: typeof value === 'string' ? value : '' };
237
+ return { ...rest, value: next };
238
+ }
239
+ /** True if `item` is (or, for a tabGroup, contains) a chart still using the `series`/`dataRef` shape. */
240
+ function hasLegacyChart(item) {
241
+ if (typeof item !== 'object' || item === null)
242
+ return false;
243
+ const block = item;
244
+ if (block.type === 'chart') {
245
+ return block.data === undefined && (block.series !== undefined || block.dataRef !== undefined);
246
+ }
247
+ if (block.type === 'tabGroup' && Array.isArray(block.tabs)) {
248
+ return block.tabs.some((tab) => hasLegacyChart(tab?.block));
249
+ }
250
+ return false;
251
+ }
252
+ /** Converts an earlier-SDK chart block (`series?`/`dataRef?`) to the discriminated `data` field, in place. */
253
+ function upgradeChartBlockInPlace(block) {
254
+ if (block.type === 'chart' && block.data === undefined) {
255
+ if (Array.isArray(block.series))
256
+ block.data = { kind: 'inline', series: block.series };
257
+ else if (typeof block.dataRef === 'string')
258
+ block.data = { kind: 'ref', dataRef: block.dataRef };
259
+ delete block.series;
260
+ delete block.dataRef;
261
+ }
262
+ if (block.type === 'tabGroup' && Array.isArray(block.tabs)) {
263
+ for (const tab of block.tabs) {
264
+ const inner = tab?.block;
265
+ if (inner && typeof inner === 'object')
266
+ upgradeChartBlockInPlace(inner);
267
+ }
268
+ }
269
+ }
270
+ /**
271
+ * Normalizes a document already at `version: 2` but written by an earlier SDK whose v2 shape differs:
272
+ * a flat `keyFacts: KeyFact[]` (now `KeyFactGroup[]`), chart blocks with `series`/`dataRef` (now a
273
+ * discriminated `data`), and a top-level `holdings` blob (now a factsheet `table` section). Without
274
+ * this, `update()` would re-validate such a document against the current validator and throw, with no
275
+ * upgrade path. Returns the input unchanged (same reference) when it is already the current shape.
276
+ */
277
+ function upgradeLegacyV2(doc) {
278
+ const raw = doc;
279
+ const pool = (raw.pool ?? {});
280
+ const factsheet = pool.factsheet;
281
+ const keyFacts = factsheet?.keyFacts;
282
+ const layoutItems = [...asArray(factsheet?.body), ...asArray(factsheet?.sections)];
283
+ const needsKeyFactUpgrade = Array.isArray(keyFacts) && keyFacts.some((item) => !isKeyFactGroup(item));
284
+ const needsHoldingsFold = raw.holdings != null;
285
+ const needsChartUpgrade = layoutItems.some(hasLegacyChart);
286
+ if (!needsKeyFactUpgrade && !needsHoldingsFold && !needsChartUpgrade)
287
+ return doc;
288
+ const next = JSON.parse(JSON.stringify(doc));
289
+ const nextPool = next.pool;
290
+ const nextFactsheet = nextPool.factsheet;
291
+ if (nextFactsheet) {
292
+ if (needsKeyFactUpgrade) {
293
+ // Wrap the flat list into one titled "Key facts" group, converting each item's value.
294
+ const items = nextFactsheet.keyFacts.map(upgradeKeyFact);
295
+ nextFactsheet.keyFacts = [{ type: 'keyFactGroup', id: 'key-facts', title: 'Key facts', items }];
296
+ }
297
+ if (needsChartUpgrade) {
298
+ for (const item of [
299
+ ...asArray(nextFactsheet.body),
300
+ ...asArray(nextFactsheet.sections),
301
+ ]) {
302
+ if (item && typeof item === 'object')
303
+ upgradeChartBlockInPlace(item);
304
+ }
305
+ }
306
+ }
307
+ if (needsHoldingsFold) {
308
+ const holdingsBlock = holdingsTableBlock(raw.holdings);
309
+ delete next.holdings;
310
+ if (holdingsBlock && nextFactsheet) {
311
+ nextFactsheet.sections = [...asArray(nextFactsheet.sections), holdingsBlock];
312
+ }
313
+ }
314
+ return next;
315
+ }
316
+ /* ================================================================================================
317
+ * Validation: hand-rolled structural checks (mirrors src/utils/catalog.ts; no zod dependency)
318
+ * ============================================================================================== */
319
+ // All enums below are validated as closed sets. The app-owned live-data key registries
320
+ // (`dataRef` / `LiveMetric` / `LiveDataset`) and `section.ref` are PROVISIONAL and expected to grow
321
+ // (see centrifuge/apps-invest#200), but the SDK is the alignment point: an unknown key is rejected
322
+ // on WRITE so the management app, SDK, and invest app stay in sync (adding a key ships in an SDK
323
+ // release). The read path does not validate, so a newer document never breaks an older reader.
324
+ const VISIBILITIES = new Set(['public', 'whitelisted', 'hidden']);
325
+ const CHART_TYPES = new Set(['line', 'area', 'bar', 'donut']);
326
+ const AXIS_FORMATS = new Set(['number', 'percent', 'currency']);
327
+ const XAXIS_TYPES = new Set(['category', 'time', 'number']);
328
+ const DATA_REFS = new Set(['apyVsBenchmarks', 'maturityDistribution']);
329
+ const SECTION_REFS = new Set(['onchainMetrics', 'smartContracts']);
330
+ const LIVE_METRICS = new Set(['tokenPrice', 'nav', 'apy30d', 'navChange', 'monthlyReturn']);
331
+ const LIVE_DATASETS = new Set(['monthlySummary']);
332
+ const KEY_FACT_REFS = new Set(['apy', 'availableNetworks', 'ratings']);
333
+ const COLUMN_FORMATS = new Set(['text', 'number', 'percent', 'currency']);
334
+ const CONTENT_BLOCK_TYPES = new Set(['text', 'table', 'chart', 'image', 'kpiGroup', 'tabGroup', 'liveTable']);
335
+ const TAB_BLOCK_TYPES = new Set(['text', 'table', 'chart', 'kpiGroup']);
336
+ const KPI_TRENDS = new Set(['up', 'down', 'neutral']);
337
+ function fail(message) {
338
+ throw new Error(`pool metadata v2: ${message}`);
339
+ }
340
+ function isObject(value) {
341
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
342
+ }
343
+ function assertString(value, where) {
344
+ if (typeof value !== 'string')
345
+ fail(`${where} must be a string`);
346
+ }
347
+ function assertVisibility(value, where) {
348
+ if (value === undefined)
349
+ return;
350
+ if (typeof value !== 'string' || !VISIBILITIES.has(value)) {
351
+ fail(`${where} has invalid visibility "${String(value)}"`);
352
+ }
353
+ }
354
+ function isPrimitiveCell(value) {
355
+ return typeof value === 'string' || typeof value === 'number';
356
+ }
357
+ function validateFile(value, where) {
358
+ if (!isObject(value))
359
+ fail(`${where} must be a file object`);
360
+ assertString(value.uri, `${where}.uri`);
361
+ assertString(value.mime, `${where}.mime`);
362
+ }
363
+ function validateIconRef(value, where) {
364
+ if (!isObject(value))
365
+ fail(`${where} must be an object`);
366
+ if (value.label !== undefined)
367
+ assertString(value.label, `${where}.label`);
368
+ switch (value.source) {
369
+ case 'metadata':
370
+ validateFile(value.file, `${where}.file`);
371
+ if (value.alt !== undefined)
372
+ assertString(value.alt, `${where}.alt`);
373
+ break;
374
+ case 'app':
375
+ // The app-owned icon-key registry is not yet enumerated, so validate shape (string) only.
376
+ assertString(value.key, `${where}.key`);
377
+ break;
378
+ default:
379
+ fail(`${where} has an invalid source "${String(value.source)}"`);
380
+ }
381
+ }
382
+ function validateKeyFactValue(value, where) {
383
+ if (!isObject(value))
384
+ fail(`${where} must be an object`);
385
+ switch (value.kind) {
386
+ case 'text':
387
+ assertString(value.text, `${where}.text`);
388
+ break;
389
+ case 'icons':
390
+ if (!Array.isArray(value.icons))
391
+ fail(`${where}.icons must be an array`);
392
+ value.icons.forEach((icon, index) => validateIconRef(icon, `${where}.icons[${index}]`));
393
+ break;
394
+ case 'ref':
395
+ // Closed, SDK-enforced registry (extended via a coordinated SDK release).
396
+ if (typeof value.ref !== 'string' || !KEY_FACT_REFS.has(value.ref)) {
397
+ fail(`${where}.ref is invalid (unknown ref "${String(value.ref)}")`);
398
+ }
399
+ break;
400
+ default:
401
+ fail(`${where} has an invalid kind "${String(value.kind)}"`);
402
+ }
403
+ }
404
+ function validateKeyFact(value, where) {
405
+ if (!isObject(value))
406
+ fail(`${where} must be an object`);
407
+ assertString(value.label, `${where}.label`);
408
+ validateKeyFactValue(value.value, `${where}.value`);
409
+ if (value.tooltip !== undefined)
410
+ assertString(value.tooltip, `${where}.tooltip`);
411
+ if (value.href !== undefined)
412
+ assertString(value.href, `${where}.href`);
413
+ assertVisibility(value.visibility, where);
414
+ }
415
+ function validateKeyFactGroup(value, where) {
416
+ if (!isObject(value))
417
+ fail(`${where} must be an object`);
418
+ if (value.type !== 'keyFactGroup')
419
+ fail(`${where}.type must be 'keyFactGroup'`);
420
+ assertString(value.id, `${where}.id`);
421
+ if (value.title !== undefined)
422
+ assertString(value.title, `${where}.title`);
423
+ assertVisibility(value.visibility, where);
424
+ if (!Array.isArray(value.items))
425
+ fail(`${where}.items must be an array`);
426
+ value.items.forEach((item, index) => validateKeyFact(item, `${where}.items[${index}]`));
427
+ }
428
+ function validateChartSeries(value, where) {
429
+ if (!isObject(value))
430
+ fail(`${where} must be an object`);
431
+ assertString(value.name, `${where}.name`);
432
+ if (value.color !== undefined)
433
+ assertString(value.color, `${where}.color`);
434
+ if (!Array.isArray(value.data))
435
+ fail(`${where}.data must be an array`);
436
+ value.data.forEach((point, index) => {
437
+ if (!isObject(point))
438
+ fail(`${where}.data[${index}] must be an object`);
439
+ const isCategorical = typeof point.value === 'number' && typeof point.label === 'string';
440
+ const isXy = (typeof point.x === 'string' || typeof point.x === 'number') && typeof point.y === 'number';
441
+ if (!isCategorical && !isXy)
442
+ fail(`${where}.data[${index}] must be {label,value} or {x,y}`);
443
+ });
444
+ }
445
+ function validateLiveColumn(value, where) {
446
+ if (!isObject(value))
447
+ fail(`${where} must be an object`);
448
+ assertString(value.header, `${where}.header`);
449
+ if (value.format !== undefined &&
450
+ (typeof value.format !== 'string' || !COLUMN_FORMATS.has(value.format))) {
451
+ fail(`${where}.format is invalid`);
452
+ }
453
+ assertVisibility(value.visibility, where);
454
+ switch (value.source) {
455
+ case 'indexer':
456
+ if (typeof value.metric !== 'string' || !LIVE_METRICS.has(value.metric)) {
457
+ fail(`${where} has an invalid indexer metric "${String(value.metric)}"`);
458
+ }
459
+ break;
460
+ case 'hardcoded':
461
+ assertString(value.key, `${where}.key`);
462
+ break;
463
+ case 'static':
464
+ if (!Array.isArray(value.values) || !value.values.every(isPrimitiveCell)) {
465
+ fail(`${where}.values must be an array of strings/numbers`);
466
+ }
467
+ break;
468
+ default:
469
+ fail(`${where} has an invalid source "${String(value.source)}"`);
470
+ }
471
+ }
472
+ function validateContentBlock(block, type, where) {
473
+ switch (type) {
474
+ case 'text':
475
+ assertString(block.body, `${where}.body`);
476
+ break;
477
+ case 'table': {
478
+ if (!Array.isArray(block.headers) || !block.headers.every((h) => typeof h === 'string')) {
479
+ fail(`${where}.headers must be an array of strings`);
480
+ }
481
+ if (!Array.isArray(block.rows) || !block.rows.every((row) => Array.isArray(row) && row.every(isPrimitiveCell))) {
482
+ fail(`${where}.rows must be an array of string/number arrays`);
483
+ }
484
+ if (block.columnVisibility !== undefined) {
485
+ if (!Array.isArray(block.columnVisibility))
486
+ fail(`${where}.columnVisibility must be an array`);
487
+ if (block.columnVisibility.length !== block.headers.length) {
488
+ fail(`${where}.columnVisibility length must match headers length`);
489
+ }
490
+ block.columnVisibility.forEach((v, index) => assertVisibility(v, `${where}.columnVisibility[${index}]`));
491
+ }
492
+ break;
493
+ }
494
+ case 'chart': {
495
+ if (typeof block.chartType !== 'string' || !CHART_TYPES.has(block.chartType)) {
496
+ fail(`${where}.chartType is invalid`);
497
+ }
498
+ // Data source is a single discriminated `data` (inline series XOR app/indexer dataRef).
499
+ if (!isObject(block.data))
500
+ fail(`${where}.data must be an object`);
501
+ switch (block.data.kind) {
502
+ case 'inline':
503
+ if (!Array.isArray(block.data.series))
504
+ fail(`${where}.data.series must be an array`);
505
+ block.data.series.forEach((s, index) => validateChartSeries(s, `${where}.data.series[${index}]`));
506
+ break;
507
+ case 'ref':
508
+ // Closed, SDK-enforced registry (extended via a coordinated SDK release).
509
+ if (typeof block.data.dataRef !== 'string' || !DATA_REFS.has(block.data.dataRef)) {
510
+ fail(`${where}.data.dataRef is invalid (unknown key "${String(block.data.dataRef)}")`);
511
+ }
512
+ break;
513
+ default:
514
+ fail(`${where}.data has an invalid kind "${String(block.data.kind)}"`);
515
+ }
516
+ if (isObject(block.xAxis) && block.xAxis.type !== undefined && !XAXIS_TYPES.has(block.xAxis.type)) {
517
+ fail(`${where}.xAxis.type is invalid`);
518
+ }
519
+ if (isObject(block.yAxis) &&
520
+ block.yAxis.format !== undefined &&
521
+ !AXIS_FORMATS.has(block.yAxis.format)) {
522
+ fail(`${where}.yAxis.format is invalid`);
523
+ }
524
+ break;
525
+ }
526
+ case 'image':
527
+ validateFile(block.file, `${where}.file`);
528
+ break;
529
+ case 'kpiGroup': {
530
+ if (block.columns !== undefined && ![1, 2, 3, 4].includes(block.columns)) {
531
+ fail(`${where}.columns must be 1, 2, 3 or 4`);
532
+ }
533
+ if (!Array.isArray(block.items))
534
+ fail(`${where}.items must be an array`);
535
+ block.items.forEach((item, index) => {
536
+ if (!isObject(item))
537
+ fail(`${where}.items[${index}] must be an object`);
538
+ assertString(item.label, `${where}.items[${index}].label`);
539
+ assertString(item.value, `${where}.items[${index}].value`);
540
+ if (item.trend !== undefined && !KPI_TRENDS.has(item.trend)) {
541
+ fail(`${where}.items[${index}].trend is invalid`);
542
+ }
543
+ assertVisibility(item.visibility, `${where}.items[${index}]`);
544
+ });
545
+ break;
546
+ }
547
+ case 'tabGroup': {
548
+ if (!Array.isArray(block.tabs))
549
+ fail(`${where}.tabs must be an array`);
550
+ block.tabs.forEach((tab, index) => {
551
+ if (!isObject(tab))
552
+ fail(`${where}.tabs[${index}] must be an object`);
553
+ assertString(tab.label, `${where}.tabs[${index}].label`);
554
+ if (!isObject(tab.block))
555
+ fail(`${where}.tabs[${index}].block must be an object`);
556
+ const tabBlock = tab.block;
557
+ if (typeof tabBlock.type !== 'string' || !TAB_BLOCK_TYPES.has(tabBlock.type)) {
558
+ fail(`${where}.tabs[${index}].block has an invalid type "${String(tabBlock.type)}"`);
559
+ }
560
+ assertString(tabBlock.id, `${where}.tabs[${index}].block.id`);
561
+ validateContentBlock(tabBlock, tabBlock.type, `${where}.tabs[${index}].block`);
562
+ });
563
+ break;
564
+ }
565
+ case 'liveTable': {
566
+ if (typeof block.dataRef !== 'string' || !LIVE_DATASETS.has(block.dataRef)) {
567
+ fail(`${where}.dataRef is invalid (unknown dataset "${String(block.dataRef)}")`);
568
+ }
569
+ if (!Array.isArray(block.columns))
570
+ fail(`${where}.columns must be an array`);
571
+ block.columns.forEach((column, index) => validateLiveColumn(column, `${where}.columns[${index}]`));
572
+ break;
573
+ }
574
+ default:
575
+ fail(`${where} has an unknown block type "${type}"`);
576
+ }
577
+ }
578
+ function validateLayoutItem(item, where) {
579
+ if (!isObject(item))
580
+ fail(`${where} must be an object`);
581
+ if (typeof item.type !== 'string')
582
+ fail(`${where} is missing a string \`type\``);
583
+ assertString(item.id, `${where}.id`);
584
+ assertVisibility(item.visibility, where);
585
+ if (item.type === 'section') {
586
+ if (typeof item.ref !== 'string' || !SECTION_REFS.has(item.ref)) {
587
+ fail(`${where} has an invalid section ref "${String(item.ref)}"`);
588
+ }
589
+ return;
590
+ }
591
+ if (!CONTENT_BLOCK_TYPES.has(item.type)) {
592
+ fail(`${where} has an unknown block type "${item.type}"`);
593
+ }
594
+ validateContentBlock(item, item.type, where);
595
+ }
596
+ function validateFactsheet(factsheet) {
597
+ if (!isObject(factsheet))
598
+ fail('`pool.factsheet` must be an object');
599
+ if (!Array.isArray(factsheet.body))
600
+ fail('`pool.factsheet.body` must be an array');
601
+ factsheet.body.forEach((item, index) => validateLayoutItem(item, `pool.factsheet.body[${index}]`));
602
+ if (!Array.isArray(factsheet.keyFacts))
603
+ fail('`pool.factsheet.keyFacts` must be an array');
604
+ factsheet.keyFacts.forEach((group, index) => validateKeyFactGroup(group, `pool.factsheet.keyFacts[${index}]`));
605
+ if (factsheet.sections !== undefined) {
606
+ if (!Array.isArray(factsheet.sections))
607
+ fail('`pool.factsheet.sections` must be an array');
608
+ factsheet.sections.forEach((item, index) => validateLayoutItem(item, `pool.factsheet.sections[${index}]`));
609
+ }
610
+ }
611
+ /**
612
+ * Hand-rolled structural validator for v2 pool metadata, in the style of
613
+ * {@link parseMarketplaceCatalog}. Validates the discriminant (`version === 2`), `pool.name`, and
614
+ * the full `pool.factsheet` content model (the layout the SDK is responsible for). Engine fields
615
+ * (`shareClasses`, `workflowPolicies`, `addressLabels`, …) are passed through
616
+ * unchecked here — they are validated where they are consumed (e.g. workflows via
617
+ * {@link parseMarketplaceCatalog}). Throws `pool metadata v2: …` on the first malformed field;
618
+ * tolerates missing optionals (visibility defaults to `'public'` app-side). Returns the input
619
+ * narrowed to {@link PoolMetadataV2} on success.
620
+ */
621
+ export function parsePoolMetadataV2(raw) {
622
+ if (!isObject(raw))
623
+ fail('expected a JSON object');
624
+ if (raw.version !== 2)
625
+ fail(`expected version 2, got ${String(raw.version)}`);
626
+ if (!isObject(raw.pool))
627
+ fail('`pool` must be an object');
628
+ assertString(raw.pool.name, '`pool.name`');
629
+ if (raw.pool.factsheet !== undefined)
630
+ validateFactsheet(raw.pool.factsheet);
631
+ return raw;
632
+ }
633
+ //# sourceMappingURL=poolMetadataMigration.js.map