@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.
- package/docs/v4-mapping-and-recomposition.md +416 -0
- package/index.js +18 -0
- package/package.json +2 -1
- package/src/schemas/v3/combined.json +168 -1
- package/src/schemas/v3/compactSkeleton.txt +20 -0
- package/src/schemas/v3/pdtf-transaction.json +175 -1
- package/src/schemas/v3/skeleton.json +29 -1
- package/src/schemas/v4/Gift.json +88 -0
- package/src/schemas/v4/Offer.json +384 -0
- package/src/schemas/v4/Organisation.json +167 -0
- package/src/schemas/v4/Person.json +321 -0
- package/src/schemas/v4/Property.json +27769 -0
- package/src/schemas/v4/Representation.json +67 -0
- package/src/schemas/v4/SellerCapacity.json +193 -0
- package/src/schemas/v4/Title.json +14672 -0
- package/src/schemas/v4/Transaction.json +1570 -0
- package/src/schemas/v4/TransactionRole.json +59 -0
- package/src/schemas/v4/mapping.json +1129 -0
- package/src/utils/generateV4Schemas.js +1341 -0
- package/src/utils/v4.js +765 -0
package/src/utils/v4.js
ADDED
|
@@ -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
|
+
};
|