@idos-network/credentials 1.2.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +289 -85
- package/assets/idos-credential-subject-edd-v1.json +10 -0
- package/assets/idos-credential-subject-v1.json +1 -1
- package/assets/idos-credential-subject-v2.json +1 -1
- package/assets/idos-credential-subject-v3.json +83 -0
- package/assets/idos-credentials-v1.json +2 -2
- package/assets/idos-credentials-v2.json +10 -0
- package/dist/index-0NqXMjNk.d.mts +395 -0
- package/dist/index-0NqXMjNk.d.mts.map +1 -0
- package/dist/index-WW372Tf5.d.mts +57 -0
- package/dist/index-WW372Tf5.d.mts.map +1 -0
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/loader-Cg6hs1-P.mjs +2 -0
- package/dist/loader-Cg6hs1-P.mjs.map +1 -0
- package/dist/parser/index.d.mts +19 -0
- package/dist/parser/index.d.mts.map +1 -0
- package/dist/parser/index.mjs +2 -0
- package/dist/parser/index.mjs.map +1 -0
- package/dist/schemas/index.d.mts +2 -0
- package/dist/schemas/index.mjs +1 -0
- package/dist/schemas-BVGuUlPe.mjs +2 -0
- package/dist/schemas-BVGuUlPe.mjs.map +1 -0
- package/dist/types/index.d.mts +2 -135
- package/dist/types/index.mjs +1 -63
- package/dist/utils/index.d.mts +38 -22
- package/dist/utils/index.d.mts.map +1 -1
- package/dist/utils/index.mjs +1 -123
- package/dist/utils/index.mjs.map +1 -1
- package/dist/verifier/index.d.mts +8 -0
- package/dist/verifier/index.d.mts.map +1 -0
- package/dist/verifier/index.mjs +2 -0
- package/dist/verifier/index.mjs.map +1 -0
- package/package.json +31 -15
- package/src/parser/index.test.ts +266 -0
- package/src/parser/index.ts +69 -0
- package/src/schemas/EddV1/index.test.ts +40 -0
- package/src/schemas/EddV1/index.ts +53 -0
- package/src/schemas/Envelope.ts +37 -0
- package/src/schemas/FaceIdV1/index.test.ts +26 -0
- package/src/schemas/FaceIdV1/index.ts +33 -0
- package/src/schemas/KycV1/IdDocument.ts +41 -0
- package/src/schemas/KycV1/ResidentialAddress.ts +56 -0
- package/src/schemas/KycV1/Subject.ts +86 -0
- package/src/schemas/KycV1/index.test.ts +156 -0
- package/src/schemas/KycV1/index.ts +40 -0
- package/src/schemas/KycV2/Subject.ts +67 -0
- package/src/schemas/KycV2/index.test.ts +162 -0
- package/src/schemas/KycV2/index.ts +45 -0
- package/src/schemas/KycV3/Biometric.ts +14 -0
- package/src/schemas/KycV3/Contact.ts +13 -0
- package/src/schemas/KycV3/EDD.ts +32 -0
- package/src/schemas/KycV3/IdDocument.ts +92 -0
- package/src/schemas/KycV3/Onboarding.ts +36 -0
- package/src/schemas/KycV3/Person.ts +105 -0
- package/src/schemas/KycV3/ResidentialAddress.ts +73 -0
- package/src/schemas/KycV3/Screening.ts +27 -0
- package/src/schemas/KycV3/SourceOfWealth.ts +44 -0
- package/src/schemas/KycV3/Subject.ts +54 -0
- package/src/schemas/KycV3/index.test.ts +682 -0
- package/src/schemas/KycV3/index.ts +108 -0
- package/src/schemas/VerifiableCredential.ts +232 -0
- package/src/schemas/enums.ts +126 -0
- package/src/schemas/index.ts +14 -0
- package/src/schemas/integration.test.ts +251 -0
- package/src/schemas/types.ts +11 -0
- package/src/schemas/utils.ts +274 -0
- package/src/types/credentials.ts +18 -249
- package/src/utils/{utils.test.ts → index.test.ts} +59 -67
- package/src/utils/index.ts +129 -137
- package/src/utils/issuer.ts +42 -0
- package/src/{builder → utils}/loader.ts +22 -17
- package/src/verifier/index.test.ts +149 -0
- package/src/{builder/verifier.ts → verifier/index.ts} +4 -8
- package/types/internals.d.ts +11 -6
- package/dist/builder/index.d.mts +0 -20
- package/dist/builder/index.d.mts.map +0 -1
- package/dist/builder/index.mjs +0 -619
- package/dist/builder/index.mjs.map +0 -1
- package/dist/types/index.d.mts.map +0 -1
- package/dist/types/index.mjs.map +0 -1
- package/src/builder/builder.test.ts +0 -407
- package/src/builder/index.ts +0 -122
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { Ed25519VerificationKey2020 } from "@digitalbazaar/ed25519-verification-key-2020";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { parseCredential } from "../parser";
|
|
7
|
+
import { verifyCredential } from "../verifier";
|
|
8
|
+
import * as S from "./index";
|
|
9
|
+
import { flatFieldsOf } from "./utils";
|
|
10
|
+
|
|
11
|
+
const assetsDir = fileURLToPath(new URL("../../assets", import.meta.url));
|
|
12
|
+
|
|
13
|
+
const issuer = "https://my-issuer.id";
|
|
14
|
+
const key = await Ed25519VerificationKey2020.generate({
|
|
15
|
+
id: `${issuer}/keys/1`,
|
|
16
|
+
controller: `${issuer}/issuers/1`,
|
|
17
|
+
});
|
|
18
|
+
const env = { id: `${issuer}/credentials/1`, approvedAt: new Date("2022-01-01") };
|
|
19
|
+
|
|
20
|
+
// Every version issued, verified, and parsed: JSON-LD @protected rejects undefined terms,
|
|
21
|
+
// so this is what proves the generated contexts actually cover the classes.
|
|
22
|
+
describe("all versions issue + verify + parse", () => {
|
|
23
|
+
it("KycV1", async () => {
|
|
24
|
+
const c = new S.VerifiableCredentialKycV1()
|
|
25
|
+
.setEnvelope(env)
|
|
26
|
+
.setSubject({
|
|
27
|
+
id: "uuid:v1",
|
|
28
|
+
applicantId: "a1",
|
|
29
|
+
inquiryId: "i1",
|
|
30
|
+
firstName: "Ada",
|
|
31
|
+
middleName: "M",
|
|
32
|
+
nationality: "GB",
|
|
33
|
+
familyName: "L",
|
|
34
|
+
maidenName: "B",
|
|
35
|
+
gender: "F",
|
|
36
|
+
governmentId: "g",
|
|
37
|
+
governmentIdType: "NATIONAL_ID",
|
|
38
|
+
email: "a@b.com",
|
|
39
|
+
ssn: "1",
|
|
40
|
+
phoneNumber: "+1",
|
|
41
|
+
dateOfBirth: new Date("1815-12-10"),
|
|
42
|
+
placeOfBirth: "London",
|
|
43
|
+
selfieFile: Buffer.from("Selfie"),
|
|
44
|
+
})
|
|
45
|
+
.addSection("idDocument", {
|
|
46
|
+
country: "GB",
|
|
47
|
+
number: "1",
|
|
48
|
+
type: "PASSPORT",
|
|
49
|
+
dateOfIssue: new Date("2020-01-01"),
|
|
50
|
+
dateOfExpiry: new Date("2030-01-01"),
|
|
51
|
+
frontFile: Buffer.from("F"),
|
|
52
|
+
backFile: Buffer.from("B"),
|
|
53
|
+
})
|
|
54
|
+
.addSection("residentialAddress", {
|
|
55
|
+
street: "S",
|
|
56
|
+
houseNumber: "1",
|
|
57
|
+
additionalAddressInfo: "x",
|
|
58
|
+
region: "R",
|
|
59
|
+
city: "London",
|
|
60
|
+
postalCode: "P",
|
|
61
|
+
country: "GB",
|
|
62
|
+
proofCategory: "UTILITY_BILL",
|
|
63
|
+
proofDateOfIssue: new Date("2021-01-01"),
|
|
64
|
+
proofFile: Buffer.from("P"),
|
|
65
|
+
});
|
|
66
|
+
const vc = await c.issue(key);
|
|
67
|
+
expect((await verifyCredential(vc, [key]))[0]).toBe(true);
|
|
68
|
+
expect(await parseCredential(vc)).toBeInstanceOf(S.VerifiableCredentialKycV1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("KycV2", async () => {
|
|
72
|
+
const c = new S.VerifiableCredentialKycV2()
|
|
73
|
+
.setEnvelope({
|
|
74
|
+
...env,
|
|
75
|
+
kycLevel: 2,
|
|
76
|
+
issued: new Date("2022-01-01"),
|
|
77
|
+
expirationDate: new Date("2030-01-01"),
|
|
78
|
+
})
|
|
79
|
+
.setSubject({
|
|
80
|
+
id: "uuid:v2",
|
|
81
|
+
firstName: "Ada",
|
|
82
|
+
middleName: "M",
|
|
83
|
+
nationality: "GB",
|
|
84
|
+
familyName: "L",
|
|
85
|
+
maidenName: "B",
|
|
86
|
+
gender: "F",
|
|
87
|
+
email: "a@b.com",
|
|
88
|
+
phoneNumber: "+1",
|
|
89
|
+
ssn: "1",
|
|
90
|
+
dateOfBirth: new Date("1815-12-10"),
|
|
91
|
+
placeOfBirth: "London",
|
|
92
|
+
selfieFile: Buffer.from("S"),
|
|
93
|
+
})
|
|
94
|
+
.addSection("idDocument", {
|
|
95
|
+
country: "GB",
|
|
96
|
+
number: "1",
|
|
97
|
+
type: "PASSPORT",
|
|
98
|
+
frontFile: Buffer.from("F"),
|
|
99
|
+
});
|
|
100
|
+
const vc = await c.issue(key);
|
|
101
|
+
expect((await verifyCredential(vc, [key]))[0]).toBe(true);
|
|
102
|
+
expect(await parseCredential(vc)).toBeInstanceOf(S.VerifiableCredentialKycV2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("EddV1", async () => {
|
|
106
|
+
const c = new S.VerifiableCredentialEddV1()
|
|
107
|
+
.setEnvelope(env)
|
|
108
|
+
.setSubject({ id: "uuid:edd" })
|
|
109
|
+
.addSection("edd", {
|
|
110
|
+
occupation: "AGRICULTURE",
|
|
111
|
+
sourceOfFundsCategory: "SALARY",
|
|
112
|
+
sourceOfFundsProofFile: Buffer.from("P"),
|
|
113
|
+
});
|
|
114
|
+
const vc = await c.issue(key);
|
|
115
|
+
expect((await verifyCredential(vc, [key]))[0]).toBe(true);
|
|
116
|
+
expect(await parseCredential(vc)).toBeInstanceOf(S.VerifiableCredentialEddV1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("FaceIdV1", async () => {
|
|
120
|
+
const c = new S.VerifiableCredentialFaceIdV1()
|
|
121
|
+
.setEnvelope(env)
|
|
122
|
+
.setSubject({ faceSignUserId: "11111111-1111-1111-1111-111111111111" });
|
|
123
|
+
const vc = await c.issue(key);
|
|
124
|
+
expect((await verifyCredential(vc, [key]))[0]).toBe(true);
|
|
125
|
+
expect(await parseCredential(vc)).toBeInstanceOf(S.VerifiableCredentialFaceIdV1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("KycV3 with every section populated", async () => {
|
|
129
|
+
const c = new S.VerifiableCredentialKycV3()
|
|
130
|
+
.setEnvelope(env)
|
|
131
|
+
.setSubject({ id: "uuid:v3" })
|
|
132
|
+
.addSection("person", {
|
|
133
|
+
firstName: "J",
|
|
134
|
+
familyName: "D",
|
|
135
|
+
middleName: "M",
|
|
136
|
+
fatherName: "F",
|
|
137
|
+
maidenName: "N",
|
|
138
|
+
motherName: "MO",
|
|
139
|
+
gender: "M",
|
|
140
|
+
nationality: "US",
|
|
141
|
+
secondNationality: "GB",
|
|
142
|
+
dateOfBirth: new Date("1990-01-01"),
|
|
143
|
+
placeOfBirth: "NY",
|
|
144
|
+
regionOfBirth: "NY",
|
|
145
|
+
stateless: false,
|
|
146
|
+
refugeeStatus: false,
|
|
147
|
+
subsidiaryProtectionStatus: false,
|
|
148
|
+
nationalIdNumber: "1",
|
|
149
|
+
taxIdNumber: "2",
|
|
150
|
+
taxIdIssuingCountry: "US",
|
|
151
|
+
taxResidenceCountry: "US",
|
|
152
|
+
ssn: "3",
|
|
153
|
+
})
|
|
154
|
+
.addSection("idDocument", {
|
|
155
|
+
type: "PASSPORT",
|
|
156
|
+
number: "1",
|
|
157
|
+
country: "US",
|
|
158
|
+
dateOfExpiry: new Date("2030-01-01"),
|
|
159
|
+
dateOfIssue: new Date("2020-01-01"),
|
|
160
|
+
issuingAuthority: "DOS",
|
|
161
|
+
frontFile: Buffer.from("F"),
|
|
162
|
+
frontFileType: "image/jpeg",
|
|
163
|
+
frontFileName: "front.jpg",
|
|
164
|
+
backFile: Buffer.from("B"),
|
|
165
|
+
backFileType: "image/jpeg",
|
|
166
|
+
backFileName: "back.jpg",
|
|
167
|
+
mrzLine1: "P<USA",
|
|
168
|
+
title: "Dr",
|
|
169
|
+
extendedValidUntil: new Date("2031-01-01"),
|
|
170
|
+
additionalNumber: "9",
|
|
171
|
+
ethnicity: "x",
|
|
172
|
+
issuingSubdivision: "CA",
|
|
173
|
+
})
|
|
174
|
+
.addSection("contact", { email: "j@e.com", phoneNumber: "+1" })
|
|
175
|
+
.addSection("biometric", { selfieFile: Buffer.from("S"), selfieMatch: 99 })
|
|
176
|
+
.addSection("residentialAddress", {
|
|
177
|
+
source: "PROOF_OF_ADDRESS",
|
|
178
|
+
street: "S",
|
|
179
|
+
houseNumber: "1",
|
|
180
|
+
additionalAddressInfo: "x",
|
|
181
|
+
region: "R",
|
|
182
|
+
city: "NY",
|
|
183
|
+
postalCode: "P",
|
|
184
|
+
country: "US",
|
|
185
|
+
proofCategory: "UTILITY_BILL",
|
|
186
|
+
proofDateOfIssue: new Date("2021-01-01"),
|
|
187
|
+
proofFile: Buffer.from("P"),
|
|
188
|
+
proofFileType: "image/jpeg",
|
|
189
|
+
proofFileName: "proof.jpg",
|
|
190
|
+
ipCountry: "US",
|
|
191
|
+
})
|
|
192
|
+
.addSection("screening", {
|
|
193
|
+
sanctionsCheckResult: "CLEAR",
|
|
194
|
+
sanctionsConfidenceScore: 95,
|
|
195
|
+
pepCheckResult: "CLEAR",
|
|
196
|
+
pepConfidenceScore: 10,
|
|
197
|
+
})
|
|
198
|
+
.addSection("edd", {
|
|
199
|
+
occupation: "REAL_ESTATE",
|
|
200
|
+
sourceOfFundsCategory: "SALARY",
|
|
201
|
+
sourceOfFundsProofFile: Buffer.from("F"),
|
|
202
|
+
sourceOfFundsProofFileType: "application/pdf",
|
|
203
|
+
sourceOfFundsProofFileName: "funds.pdf",
|
|
204
|
+
})
|
|
205
|
+
.addSection("sourceOfWealth", {
|
|
206
|
+
type: "SALARY",
|
|
207
|
+
yearlyGrossIncome: "LESS_THAN_20000",
|
|
208
|
+
yearlyGrossIncomeCurrency: "EUR",
|
|
209
|
+
approximateNetWorth: "UP_TO_25000",
|
|
210
|
+
approximateNetWorthCurrency: "EUR",
|
|
211
|
+
sourceOfWealthProofFile: Buffer.from("P"),
|
|
212
|
+
})
|
|
213
|
+
.addSection("onboarding", {
|
|
214
|
+
intendedUse: "INVESTING",
|
|
215
|
+
employmentStatus: "EMPLOYED",
|
|
216
|
+
expectedMonthlyTransactionCount: "LESS_THAN_5",
|
|
217
|
+
expectedMonthlyTransactionVolume: "MORE_THAN_500_LESS_THAN_2000",
|
|
218
|
+
expectedMonthlyTransactionVolumeCurrency: "EUR",
|
|
219
|
+
});
|
|
220
|
+
const vc = await c.issue(key);
|
|
221
|
+
expect((await verifyCredential(vc, [key]))[0]).toBe(true);
|
|
222
|
+
const parsed = await parseCredential(vc);
|
|
223
|
+
expect(parsed).toBeInstanceOf(S.VerifiableCredentialKycV3);
|
|
224
|
+
expect(c.kycLevel()).toBe(3);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Every generated context key must be a key the class accepts, and vice versa.
|
|
229
|
+
describe("generated contexts match the classes", () => {
|
|
230
|
+
const cases = [
|
|
231
|
+
["idos-credential-subject-v1", S.VerifiableCredentialKycV1],
|
|
232
|
+
["idos-credential-subject-v2", S.VerifiableCredentialKycV2],
|
|
233
|
+
["idos-credential-subject-v3", S.VerifiableCredentialKycV3],
|
|
234
|
+
["idos-credential-subject-face-id-v1", S.VerifiableCredentialFaceIdV1],
|
|
235
|
+
["idos-credential-subject-edd-v1", S.VerifiableCredentialEddV1],
|
|
236
|
+
] as const;
|
|
237
|
+
|
|
238
|
+
it.each(cases)("%s", async (asset, Version) => {
|
|
239
|
+
const ctx = JSON.parse(readFileSync(`${assetsDir}/${asset}.json`, "utf8"))["@context"];
|
|
240
|
+
const ctxKeys = Object.keys(ctx).filter(
|
|
241
|
+
(k) => !k.startsWith("@") && k !== "xsd" && k !== "aux",
|
|
242
|
+
);
|
|
243
|
+
// biome-ignore lint: reading a private field for review purposes
|
|
244
|
+
const accepted = flatFieldsOf((new Version() as any).subjectClass);
|
|
245
|
+
const notAccepted = ctxKeys.filter((k) => !accepted.has(k));
|
|
246
|
+
const notInContext = [...accepted].filter(
|
|
247
|
+
(k) => !ctxKeys.includes(k) && !["id", "issued", "expirationDate"].includes(k),
|
|
248
|
+
);
|
|
249
|
+
expect({ notAccepted, notInContext }).toEqual({ notAccepted: [], notInContext: [] });
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value a credential may publish in its unencrypted public notes.
|
|
3
|
+
*
|
|
4
|
+
* A `Date` is accepted here, but public notes travel as JSON: once stored, a `Date` is an
|
|
5
|
+
* ISO string, and every consumer reads it as one.
|
|
6
|
+
*/
|
|
7
|
+
export type PublicNotesAllowedValues = number | string | Date | undefined;
|
|
8
|
+
export type PublicNotes = Record<
|
|
9
|
+
string,
|
|
10
|
+
PublicNotesAllowedValues | Record<string, PublicNotesAllowedValues> | undefined
|
|
11
|
+
>;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import "reflect-metadata";
|
|
2
|
+
import { base85ToFile, fileToBase85 } from "@idos-network/utils/codecs";
|
|
3
|
+
import { Transform, Type, instanceToPlain, plainToInstance } from "class-transformer";
|
|
4
|
+
import {
|
|
5
|
+
IsDate,
|
|
6
|
+
IsInstance,
|
|
7
|
+
ValidateIf,
|
|
8
|
+
ValidateNested,
|
|
9
|
+
type ValidationError,
|
|
10
|
+
getMetadataStorage,
|
|
11
|
+
validateSync,
|
|
12
|
+
} from "class-validator";
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* A credential subject travels the wire flat: every section is merged into one object with
|
|
16
|
+
* its own name as a prefix (`person` + `firstName` -> `personFirstName`), while `root`
|
|
17
|
+
* contributes its fields unprefixed. `@Section` records the mapping so both directions are
|
|
18
|
+
* derived from the class instead of being generated or hand-written per version.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const SECTIONS = Symbol("idos:sections");
|
|
22
|
+
|
|
23
|
+
type Ctor<T> = new () => T;
|
|
24
|
+
|
|
25
|
+
interface SectionMeta {
|
|
26
|
+
property: string;
|
|
27
|
+
prefix: string;
|
|
28
|
+
type: () => Ctor<object>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function flatKey(prefix: string, field: string): string {
|
|
32
|
+
return prefix ? `${prefix}${field.charAt(0).toUpperCase()}${field.slice(1)}` : field;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Declares a property as a flattened section, and wires up the nested class for
|
|
37
|
+
* class-transformer and class-validator. Pass `prefix: ""` to keep the section's field
|
|
38
|
+
* names as they are — that is what `root` does.
|
|
39
|
+
*
|
|
40
|
+
* Presence stays the caller's business: pair with `@IsDefined()` for a mandatory section
|
|
41
|
+
* or `@IsOptional()` for an optional one.
|
|
42
|
+
*/
|
|
43
|
+
export function Section(type: () => Ctor<object>, prefix?: string): PropertyDecorator {
|
|
44
|
+
return (target, propertyKey) => {
|
|
45
|
+
const property = String(propertyKey);
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
* Read inherited sections but define our own copy, so a subclass extending a subject
|
|
49
|
+
* sees its parent's sections without mutating the parent's array.
|
|
50
|
+
*/
|
|
51
|
+
const own = Reflect.getOwnMetadata(SECTIONS, target.constructor) as SectionMeta[] | undefined;
|
|
52
|
+
const sections = own ?? [
|
|
53
|
+
...((Reflect.getMetadata(SECTIONS, target.constructor) as SectionMeta[] | undefined) ?? []),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
sections.push({ property, prefix: prefix ?? property, type });
|
|
57
|
+
Reflect.defineMetadata(SECTIONS, sections, target.constructor);
|
|
58
|
+
|
|
59
|
+
Type(type)(target, propertyKey);
|
|
60
|
+
ValidateNested()(target, propertyKey);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function sectionsOf(cls: Ctor<object>): SectionMeta[] {
|
|
65
|
+
return (Reflect.getMetadata(SECTIONS, cls) as SectionMeta[] | undefined) ?? [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The field names of a class, taken from its validation metadata — which is why every
|
|
70
|
+
* field needs at least one validation decorator to survive a round trip. Inherited fields
|
|
71
|
+
* are included.
|
|
72
|
+
*/
|
|
73
|
+
export function fieldsOf(cls: Ctor<object>): string[] {
|
|
74
|
+
const metadata = getMetadataStorage().getTargetValidationMetadatas(cls, cls.name, true, false);
|
|
75
|
+
|
|
76
|
+
return [...new Set(metadata.map((entry) => entry.propertyName))];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Flattens an instance to its wire form. Classes declaring `@Section` properties merge
|
|
81
|
+
* those sections in with their prefixes; anything else contributes its own fields.
|
|
82
|
+
* Undefined values are dropped rather than emitted as explicit `undefined` keys.
|
|
83
|
+
*/
|
|
84
|
+
export function toFlat<T extends object>(instance: T): Record<string, unknown> {
|
|
85
|
+
const plain = instanceToPlain(instance) as Record<string, unknown>;
|
|
86
|
+
const sections = new Map(
|
|
87
|
+
sectionsOf(instance.constructor as Ctor<object>).map(({ property, prefix }) => [
|
|
88
|
+
property,
|
|
89
|
+
prefix,
|
|
90
|
+
]),
|
|
91
|
+
);
|
|
92
|
+
const flat: Record<string, unknown> = {};
|
|
93
|
+
|
|
94
|
+
for (const [key, value] of Object.entries(plain)) {
|
|
95
|
+
/*
|
|
96
|
+
* A key the class declares as a section is merged in with its prefix; anything else is a
|
|
97
|
+
* field of the class itself, like the subject's `id`, and travels as it is.
|
|
98
|
+
*/
|
|
99
|
+
if (!sections.has(key)) {
|
|
100
|
+
if (value !== undefined) flat[key] = value;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!value) continue;
|
|
105
|
+
|
|
106
|
+
const prefix = sections.get(key) as string;
|
|
107
|
+
|
|
108
|
+
for (const [field, nested] of Object.entries(value as Record<string, unknown>)) {
|
|
109
|
+
if (nested !== undefined) flat[flatKey(prefix, field)] = nested;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return flat;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The flat wire keys a class accepts: its own fields, plus each section's prefixed fields. */
|
|
117
|
+
export function flatFieldsOf(cls: Ctor<object>): Set<string> {
|
|
118
|
+
const sections = sectionsOf(cls);
|
|
119
|
+
const sectionProperties = new Set(sections.map(({ property }) => property));
|
|
120
|
+
const keys = fieldsOf(cls).filter((field) => !sectionProperties.has(field));
|
|
121
|
+
|
|
122
|
+
for (const { prefix, type } of sections) {
|
|
123
|
+
for (const field of fieldsOf(type())) keys.push(flatKey(prefix, field));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return new Set(keys);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Rebuilds an instance from its wire form, the inverse of `toFlat`. Only keys the class
|
|
131
|
+
* declares are read, so extras a credential happens to carry (`proof`, `@context`, an
|
|
132
|
+
* unknown section) are dropped instead of landing on the instance.
|
|
133
|
+
*/
|
|
134
|
+
export function fromFlat<T extends object>(cls: Ctor<T>, flat: Record<string, unknown>): T {
|
|
135
|
+
const sections = sectionsOf(cls);
|
|
136
|
+
const sectionProperties = new Set(sections.map(({ property }) => property));
|
|
137
|
+
const nested: Record<string, unknown> = {};
|
|
138
|
+
|
|
139
|
+
// The class's own fields — everything it declares that is not a section, like a subject's `id`.
|
|
140
|
+
for (const field of fieldsOf(cls)) {
|
|
141
|
+
if (!sectionProperties.has(field) && Object.hasOwn(flat, field)) nested[field] = flat[field];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const { property, prefix, type } of sections) {
|
|
145
|
+
const section: Record<string, unknown> = {};
|
|
146
|
+
|
|
147
|
+
for (const field of fieldsOf(type())) {
|
|
148
|
+
const key = flatKey(prefix, field);
|
|
149
|
+
|
|
150
|
+
if (Object.hasOwn(flat, key)) section[field] = flat[key];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// An absent section stays absent — an empty object would validate as a broken section.
|
|
154
|
+
if (Object.keys(section).length > 0) nested[property] = section;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return plainToInstance(cls, nested);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Builds an instance from the subset of a class's fields a caller passed, applying the class's
|
|
162
|
+
* `toClassOnly` transforms — so either form of a value is accepted: an ISO string or a `Date`,
|
|
163
|
+
* ascii85 or a `Buffer`.
|
|
164
|
+
*
|
|
165
|
+
* Fields the caller left out stay absent rather than becoming an explicit `undefined`, which
|
|
166
|
+
* would overwrite a section already added.
|
|
167
|
+
*/
|
|
168
|
+
export function toClass<T extends object>(cls: Ctor<T>, fields: object): Partial<T> {
|
|
169
|
+
const instance = plainToInstance(cls, fields) as Record<string, unknown>;
|
|
170
|
+
|
|
171
|
+
return Object.fromEntries(Object.keys(fields).map((key) => [key, instance[key]])) as Partial<T>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function describe(errors: ValidationError[], path = ""): string[] {
|
|
175
|
+
return errors.flatMap((error) => {
|
|
176
|
+
const at = path ? `${path}.${error.property}` : error.property;
|
|
177
|
+
|
|
178
|
+
return [
|
|
179
|
+
...Object.values(error.constraints ?? {}).map((message) => `${at}: ${message}`),
|
|
180
|
+
...describe(error.children ?? [], at),
|
|
181
|
+
];
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export class CredentialValidationError extends Error {
|
|
186
|
+
override readonly name = "CredentialValidationError";
|
|
187
|
+
|
|
188
|
+
constructor(
|
|
189
|
+
label: string,
|
|
190
|
+
readonly errors: ValidationError[],
|
|
191
|
+
) {
|
|
192
|
+
super(`Invalid ${label}: ${describe(errors).join("; ")}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Throws `CredentialValidationError` unless `instance` and its sections are valid. */
|
|
197
|
+
export function assertValid(label: string, instance: object): void {
|
|
198
|
+
const errors = validateSync(instance);
|
|
199
|
+
|
|
200
|
+
if (errors.length > 0) throw new CredentialValidationError(label, errors);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Validates this property when `predicate` holds, and whenever it carries a value — so a
|
|
205
|
+
* conditionally required field is still type-checked when the condition is off.
|
|
206
|
+
*
|
|
207
|
+
* Do not combine with `@IsOptional()`: that skips every validator on an absent value,
|
|
208
|
+
* including this one.
|
|
209
|
+
*/
|
|
210
|
+
export function RequiredWhen(predicate: (object: any) => boolean): PropertyDecorator {
|
|
211
|
+
return (target, propertyKey) =>
|
|
212
|
+
ValidateIf(
|
|
213
|
+
(object: Record<string, unknown>) =>
|
|
214
|
+
predicate(object) || object[String(propertyKey)] !== undefined,
|
|
215
|
+
)(target, propertyKey);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** `RequiredWhen`, for the common case of a field required by the presence of another. */
|
|
219
|
+
export function RequiredWith(property: string): PropertyDecorator {
|
|
220
|
+
return RequiredWhen((object: Record<string, unknown>) => object[property] !== undefined);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/*
|
|
224
|
+
* Codecs: the encoded side is what a serialized credential carries, the decoded side is
|
|
225
|
+
* what a subject holds. A field declares its representation once, and neither direction
|
|
226
|
+
* inspects value types at serialization time.
|
|
227
|
+
*/
|
|
228
|
+
|
|
229
|
+
function decorate(...decorators: PropertyDecorator[]): PropertyDecorator {
|
|
230
|
+
return (target, propertyKey) => {
|
|
231
|
+
for (const decorator of decorators) decorator(target, propertyKey);
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A date carried as an ISO 8601 string on the wire, held as a `Date` in a subject. */
|
|
236
|
+
export function IsoDateField(): PropertyDecorator {
|
|
237
|
+
return decorate(
|
|
238
|
+
Transform(({ value }) => (value instanceof Date ? value.toISOString() : value), {
|
|
239
|
+
toPlainOnly: true,
|
|
240
|
+
}),
|
|
241
|
+
Transform(({ value }) => (typeof value === "string" ? new Date(value) : value), {
|
|
242
|
+
toClassOnly: true,
|
|
243
|
+
}),
|
|
244
|
+
// `new Date("nonsense")` yields an Invalid Date, which `IsDate` rejects.
|
|
245
|
+
IsDate(),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `fileToBase85` always emits delimited ascii85; `base85.decode` accepts anything. */
|
|
250
|
+
const ASCII85_FRAMED = /^<~[\s\S]*~>$/;
|
|
251
|
+
|
|
252
|
+
/** A file carried as ascii85 on the wire, held as a `Buffer` in a subject. */
|
|
253
|
+
export function Base85FileField(): PropertyDecorator {
|
|
254
|
+
return decorate(
|
|
255
|
+
Transform(({ value }) => (Buffer.isBuffer(value) ? fileToBase85(value) : value), {
|
|
256
|
+
toPlainOnly: true,
|
|
257
|
+
}),
|
|
258
|
+
/*
|
|
259
|
+
* Undecodable input is handed on untouched so `IsInstance` reports it, rather than
|
|
260
|
+
* being turned into a `false` that reads like an intentional value.
|
|
261
|
+
*/
|
|
262
|
+
Transform(
|
|
263
|
+
({ value }) => {
|
|
264
|
+
if (typeof value !== "string" || !ASCII85_FRAMED.test(value)) return value;
|
|
265
|
+
|
|
266
|
+
return base85ToFile(value) || value;
|
|
267
|
+
},
|
|
268
|
+
{ toClassOnly: true },
|
|
269
|
+
),
|
|
270
|
+
IsInstance(Buffer, {
|
|
271
|
+
message: ({ property }) => `${property} must be a file encoded as delimited ascii85`,
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
}
|