@pdtf/schemas 3.6.0-dev.20 → 3.6.0-dev.22

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