@pdtf/schemas 3.6.0-dev.19 → 3.6.0-dev.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,765 @@
1
+ /**
2
+ * V3 ⇄ V4 decomposition and recomposition.
3
+ *
4
+ * Both directions are driven entirely by `src/schemas/v4/mapping.json`, which
5
+ * is emitted by `src/utils/generateV4Schemas.js` from the same constants that
6
+ * shape the V4 schemas. Nothing here restates the generator's rules: change
7
+ * the decomposition and this module follows automatically.
8
+ *
9
+ * decompose(v3Transaction) -> { Property, Title[], Transaction, Person[] }
10
+ * recompose(entities) -> v3Transaction
11
+ *
12
+ * PDTF 1.x verified claims are JSON Pointers against V3; `resolveV3Pointer`
13
+ * maps such a pointer onto the entity and pointer that now carry it, which is
14
+ * what a claim-to-credential bridge needs.
15
+ */
16
+
17
+ const mapping = require("../schemas/v4/mapping.json");
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Manifest access
21
+ // ---------------------------------------------------------------------------
22
+ const getRule = (id) => {
23
+ const rule = mapping.rules.find((r) => r.id === id);
24
+ if (!rule) throw new Error(`v4 mapping: no rule with id "${id}"`);
25
+ return rule;
26
+ };
27
+
28
+ /** Keys present in both Title source arrays (currently: titleNumber) */
29
+ const SHARED_TITLE_KEYS = mapping.collisions
30
+ .filter((c) => c.entity === "Title")
31
+ .map((c) => c.key);
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Small helpers
35
+ // ---------------------------------------------------------------------------
36
+ const isPlainObject = (v) =>
37
+ v !== null && typeof v === "object" && !Array.isArray(v);
38
+
39
+ const pick = (obj, keys) =>
40
+ Object.fromEntries(
41
+ Object.entries(obj || {}).filter(([k]) => keys.includes(k))
42
+ );
43
+
44
+ const omit = (obj, keys) =>
45
+ Object.fromEntries(
46
+ Object.entries(obj || {}).filter(([k]) => !keys.includes(k))
47
+ );
48
+
49
+ /** Apply a rule's `keys` selector to a V3 node. */
50
+ const selectKeys = (node, rule) => {
51
+ if (!rule.keys) return { ...(node || {}) };
52
+ return rule.keys.mode === "include"
53
+ ? pick(node, rule.keys.values)
54
+ : omit(node, rule.keys.values);
55
+ };
56
+
57
+ const isEmpty = (v) =>
58
+ v === undefined ||
59
+ (isPlainObject(v) && Object.keys(v).length === 0) ||
60
+ (Array.isArray(v) && v.length === 0);
61
+
62
+ /** Assign only when there is something to assign, so we never fabricate nodes. */
63
+ const assignIfPresent = (target, key, value) => {
64
+ if (!isEmpty(value)) target[key] = value;
65
+ };
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Default identifiers
69
+ //
70
+ // Deterministic and derived from the transaction, so a decompose/recompose
71
+ // cycle is stable and diffable. Supply `idFactory` to mint real URNs/DIDs.
72
+ // ---------------------------------------------------------------------------
73
+ const didSafe = (value) => String(value).replace(/[^A-Za-z0-9._-]/g, "-");
74
+
75
+ const defaultIdFactory = (transactionId) => {
76
+ // DID method-specific ids admit only [A-Za-z0-9._-], so ids are sanitised and
77
+ // segments joined with "-" rather than ":".
78
+ const base = didSafe(transactionId || "unknown");
79
+ return {
80
+ property: () => `urn:pdtf:property:${base}`,
81
+ transaction: () => `did:pdtf:transaction-${base}`,
82
+ title: (title, index) =>
83
+ `urn:pdtf:title:${base}:${title.titleNumber || `index-${index}`}`,
84
+ // V3's own `did` is used verbatim where present — it is the party's real
85
+ // identifier, stable across transactions and across two entries for one
86
+ // person who is both buying and selling. Everything below is a fallback for
87
+ // instances that carry none, and array position is the last resort because
88
+ // it is only stable for a fixed participants array.
89
+ person: (participant, index) =>
90
+ participant?.did ||
91
+ `did:pdtf:person-${base}-${
92
+ participant?.participantId ? didSafe(participant.participantId) : index
93
+ }`,
94
+ offer: (offerId) => `urn:pdtf:offer:${base}:${didSafe(offerId)}`,
95
+ gift: (donor) => `urn:pdtf:gift:${base}:${didSafe(donor)}`,
96
+ transactionRole: (participant) =>
97
+ `urn:pdtf:transactionrole:${base}:${didSafe(participant)}`,
98
+ representation: (representative, representedParty) =>
99
+ `urn:pdtf:representation:${base}:${didSafe(representative)}:${didSafe(
100
+ representedParty
101
+ )}`,
102
+ sellerCapacity: (seller) => `urn:pdtf:sellercapacity:${base}:${didSafe(seller)}`,
103
+ };
104
+ };
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // decompose
108
+ // ---------------------------------------------------------------------------
109
+ const decompose = (v3, { idFactory } = {}) => {
110
+ const propertyPack = v3.propertyPack || {};
111
+ const ids = { ...defaultIdFactory(v3.transactionId), ...(idFactory || {}) };
112
+
113
+ const propertyRule = getRule("property");
114
+ const saleContextRule = getRule("transaction.saleContext");
115
+ const legalOwnersRule = getRule("transaction.saleContext.legalOwners");
116
+ const titlesRule = getRule("title.titlesToBeSold");
117
+ const ownershipsRule = getRule("title.ownershipsToBeTransferred");
118
+ const personRule = getRule("person");
119
+ const contextRule = getRule("transaction.participants");
120
+ const transactionRule = getRule("transaction");
121
+
122
+ // --- Property ------------------------------------------------------------
123
+ const property = selectKeys(propertyPack, propertyRule);
124
+ property.id = ids.property();
125
+
126
+ // --- Title ---------------------------------------------------------------
127
+ // The two source arrays are correlated by titleNumber, NOT by index: a V3
128
+ // instance may list them in different orders or list only one side.
129
+ const titleItems = propertyPack.titlesToBeSold || [];
130
+ const ownershipItems =
131
+ (propertyPack.ownership || {}).ownershipsToBeTransferred || [];
132
+
133
+ const correlateBy = titlesRule.instance.correlateBy;
134
+ const titles = [];
135
+ const byKey = new Map();
136
+
137
+ const upsert = (correlationValue) => {
138
+ const key =
139
+ correlationValue === undefined ? Symbol("uncorrelated") : correlationValue;
140
+ if (byKey.has(key)) return byKey.get(key);
141
+ const entity = {};
142
+ byKey.set(key, entity);
143
+ titles.push(entity);
144
+ return entity;
145
+ };
146
+
147
+ /**
148
+ * Merge one source array into the Title set.
149
+ *
150
+ * A correlation value repeated WITHIN one array would silently collapse two
151
+ * V3 entries into a single Title — the entries are valid V3 (neither array
152
+ * declares uniqueness) but a title number identifies a title, so this is
153
+ * malformed data. Failing loudly beats losing an entry.
154
+ */
155
+ const mergeSource = (items, rule, sourceName) => {
156
+ const seen = new Set();
157
+ items.forEach((item, index) => {
158
+ const correlationValue = item[correlateBy];
159
+ if (correlationValue !== undefined) {
160
+ if (seen.has(correlationValue)) {
161
+ throw new Error(
162
+ `v4 decompose: ${sourceName}[${index}] repeats ${correlateBy} "${correlationValue}". ` +
163
+ `${correlateBy} must be unique within an array — it is what correlates the two Title sources.`
164
+ );
165
+ }
166
+ seen.add(correlationValue);
167
+ }
168
+ Object.assign(upsert(correlationValue), selectKeys(item, rule));
169
+ });
170
+ };
171
+
172
+ // titlesToBeSold order leads; ownership-only titles are appended after.
173
+ mergeSource(titleItems, titlesRule, "propertyPack.titlesToBeSold");
174
+ mergeSource(
175
+ ownershipItems,
176
+ ownershipsRule,
177
+ "propertyPack.ownership.ownershipsToBeTransferred"
178
+ );
179
+
180
+ titles.forEach((title, index) => {
181
+ title.id = ids.title(title, index);
182
+ });
183
+
184
+ // --- Person + participant context ---------------------------------------
185
+ const participants = v3.participants || [];
186
+ const persons = [];
187
+ const personIndexById = new Map();
188
+ const participantContext = [];
189
+ // Keyed by roster position, not by person: one human both buying and selling
190
+ // holds one DID but two participant entries, and each carries its own
191
+ // relationship.
192
+ const relationalByIndex = [];
193
+
194
+ participants.forEach((participant, index) => {
195
+ const person = selectKeys(participant, personRule);
196
+ person.id = ids.person(participant, index);
197
+
198
+ // One party, one entry. A DID repeated within a transaction means the same
199
+ // party listed twice, which is a data error rather than two participations:
200
+ // a transaction is a single sale, so nobody is both its buyer and its
201
+ // seller. The same person across a sale and an onward purchase is two
202
+ // transactions, each with its own credentials, and needs nothing special.
203
+ if (personIndexById.has(person.id)) {
204
+ throw new Error(
205
+ `v4 decompose: participants[${index}] repeats did "${person.id}", already used by participants[${personIndexById.get(person.id)}]. ` +
206
+ "A party appears once per transaction; the same person in a related transaction belongs to that transaction's participants."
207
+ );
208
+ }
209
+ personIndexById.set(person.id, index);
210
+ persons.push(person);
211
+
212
+ participantContext.push({
213
+ participant: person.id,
214
+ ...selectKeys(participant, contextRule),
215
+ });
216
+ // Everything relational is kept aside for the credential pass below.
217
+ relationalByIndex[index] = participant;
218
+ });
219
+
220
+ // --- Transaction ---------------------------------------------------------
221
+ const transaction = selectKeys(v3, transactionRule);
222
+ transaction.id = ids.transaction();
223
+ transaction.property = property.id;
224
+
225
+ assignIfPresent(
226
+ transaction,
227
+ "titlesToBeSold",
228
+ titles.map((t) => t.id)
229
+ );
230
+ assignIfPresent(transaction, "participants", participantContext);
231
+
232
+ const saleContext = selectKeys(propertyPack.ownership, saleContextRule);
233
+ if (propertyPack.legalOwners !== undefined) {
234
+ saleContext[legalOwnersRule.entityPointer.split("/").pop()] =
235
+ propertyPack.legalOwners;
236
+ }
237
+ assignIfPresent(transaction, "saleContext", saleContext);
238
+
239
+ const entities = {
240
+ Property: property,
241
+ Title: titles,
242
+ Transaction: transaction,
243
+ Person: persons,
244
+ };
245
+
246
+ // The credentials are part of the round trip: role and every relationship
247
+ // live on them, so decompose produces them rather than leaving it to a
248
+ // separate call.
249
+ return {
250
+ ...entities,
251
+ ...projectRelationships(entities, { idFactory, relationalByIndex }),
252
+ };
253
+ };
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // recompose
257
+ // ---------------------------------------------------------------------------
258
+ /** Role implied 1:1 by the existence of a credential of this type. */
259
+ const ROLE_IMPLIED_BY_CREDENTIAL = {
260
+ SellerCapacity: "Seller",
261
+ Offer: "Buyer",
262
+ Gift: "Gift Donor",
263
+ };
264
+
265
+ /**
266
+ * Rebuild each participant's role and relationship fields from the credentials.
267
+ *
268
+ * Role is not stored on the roster: for SellerCapacity, Offer and Gift it is
269
+ * implied by the credential's existence, and Representation and TransactionRole
270
+ * carry it as their own discriminator. So revoking a credential removes the
271
+ * relationship AND the role it asserted, with no second copy left behind.
272
+ */
273
+ const relationalFromCredentials = ({
274
+ Representation = [],
275
+ SellerCapacity = [],
276
+ Offer = [],
277
+ Gift = [],
278
+ TransactionRole = [],
279
+ roster = [],
280
+ }) => {
281
+ // Rebuild actingFor with whichever identifier V3 used: did where the party
282
+ // has one, participantId otherwise.
283
+ const idOf = new Map(
284
+ roster
285
+ .filter((e) => e.did !== undefined || e.participantId !== undefined)
286
+ .map((e) => [e.participant, e.did ?? e.participantId])
287
+ );
288
+
289
+ const fieldsFor = new Map();
290
+ const into = (did) => {
291
+ if (!fieldsFor.has(did)) fieldsFor.set(did, {});
292
+ return fieldsFor.get(did);
293
+ };
294
+
295
+ for (const c of SellerCapacity) {
296
+ const to = into(c.seller);
297
+ to.role = ROLE_IMPLIED_BY_CREDENTIAL.SellerCapacity;
298
+ if (c.sellersCapacity !== undefined) to.sellersCapacity = c.sellersCapacity;
299
+ if (c.dateBecameOwnerOrAuthority !== undefined) {
300
+ to.dateBecameOwnerOrAuthority = c.dateBecameOwnerOrAuthority;
301
+ }
302
+ }
303
+ for (const c of Offer) {
304
+ const to = into(c.buyer);
305
+ to.role = ROLE_IMPLIED_BY_CREDENTIAL.Offer;
306
+ if (c.offerId !== undefined) to.offerId = c.offerId;
307
+ }
308
+ for (const c of Gift) {
309
+ const to = into(c.donor);
310
+ to.role = ROLE_IMPLIED_BY_CREDENTIAL.Gift;
311
+ if (c.offerId !== undefined) to.offerId = c.offerId;
312
+ if (c.giftDetails !== undefined) to.giftDetails = c.giftDetails;
313
+ }
314
+ for (const c of TransactionRole) {
315
+ const to = into(c.participant);
316
+ if (c.role !== undefined) to.role = c.role;
317
+ }
318
+ // Representation is the one that is not exclusive: a conveyancer instructed
319
+ // by two sellers holds two, merging into one actingFor array.
320
+ for (const c of Representation) {
321
+ const to = into(c.representative);
322
+ if (c.role !== undefined) to.role = c.role;
323
+ const representedId = idOf.get(c.representedParty);
324
+ if (representedId !== undefined) {
325
+ to.actingFor = [...(to.actingFor || []), representedId];
326
+ }
327
+ }
328
+
329
+ return roster.map((entry) => fieldsFor.get(entry.participant) || {});
330
+ };
331
+
332
+ const recompose = ({
333
+ Property,
334
+ Title,
335
+ Transaction,
336
+ Person,
337
+ Representation,
338
+ SellerCapacity,
339
+ Offer,
340
+ Gift,
341
+ TransactionRole,
342
+ }) => {
343
+ const titles = Title || [];
344
+ const persons = Person || [];
345
+ const transaction = Transaction || {};
346
+
347
+ const propertyRule = getRule("property");
348
+ const saleContextRule = getRule("transaction.saleContext");
349
+ const legalOwnersRule = getRule("transaction.saleContext.legalOwners");
350
+ const titlesRule = getRule("title.titlesToBeSold");
351
+ const ownershipsRule = getRule("title.ownershipsToBeTransferred");
352
+ const transactionRule = getRule("transaction");
353
+
354
+ const legalOwnersKey = legalOwnersRule.entityPointer.split("/").pop();
355
+
356
+ // --- top level -----------------------------------------------------------
357
+ const v3 = {
358
+ $schema: mapping.source.v3SchemaId,
359
+ ...omit(transaction, [
360
+ "id",
361
+ "property",
362
+ "titlesToBeSold",
363
+ "participants",
364
+ "saleContext",
365
+ ]),
366
+ };
367
+
368
+ // --- participants --------------------------------------------------------
369
+ // Order comes from Transaction.participants, which is authoritative; the
370
+ // entity documents themselves carry no index.
371
+ const personById = new Map(persons.map((p) => [p.id, p]));
372
+ const roster = transaction.participants || [];
373
+ const relational = relationalFromCredentials({
374
+ Representation,
375
+ SellerCapacity,
376
+ Offer,
377
+ Gift,
378
+ TransactionRole,
379
+ roster,
380
+ });
381
+ const participants = roster.map((entry, index) => {
382
+ const person = personById.get(entry.participant) || {};
383
+ return {
384
+ ...omit(person, ["id"]),
385
+ ...omit(entry, ["participant"]),
386
+ ...(relational[index] || {}),
387
+ };
388
+ });
389
+ assignIfPresent(v3, "participants", participants);
390
+
391
+ // --- propertyPack --------------------------------------------------------
392
+ const propertyPack = omit(Property || {}, ["id"]);
393
+
394
+ // Split each Title back into the two source arrays it was merged from.
395
+ // A key shared by both sources is written to every array that gets an entry;
396
+ // a Title carrying *only* shared keys goes to the primarySource array alone.
397
+ const titlesOwned = titlesRule.keys.values;
398
+ const ownershipsOwned = ownershipsRule.keys.values;
399
+
400
+ const titlesToBeSold = [];
401
+ const ownershipsToBeTransferred = [];
402
+
403
+ // Transaction.titlesToBeSold is the authoritative order for Title entities.
404
+ const titleById = new Map(titles.map((t) => [t.id, t]));
405
+ const orderedTitles = (transaction.titlesToBeSold || []).map(
406
+ (id, index) => titleById.get(id) || titles[index]
407
+ );
408
+ const titleList = orderedTitles.length ? orderedTitles.filter(Boolean) : titles;
409
+
410
+ for (const title of titleList) {
411
+ const titlesPart = pick(title, titlesOwned);
412
+ const ownershipPart = pick(title, ownershipsOwned);
413
+
414
+ const hasTitlesOnly = Object.keys(omit(titlesPart, SHARED_TITLE_KEYS)).length > 0;
415
+ const hasOwnershipOnly =
416
+ Object.keys(omit(ownershipPart, SHARED_TITLE_KEYS)).length > 0;
417
+
418
+ if (hasTitlesOnly || !hasOwnershipOnly) titlesToBeSold.push(titlesPart);
419
+ if (hasOwnershipOnly) ownershipsToBeTransferred.push(ownershipPart);
420
+ }
421
+
422
+ assignIfPresent(propertyPack, "titlesToBeSold", titlesToBeSold);
423
+
424
+ // --- ownership + legalOwners --------------------------------------------
425
+ const saleContext = transaction.saleContext || {};
426
+ if (saleContext[legalOwnersKey] !== undefined) {
427
+ propertyPack[legalOwnersRule.v3Pointer.split("/").pop()] =
428
+ saleContext[legalOwnersKey];
429
+ }
430
+
431
+ const ownership = selectKeys(omit(saleContext, [legalOwnersKey]), {
432
+ keys: { mode: "exclude", values: [] },
433
+ });
434
+ assignIfPresent(ownership, "ownershipsToBeTransferred", ownershipsToBeTransferred);
435
+ assignIfPresent(propertyPack, "ownership", ownership);
436
+
437
+ assignIfPresent(v3, "propertyPack", propertyPack);
438
+
439
+ // Preserve V3 key order for the keys the transaction rule owns.
440
+ void transactionRule;
441
+ void propertyRule;
442
+ void saleContextRule;
443
+
444
+ return v3;
445
+ };
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Relationship projections
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /**
452
+ * Build the relationship credentials — Representation, SellerCapacity, Offer,
453
+ * Gift and TransactionRole.
454
+ *
455
+ * Internal to decompose: the roster deliberately no longer carries the
456
+ * relational fields, so this cannot be run usefully against already-decomposed
457
+ * entities. Credentials come out of `decompose` alongside the other entities.
458
+ *
459
+ * These are the linking entities that EMBODY a party's role: a Representation
460
+ * says a conveyancer acts for a seller, a SellerCapacity says a person sells in
461
+ * a given capacity, an Offer says a person is the buyer. The role is the
462
+ * relationship, not a label on the participant.
463
+ *
464
+ * These add no information: everything they carry round-trips on
465
+ * Transaction.participants[], so `recompose` neither needs nor accepts them.
466
+ * They exist because a VC issuer signs, and a holder presents, one relationship
467
+ * at a time — "Suemme and Profitt act for Peter Hetherington-Smythe in this
468
+ * transaction" is a statement you want to hand over on its own, without
469
+ * disclosing the rest of the participant list.
470
+ *
471
+ * Cardinality is one entity per (representative, represented party) pair, so a
472
+ * conveyancer instructed jointly by two sellers yields two Representations, and
473
+ * two sellers who instruct separate conveyancers yield one each. Shape and
474
+ * cardinality are declared in `mapping.projections`.
475
+ */
476
+ const projectRelationships = (
477
+ { Transaction },
478
+ { idFactory, relationalByIndex } = {}
479
+ ) => {
480
+ const transaction = Transaction || {};
481
+ const roster = transaction.participants || [];
482
+ // When called from decompose, the relational fields come from the V3
483
+ // participants; when called on already-decomposed entities they are whatever
484
+ // the roster still carries.
485
+ const participants = roster.map((entry, index) => ({
486
+ ...entry,
487
+ ...(relationalByIndex?.[index] || {}),
488
+ participant: entry.participant,
489
+ }));
490
+ const ids = {
491
+ ...defaultIdFactory(transaction.transactionId),
492
+ ...(idFactory || {}),
493
+ };
494
+
495
+ // actingFor names a party by did where one is minted, else by participantId.
496
+ const byParticipantId = new Map();
497
+ for (const p of participants) {
498
+ if (p.participantId !== undefined) byParticipantId.set(p.participantId, p);
499
+ if (p.did !== undefined) byParticipantId.set(p.did, p);
500
+ }
501
+ const Gift = [];
502
+
503
+ const Representation = [];
504
+ const SellerCapacity = [];
505
+ const Offer = [];
506
+ const TransactionRole = [];
507
+ const offers = transaction.offers || {};
508
+
509
+ participants.forEach((entry, index) => {
510
+ for (const actingForId of entry.actingFor || []) {
511
+ const represented = byParticipantId.get(actingForId);
512
+ if (!represented) {
513
+ throw new Error(
514
+ `v4 projection: participants[${index}] acts for unknown participantId "${actingForId}". ` +
515
+ "Every actingFor entry must name a participantId present on the transaction."
516
+ );
517
+ }
518
+ Representation.push({
519
+ id: ids.representation(entry.participant, represented.participant),
520
+ representative: entry.participant,
521
+ representedParty: represented.participant,
522
+ ...(entry.role !== undefined && { role: entry.role }),
523
+ transaction: transaction.id,
524
+ });
525
+ }
526
+
527
+ // A seller's role is embodied by this credential, so it is emitted for
528
+ // every seller — not only those who have declared a capacity yet.
529
+ if (entry.role === "Seller" || entry.sellersCapacity !== undefined) {
530
+ SellerCapacity.push({
531
+ id: ids.sellerCapacity(entry.participant),
532
+ seller: entry.participant,
533
+ ...(entry.sellersCapacity !== undefined && {
534
+ sellersCapacity: entry.sellersCapacity,
535
+ }),
536
+ ...(entry.dateBecameOwnerOrAuthority !== undefined && {
537
+ dateBecameOwnerOrAuthority: entry.dateBecameOwnerOrAuthority,
538
+ }),
539
+ transaction: transaction.id,
540
+ });
541
+ }
542
+
543
+ // Likewise a buyer's role is embodied by their offer. An offer that no
544
+ // participant references stays transaction data: Offer requires a buyer.
545
+ if (entry.giftDetails !== undefined || entry.role === "Gift Donor") {
546
+ Gift.push({
547
+ id: ids.gift(entry.participant),
548
+ donor: entry.participant,
549
+ ...(entry.offerId !== undefined && { offerId: entry.offerId }),
550
+ ...(entry.giftDetails !== undefined && { giftDetails: entry.giftDetails }),
551
+ transaction: transaction.id,
552
+ });
553
+ } else if (entry.offerId !== undefined) {
554
+ const { externalIds, ...offerFields } = offers[entry.offerId] || {};
555
+ Offer.push({
556
+ id: ids.offer(entry.offerId),
557
+ offerId: entry.offerId,
558
+ buyer: entry.participant,
559
+ ...offerFields,
560
+ transaction: transaction.id,
561
+ });
562
+ }
563
+ });
564
+
565
+ // Anyone left carrying a role that no specific credential embodies gets the
566
+ // catch-all, so every participant with a role has exactly one credential
567
+ // asserting it. That covers Lender, Landlord, Tenant, Gift Donor and Platform
568
+ // Support, which V3 records as a role and nothing more, and also a party
569
+ // whose specific relationship is not yet established.
570
+ const hasSpecificCredential = new Set([
571
+ ...Representation.map((r) => r.representative),
572
+ ...SellerCapacity.map((c) => c.seller),
573
+ ...Offer.map((o) => o.buyer),
574
+ ...Gift.map((g) => g.donor),
575
+ ]);
576
+
577
+ for (const entry of participants) {
578
+ if (entry.role === undefined) continue;
579
+ if (hasSpecificCredential.has(entry.participant)) continue;
580
+ TransactionRole.push({
581
+ id: ids.transactionRole(entry.participant),
582
+ participant: entry.participant,
583
+ role: entry.role,
584
+ transaction: transaction.id,
585
+ });
586
+ }
587
+
588
+ return { Representation, SellerCapacity, Offer, Gift, TransactionRole };
589
+ };
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // Array item identity
593
+ // ---------------------------------------------------------------------------
594
+
595
+ const ARRAY_KEYS_BY_POINTER = new Map(
596
+ mapping.arrayKeys.map(({ pointer, keys }) => [pointer, keys])
597
+ );
598
+
599
+ /**
600
+ * The candidate keys for items of the array at this V3 pointer, in precedence
601
+ * order, or [] if it has none. See mapping.arrayKeyScopes for what "canonical"
602
+ * and "vendor" mean.
603
+ */
604
+ const arrayKeyFor = (arrayPointer) =>
605
+ ARRAY_KEYS_BY_POINTER.get(arrayPointer) || [];
606
+
607
+ const getAtPointer = (root, segments) =>
608
+ segments.reduce((node, seg) => (node == null ? undefined : node[seg]), root);
609
+
610
+ /**
611
+ * Resolve the best available identity for one array item.
612
+ *
613
+ * Candidates are tried in precedence order: the canonical key first, because it
614
+ * lives in the shared payload and any party can resolve it, then the
615
+ * vendor-scoped externalIds fallback if `source` was supplied. Every V3
616
+ * identifier is optional, so an item may carry neither.
617
+ */
618
+ const resolveItemKey = (item, candidates, source) => {
619
+ for (const candidate of candidates) {
620
+ const pointer = candidate.pointer.replace("{source}", source ?? "");
621
+ if (candidate.scope === "vendor") {
622
+ if (!source) continue;
623
+ const value = item?.externalIds?.[source];
624
+ if (value !== undefined) {
625
+ return { key: pointer, value, scope: candidate.scope };
626
+ }
627
+ continue;
628
+ }
629
+ const field = candidate.pointer.slice(1);
630
+ const value = item?.[field];
631
+ if (value !== undefined) {
632
+ return { key: pointer, value, scope: candidate.scope };
633
+ }
634
+ }
635
+ return { key: null, value: null, scope: null };
636
+ };
637
+
638
+ /**
639
+ * Describe every array index a V3 pointer passes through, so an item can be
640
+ * addressed by identity instead of by position.
641
+ *
642
+ * A pointer like /propertyPack/documents/3/summary is only meaningful against
643
+ * one producer's ordering of that array — two parties editing it independently
644
+ * will disagree about index 3. Given the instance, this returns the identity of
645
+ * the item at each index:
646
+ *
647
+ * identifyV3Pointer("/propertyPack/documents/3/summary", v3)
648
+ * // [{ array: "/propertyPack/documents", index: 3,
649
+ * // key: "/documentId", value: "abc", scope: "canonical" }]
650
+ *
651
+ * Pass `source` to allow the vendor-scoped fallback for items that carry no
652
+ * canonical id but do carry an externalIds entry for that source:
653
+ *
654
+ * identifyV3Pointer(pointer, v3, { source: "Moverly" })
655
+ * // [{ ..., key: "/externalIds/Moverly", value: "file-1", scope: "vendor" }]
656
+ *
657
+ * A `vendor` result is only resolvable by a party that knows that namespace, so
658
+ * it should not be the identifier in a credential presented to a third party —
659
+ * check `scope` before relying on it. `key`, `value` and `scope` are all null
660
+ * where the array has no candidate key, or the item carries none of them.
661
+ */
662
+ const identifyV3Pointer = (pointer, v3, { source } = {}) => {
663
+ const segments = pointer.split("/").filter(Boolean);
664
+ const out = [];
665
+ const templateParts = [];
666
+
667
+ for (let i = 0; i < segments.length; i += 1) {
668
+ const segment = segments[i];
669
+ if (!/^\d+$/.test(segment)) {
670
+ templateParts.push(segment);
671
+ continue;
672
+ }
673
+ const arrayPointer = `/${templateParts.join("/")}`;
674
+ const item = getAtPointer(v3, segments.slice(0, i + 1));
675
+ out.push({
676
+ array: arrayPointer,
677
+ index: Number(segment),
678
+ ...resolveItemKey(item, arrayKeyFor(arrayPointer), source),
679
+ });
680
+ templateParts.push("{index}");
681
+ }
682
+
683
+ return out;
684
+ };
685
+
686
+ // ---------------------------------------------------------------------------
687
+ // Claim pointer resolution
688
+ // ---------------------------------------------------------------------------
689
+ const POINTER_SEGMENT = /\{index\}/g;
690
+
691
+ /**
692
+ * Resolve a V3 JSON Pointer (as carried by a PDTF 1.x verified claim) to the
693
+ * V4 entity and pointer that now hold it, by longest-prefix match.
694
+ *
695
+ * Returns `{ entity, entityPointer, rule }`, or null if nothing matches.
696
+ * Where a V3 node is split across entities (participants), the more specific
697
+ * rule wins: a pointer at the node itself returns every candidate via
698
+ * `resolveV3PointerAll`.
699
+ */
700
+ const matchRule = (pointer, rule) => {
701
+ const template = rule.v3Pointer;
702
+ if (template === "") return { matched: "", rest: pointer, indices: [] };
703
+
704
+ const parts = template.split("/").filter(Boolean);
705
+ const segs = pointer.split("/").filter(Boolean);
706
+ if (segs.length < parts.length) return null;
707
+
708
+ const indices = [];
709
+ for (let i = 0; i < parts.length; i += 1) {
710
+ if (parts[i] === "{index}") {
711
+ if (!/^\d+$/.test(segs[i])) return null;
712
+ indices.push(segs[i]);
713
+ } else if (parts[i] !== segs[i]) {
714
+ return null;
715
+ }
716
+ }
717
+ return {
718
+ matched: `/${segs.slice(0, parts.length).join("/")}`,
719
+ rest: segs.length > parts.length ? `/${segs.slice(parts.length).join("/")}` : "",
720
+ indices,
721
+ };
722
+ };
723
+
724
+ const resolveV3PointerAll = (pointer) => {
725
+ const results = [];
726
+ for (const rule of mapping.rules) {
727
+ const m = matchRule(pointer, rule);
728
+ if (!m) continue;
729
+
730
+ // Respect the rule's key selector for the first segment beyond the match.
731
+ const nextKey = m.rest.split("/").filter(Boolean)[0];
732
+ if (nextKey && rule.keys) {
733
+ const included =
734
+ rule.keys.mode === "include"
735
+ ? rule.keys.values.includes(nextKey)
736
+ : !rule.keys.values.includes(nextKey);
737
+ if (!included) continue;
738
+ }
739
+
740
+ let entityPointer = rule.entityPointer;
741
+ let n = 0;
742
+ entityPointer = entityPointer.replace(POINTER_SEGMENT, () => m.indices[n++]);
743
+
744
+ results.push({
745
+ entity: rule.entity,
746
+ entityPointer: `${entityPointer}${m.rest}`,
747
+ rule: rule.id,
748
+ specificity: rule.v3Pointer.split("/").filter(Boolean).length,
749
+ });
750
+ }
751
+ return results.sort((a, b) => b.specificity - a.specificity);
752
+ };
753
+
754
+ const resolveV3Pointer = (pointer) => resolveV3PointerAll(pointer)[0] || null;
755
+
756
+ module.exports = {
757
+ mapping,
758
+ decompose,
759
+ recompose,
760
+ projectRelationships,
761
+ arrayKeyFor,
762
+ identifyV3Pointer,
763
+ resolveV3Pointer,
764
+ resolveV3PointerAll,
765
+ };