@idos-network/credentials 1.5.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.
Files changed (82) hide show
  1. package/README.md +289 -85
  2. package/assets/idos-credential-subject-edd-v1.json +10 -0
  3. package/assets/idos-credential-subject-v1.json +1 -1
  4. package/assets/idos-credential-subject-v2.json +1 -1
  5. package/assets/idos-credential-subject-v3.json +83 -0
  6. package/assets/idos-credentials-v1.json +2 -2
  7. package/assets/idos-credentials-v2.json +10 -0
  8. package/dist/index-0NqXMjNk.d.mts +395 -0
  9. package/dist/index-0NqXMjNk.d.mts.map +1 -0
  10. package/dist/index-WW372Tf5.d.mts +57 -0
  11. package/dist/index-WW372Tf5.d.mts.map +1 -0
  12. package/dist/index.mjs +1 -1
  13. package/dist/loader-Cg6hs1-P.mjs +2 -0
  14. package/dist/loader-Cg6hs1-P.mjs.map +1 -0
  15. package/dist/parser/index.d.mts +19 -0
  16. package/dist/parser/index.d.mts.map +1 -0
  17. package/dist/parser/index.mjs +2 -0
  18. package/dist/parser/index.mjs.map +1 -0
  19. package/dist/schemas/index.d.mts +2 -0
  20. package/dist/schemas/index.mjs +1 -0
  21. package/dist/schemas-BVGuUlPe.mjs +2 -0
  22. package/dist/schemas-BVGuUlPe.mjs.map +1 -0
  23. package/dist/types/index.d.mts +2 -134
  24. package/dist/types/index.mjs +1 -63
  25. package/dist/utils/index.d.mts +37 -15
  26. package/dist/utils/index.d.mts.map +1 -1
  27. package/dist/utils/index.mjs +1 -116
  28. package/dist/utils/index.mjs.map +1 -1
  29. package/dist/verifier/index.d.mts +8 -0
  30. package/dist/verifier/index.d.mts.map +1 -0
  31. package/dist/verifier/index.mjs +2 -0
  32. package/dist/verifier/index.mjs.map +1 -0
  33. package/package.json +21 -9
  34. package/src/parser/index.test.ts +266 -0
  35. package/src/parser/index.ts +69 -0
  36. package/src/schemas/EddV1/index.test.ts +40 -0
  37. package/src/schemas/EddV1/index.ts +53 -0
  38. package/src/schemas/Envelope.ts +37 -0
  39. package/src/schemas/FaceIdV1/index.test.ts +26 -0
  40. package/src/schemas/FaceIdV1/index.ts +33 -0
  41. package/src/schemas/KycV1/IdDocument.ts +41 -0
  42. package/src/schemas/KycV1/ResidentialAddress.ts +56 -0
  43. package/src/schemas/KycV1/Subject.ts +86 -0
  44. package/src/schemas/KycV1/index.test.ts +156 -0
  45. package/src/schemas/KycV1/index.ts +40 -0
  46. package/src/schemas/KycV2/Subject.ts +67 -0
  47. package/src/schemas/KycV2/index.test.ts +162 -0
  48. package/src/schemas/KycV2/index.ts +45 -0
  49. package/src/schemas/KycV3/Biometric.ts +14 -0
  50. package/src/schemas/KycV3/Contact.ts +13 -0
  51. package/src/schemas/KycV3/EDD.ts +32 -0
  52. package/src/schemas/KycV3/IdDocument.ts +92 -0
  53. package/src/schemas/KycV3/Onboarding.ts +36 -0
  54. package/src/schemas/KycV3/Person.ts +105 -0
  55. package/src/schemas/KycV3/ResidentialAddress.ts +73 -0
  56. package/src/schemas/KycV3/Screening.ts +27 -0
  57. package/src/schemas/KycV3/SourceOfWealth.ts +44 -0
  58. package/src/schemas/KycV3/Subject.ts +54 -0
  59. package/src/schemas/KycV3/index.test.ts +682 -0
  60. package/src/schemas/KycV3/index.ts +108 -0
  61. package/src/schemas/VerifiableCredential.ts +232 -0
  62. package/src/schemas/enums.ts +126 -0
  63. package/src/schemas/index.ts +14 -0
  64. package/src/schemas/integration.test.ts +251 -0
  65. package/src/schemas/types.ts +11 -0
  66. package/src/schemas/utils.ts +274 -0
  67. package/src/types/credentials.ts +18 -249
  68. package/src/utils/{utils.test.ts → index.test.ts} +59 -67
  69. package/src/utils/index.ts +129 -134
  70. package/src/utils/issuer.ts +42 -0
  71. package/src/{builder → utils}/loader.ts +22 -17
  72. package/src/verifier/index.test.ts +149 -0
  73. package/src/{builder/verifier.ts → verifier/index.ts} +4 -8
  74. package/types/internals.d.ts +11 -6
  75. package/dist/builder/index.d.mts +0 -19
  76. package/dist/builder/index.d.mts.map +0 -1
  77. package/dist/builder/index.mjs +0 -619
  78. package/dist/builder/index.mjs.map +0 -1
  79. package/dist/types/index.d.mts.map +0 -1
  80. package/dist/types/index.mjs.map +0 -1
  81. package/src/builder/builder.test.ts +0 -407
  82. 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
+ }