diff --git a/src/4.0/__tests__/documentBuilder.test.ts b/src/4.0/__tests__/documentBuilder.test.ts new file mode 100644 index 00000000..93135984 --- /dev/null +++ b/src/4.0/__tests__/documentBuilder.test.ts @@ -0,0 +1,381 @@ +import { verify } from "../verify"; +import { DocumentBuilder, DocumentBuilderErrors } from "../documentBuilder"; +import { isSignedWrappedDocument, isWrappedDocument, signDocument } from "../exports"; +import { SAMPLE_SIGNING_KEYS } from "../fixtures"; + +describe(`DocumentBuilder`, () => { + describe("given a single document", () => { + const document = new DocumentBuilder({ content: { name: "John Doe" }, name: "Diploma" }) + .embeddedRenderer({ + rendererUrl: "https://example.com", + templateName: "example", + }) + .dnsTxtIssuance({ + identityProofDomain: "example.com", + issuerId: "did:example:123", + issuerName: "Example University", + }); + + test("given sign and wrap is called, return a single signed document", async () => { + const signed = await document.wrapAndSign({ signer: SAMPLE_SIGNING_KEYS }); + expect(signed.issuer).toMatchInlineSnapshot(` + { + "id": "did:example:123", + "identityProof": { + "identifier": "example.com", + "identityProofType": "DNS-TXT", + }, + "name": "Example University", + "type": "OpenAttestationIssuer", + } + `); + expect(signed.credentialSubject).toMatchInlineSnapshot(` + { + "name": "John Doe", + } + `); + expect(signed.renderMethod).toMatchInlineSnapshot(` + [ + { + "id": "https://example.com", + "templateName": "example", + "type": "OpenAttestationEmbeddedRenderer", + }, + ] + `); + expect(isSignedWrappedDocument(signed)).toBe(true); + expect(verify(signed)).toBe(true); + }); + + test("given wrap is called, return a wrapped document", async () => { + const wrapped = await document.justWrapWithoutSigning(); + expect(wrapped.issuer).toMatchInlineSnapshot(` + { + "id": "did:example:123", + "identityProof": { + "identifier": "example.com", + "identityProofType": "DNS-TXT", + }, + "name": "Example University", + "type": "OpenAttestationIssuer", + } + `); + expect(wrapped.credentialSubject).toMatchInlineSnapshot(` + { + "name": "John Doe", + } + `); + expect(wrapped.renderMethod).toMatchInlineSnapshot(` + [ + { + "id": "https://example.com", + "templateName": "example", + "type": "OpenAttestationEmbeddedRenderer", + }, + ] + `); + expect(isWrappedDocument(wrapped)).toBe(true); + expect(isSignedWrappedDocument(wrapped)).toBe(false); + }); + }); + + describe("given a multiple documents", () => { + const document = new DocumentBuilder([ + { content: { name: "John Doe" }, name: "Diploma" }, + { content: { name: "Jane Foster" }, name: "Degree" }, + ]) + .embeddedRenderer({ + rendererUrl: "https://example.com", + templateName: "example", + }) + .dnsTxtIssuance({ + identityProofDomain: "example.com", + issuerId: "did:example:123", + issuerName: "Example University", + }); + + test("given sign and wrap is called, return a list of signed document", async () => { + const signed = await document.wrapAndSign({ signer: SAMPLE_SIGNING_KEYS }); + expect(signed[0].issuer).toMatchInlineSnapshot(` + { + "id": "did:example:123", + "identityProof": { + "identifier": "example.com", + "identityProofType": "DNS-TXT", + }, + "name": "Example University", + "type": "OpenAttestationIssuer", + } + `); + expect(signed[0].credentialSubject).toMatchInlineSnapshot(` + { + "name": "John Doe", + } + `); + expect(signed[0].renderMethod).toMatchInlineSnapshot(` + [ + { + "id": "https://example.com", + "templateName": "example", + "type": "OpenAttestationEmbeddedRenderer", + }, + ] + `); + expect(isSignedWrappedDocument(signed[0])).toBe(true); + expect(verify(signed[0])).toBe(true); + + expect(signed[1].issuer).toMatchInlineSnapshot(` + { + "id": "did:example:123", + "identityProof": { + "identifier": "example.com", + "identityProofType": "DNS-TXT", + }, + "name": "Example University", + "type": "OpenAttestationIssuer", + } + `); + expect(signed[1].credentialSubject).toMatchInlineSnapshot(` + { + "name": "Jane Foster", + } + `); + expect(signed[1].renderMethod).toMatchInlineSnapshot(` + [ + { + "id": "https://example.com", + "templateName": "example", + "type": "OpenAttestationEmbeddedRenderer", + }, + ] + `); + expect(isSignedWrappedDocument(signed[1])).toBe(true); + expect(verify(signed[1])).toBe(true); + }); + + test("given wrap is called, return a list of wrapped document", async () => { + const wrapped = await document.justWrapWithoutSigning(); + expect(wrapped[0].issuer).toMatchInlineSnapshot(` + { + "id": "did:example:123", + "identityProof": { + "identifier": "example.com", + "identityProofType": "DNS-TXT", + }, + "name": "Example University", + "type": "OpenAttestationIssuer", + } + `); + expect(wrapped[0].credentialSubject).toMatchInlineSnapshot(` + { + "name": "John Doe", + } + `); + expect(wrapped[0].renderMethod).toMatchInlineSnapshot(` + [ + { + "id": "https://example.com", + "templateName": "example", + "type": "OpenAttestationEmbeddedRenderer", + }, + ] + `); + expect(isWrappedDocument(wrapped[0])).toBe(true); + expect(isSignedWrappedDocument(wrapped[0])).toBe(false); + + expect(wrapped[1].issuer).toMatchInlineSnapshot(` + { + "id": "did:example:123", + "identityProof": { + "identifier": "example.com", + "identityProofType": "DNS-TXT", + }, + "name": "Example University", + "type": "OpenAttestationIssuer", + } + `); + expect(wrapped[1].credentialSubject).toMatchInlineSnapshot(` + { + "name": "Jane Foster", + } + `); + expect(wrapped[1].renderMethod).toMatchInlineSnapshot(` + [ + { + "id": "https://example.com", + "templateName": "example", + "type": "OpenAttestationEmbeddedRenderer", + }, + ] + `); + expect(isWrappedDocument(wrapped[1])).toBe(true); + expect(isSignedWrappedDocument(wrapped[1])).toBe(false); + }); + }); + + test("given additional properties in constructor payload, should not be added into the document", async () => { + const signed = await new DocumentBuilder({ + content: { name: "John Doe" }, + name: "Diploma", + anotherProperty: "value", + }) + .embeddedRenderer({ + rendererUrl: "https://example.com", + templateName: "example", + }) + .dnsTxtIssuance({ + identityProofDomain: "example.com", + issuerId: "did:example:123", + issuerName: "Example University", + }) + .wrapAndSign({ signer: SAMPLE_SIGNING_KEYS }); + + expect(signed).not.toHaveProperty("anotherProperty"); + }); + + test("given attachment is added, should be added into the document", async () => { + const signed = await new DocumentBuilder({ + content: { name: "John Doe" }, + name: "Diploma", + attachments: [ + { + data: "data", + fileName: "file", + mimeType: "application/pdf", + }, + ], + }) + .embeddedRenderer({ + rendererUrl: "https://example.com", + templateName: "example", + }) + .dnsTxtIssuance({ + identityProofDomain: "example.com", + issuerId: "did:example:123", + issuerName: "Example University", + }) + .wrapAndSign({ signer: SAMPLE_SIGNING_KEYS }); + + expect(signed.attachments).toMatchInlineSnapshot(` + [ + { + "data": "data", + "fileName": "file", + "mimeType": "application/pdf", + }, + ] + `); + }); + + test("given wrap only is called, should be able to sign the wrapped document with the standalone sign fn", async () => { + const wrapped = await new DocumentBuilder({ + content: { name: "John Doe" }, + name: "Diploma", + }) + .embeddedRenderer({ + rendererUrl: "https://example.com", + templateName: "example", + }) + .dnsTxtIssuance({ + identityProofDomain: "example.com", + issuerId: "did:example:123", + issuerName: "Example University", + }) + .justWrapWithoutSigning(); + + const signed = await signDocument(wrapped, "Secp256k1VerificationKey2018", SAMPLE_SIGNING_KEYS); + expect(isSignedWrappedDocument(signed)).toBe(true); + expect(verify(signed)).toBe(true); + }); + + test("given re-setting of values, should throw", async () => { + const builder = await new DocumentBuilder({ + content: { name: "John Doe" }, + name: "Diploma", + }); + + const documentWithRenderMethod = builder.embeddedRenderer({ + rendererUrl: "https://example.com", + templateName: "example", + }); + + expect(() => + builder.embeddedRenderer({ + rendererUrl: "https://another.com", + templateName: "another", + }) + ).toThrowError(DocumentBuilderErrors.ShouldNotModifyAfterSettingError); + + documentWithRenderMethod.dnsTxtIssuance({ + identityProofDomain: "example.com", + issuerId: "did:example:123", + issuerName: "Example University", + }); + + expect(() => + documentWithRenderMethod.dnsTxtIssuance({ + identityProofDomain: "another.com", + issuerId: "did:example:123", + issuerName: "Example University", + }) + ).toThrowError(DocumentBuilderErrors.ShouldNotModifyAfterSettingError); + }); + + describe("given invalid props", () => { + test("given an invalid attachment, should throw", () => { + let error; + expect(() => { + try { + new DocumentBuilder({ + content: { name: "John Doe" }, + name: "Diploma", + attachments: [ + { + data: "data", + fileName: "file", + } as any, + ], + }); + } catch (e) { + error = e; + throw e; + } + }).toThrowError(DocumentBuilderErrors.PropsValidationError); + expect(error).toBeInstanceOf(DocumentBuilderErrors.PropsValidationError); + }); + + test("given an invalid identity identifier, should throw", () => { + const builder = new DocumentBuilder({ + content: { name: "John Doe" }, + name: "Diploma", + }).embeddedRenderer({ + rendererUrl: "https://example.com", + templateName: "example", + }); + let error; + expect(() => { + try { + builder.dnsTxtIssuance({ + identityProofDomain: "example.com", + issuerId: "invalid", + issuerName: "Example University", + }); + } catch (e) { + error = e; + throw e; + } + }).toThrowErrorMatchingInlineSnapshot(` + "Invalid props: + { + "_errors": [], + "issuerId": { + "_errors": [ + "Invalid URI" + ] + } + }" + `); + expect(error).toBeInstanceOf(DocumentBuilderErrors.PropsValidationError); + }); + }); +}); diff --git a/src/4.0/documentBuilder.ts b/src/4.0/documentBuilder.ts new file mode 100644 index 00000000..dbfca306 --- /dev/null +++ b/src/4.0/documentBuilder.ts @@ -0,0 +1,280 @@ +import { wrapDocument, wrapDocuments, wrapDocumentErrors } from "./wrap"; +import { signDocument, signDocumentErrors } from "./sign"; +import { + DecentralisedEmbeddedRenderer, + Override, + SvgRenderer, + V4Document, + V4SignedWrappedDocument, + V4WrappedDocument, +} from "./types"; + +import { ZodError, z } from "zod"; + +const SingleDocumentProps = z.object({ + name: V4Document.shape.name.unwrap(), + content: z.record(z.unknown()), + attachments: V4Document.shape.attachments, +}); + +const DocumentProps = z.union([SingleDocumentProps, z.array(SingleDocumentProps)]); + +const EmbeddedRendererProps = z.object({ + rendererUrl: DecentralisedEmbeddedRenderer.shape.id, + templateName: DecentralisedEmbeddedRenderer.shape.templateName, +}); + +const SvgRendererProps = z.object({ + urlOrEmbeddedSvg: SvgRenderer.shape.id, +}); + +const DnsTextIssuanceProps = z.object({ + issuerId: V4Document.shape.issuer.shape.id, + issuerName: V4Document.shape.issuer.shape.name, + identityProofDomain: V4Document.shape.issuer.shape.identityProof.shape.identifier, +}); + +class PropsValidationError extends Error { + constructor(public error: ZodError) { + super(`Invalid props: \n ${JSON.stringify(error.format(), null, 2)}`); + Object.setPrototypeOf(this, PropsValidationError.prototype); + } +} + +class ShouldNotModifyAfterSettingError extends Error { + constructor() { + super("Modifying what was already set can lead to unexpected behaviour, please consider creating a new instance"); + Object.setPrototypeOf(this, ShouldNotModifyAfterSettingError.prototype); + } +} + +type DocumentProps = { + /** Human readable name of the document */ + name: string; + /** Main content of the document */ + content: Record; + /** Attachments that will be rendered out of the box with decentralised renderer components */ + attachments?: V4Document["attachments"]; +}; + +/** + * A builder to simplify the creation of OAv4 document(s) + * This builder assumes that All documents share the same issuer AND renderMethod + * If this builder cannot satisfy your use case, please use the underlying wrap and sign functions directly + */ +export class DocumentBuilder { + private documentMainProps: DocumentProps | DocumentProps[]; + private renderMethod: V4Document["renderMethod"]; + private issuer: V4Document["issuer"] | undefined; + + constructor(props: Props) { + const parsedResults = DocumentProps.safeParse(props); + if (!parsedResults.success) throw new PropsValidationError(parsedResults.error); + this.documentMainProps = parsedResults.data; + } + + private wrap = async (): Promise> => { + const data = this.documentMainProps; + const issuer = this.issuer; + + // this should never happen + if (!issuer) throw new Error("Issuer is required"); + if (Array.isArray(data)) { + const toWrap = data.map( + ({ name, content, attachments }) => + ({ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://schemata.openattestation.com/com/openattestation/4.0/alpha-context.json", + ], + type: ["VerifiableCredential", "OpenAttestationCredential"], + issuer, + name, + credentialSubject: content, + renderMethod: this.renderMethod, + ...(attachments && { attachments }), + } satisfies V4Document) + ); + + return wrapDocuments(toWrap) as unknown as WrappedReturn; + } + + // this should never happen + if (!data) throw new Error("CredentialSubject is required"); + + const { name, content, attachments } = data; + return wrapDocument({ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://schemata.openattestation.com/com/openattestation/4.0/alpha-context.json", + ], + type: ["VerifiableCredential", "OpenAttestationCredential"], + issuer, + name, + credentialSubject: content, + renderMethod: this.renderMethod, + ...(attachments && { attachments }), + }) as unknown as WrappedReturn; + }; + + private sign = async (props: { signer: Parameters[2] }): Promise> => { + const wrapped = await this.wrap(); + if (Array.isArray(wrapped)) { + return Promise.all(wrapped.map((d) => signDocument(d, "Secp256k1VerificationKey2018", props.signer))) as Promise< + SignedReturn + >; + } + + return signDocument(wrapped, "Secp256k1VerificationKey2018", props.signer) as Promise>; + }; + + // add issuance methods here + private ISSUANCE_METHODS = { + // not supported right now + // blockchainIssuance: (props: { + // /** A unique ID of the issuer that MUST BE in a URI */ + // issuerId: string; + // issuerName: string; + // /** should be in the form of "did:ethr:0x${string}#controller" */ + // ethDid: string; + // /** */ + // identityProofDomain: string; + // }) => { + // this.issuer = { + // id: props.issuerId, + // name: props.issuerName, + // type: "OpenAttestationIssuer", + // identityProof: { + // identityProofType: "DNS-DID", + // identifier: props.ethDid, + // }, + // }; + // return { + // wrap: this.wrap, + // }; + // }, + + dnsTxtIssuance: (props: { + /** A unique ID of the issuer that MUST BE in a URI */ + issuerId: string; + /** Human readable name of the issuer */ + issuerName: string; + /** Domain where DNS TXT record proof is located */ + identityProofDomain: string; + }) => { + if (this.issuer) throw new ShouldNotModifyAfterSettingError(); + + const parsedResults = DnsTextIssuanceProps.safeParse(props); + if (!parsedResults.success) throw new PropsValidationError(parsedResults.error); + const { issuerId, issuerName, identityProofDomain } = parsedResults.data; + + this.issuer = { + id: issuerId, + name: issuerName, + type: "OpenAttestationIssuer", + identityProof: { + identityProofType: "DNS-TXT", + identifier: identityProofDomain, + }, + }; + + return { + /** + * wrap and signs the entire batch AT ONE GO, there is no internal batching + * logic so please use with caution, especially for large batches + */ + wrapAndSign: this.sign, + /** + * there are instances where you want to take control of the signing process + * for example you might want to sign in smaller batches + */ + justWrapWithoutSigning: this.wrap, + }; + }, + }; + + public embeddedRenderer = (props: { + /** URL where the renderer is hosted */ + rendererUrl: string; + /** Template identifier to "select" the correct template on the renderer */ + templateName: string; + }) => { + if (this.renderMethod) throw new ShouldNotModifyAfterSettingError(); + + const parsedResults = EmbeddedRendererProps.safeParse(props); + if (!parsedResults.success) throw new PropsValidationError(parsedResults.error); + const { rendererUrl, templateName } = parsedResults.data; + + this.renderMethod = [ + { + id: rendererUrl, + type: "OpenAttestationEmbeddedRenderer", + templateName, + }, + ]; + + return this.ISSUANCE_METHODS; + }; + + public svgRenderer = (props: { urlOrEmbeddedSvg: string }) => { + if (this.renderMethod) throw new ShouldNotModifyAfterSettingError(); + + const parsedResults = SvgRendererProps.safeParse(props); + if (!parsedResults.success) throw new PropsValidationError(parsedResults.error); + const { urlOrEmbeddedSvg } = parsedResults.data; + + this.renderMethod = [ + { + id: urlOrEmbeddedSvg, + type: "SvgRenderingTemplate2023", + }, + ]; + + return this.ISSUANCE_METHODS; + }; +} + +type SignedReturn = Props extends Array + ? Override< + V4SignedWrappedDocument, + { + name: Props[number]["name"]; + credentialSubject: Props[number]["content"]; + } + >[] + : Props extends DocumentProps + ? Override< + V4SignedWrappedDocument, + { + name: Props["name"]; + credentialSubject: Props["content"]; + } + > + : never; + +type WrappedReturn = Props extends Array + ? Override< + V4WrappedDocument, + { + name: Props[number]["name"]; + credentialSubject: Props[number]["content"]; + } + >[] + : Props extends DocumentProps + ? Override< + V4WrappedDocument, + { + name: Props["name"]; + credentialSubject: Props["content"]; + } + > + : never; + +const { UnableToInterpretContextError } = wrapDocumentErrors; +const { CouldNotSignDocumentError } = signDocumentErrors; +export const DocumentBuilderErrors = { + PropsValidationError, + ShouldNotModifyAfterSettingError, + UnableToInterpretContextError, + CouldNotSignDocumentError, +}; diff --git a/src/4.0/exports/builder.ts b/src/4.0/exports/builder.ts new file mode 100644 index 00000000..9d129cf1 --- /dev/null +++ b/src/4.0/exports/builder.ts @@ -0,0 +1 @@ +export { DocumentBuilder, DocumentBuilderErrors } from "../documentBuilder"; diff --git a/src/4.0/exports/index.ts b/src/4.0/exports/index.ts index 9ddeb6f0..40d37afe 100644 --- a/src/4.0/exports/index.ts +++ b/src/4.0/exports/index.ts @@ -4,3 +4,4 @@ export * from "./obfuscate"; export * from "./verify"; export * from "./utils"; export * from "./types"; +export * from "./builder"; diff --git a/src/4.0/types.ts b/src/4.0/types.ts index a134d957..2a8f6219 100644 --- a/src/4.0/types.ts +++ b/src/4.0/types.ts @@ -154,6 +154,30 @@ export const W3cVerifiableCredential = _W3cVerifiableCredential.passthrough(); const IdentityProofType = z.union([z.literal("DNS-TXT"), z.literal("DNS-DID"), z.literal("DID")]); const Salt = z.object({ value: z.string(), path: z.string() }); +export const DecentralisedEmbeddedRenderer = z.object({ + // Must have id match url pattern + id: z.string().url().describe("URL of a decentralised renderer to render this document"), + type: z.literal("OpenAttestationEmbeddedRenderer"), + templateName: z.string(), +}); + +export const SvgRenderer = z.object({ + // Must have id match url pattern or embeded SVG string + id: z + .union([z.string(), z.string().url()]) + .describe( + "A URL that dereferences to an SVG image [SVG] with an associated image/svg+xml media type. Or an embedded SVG image [SVG]" + ), + type: z.literal("SvgRenderingTemplate2023"), + name: z.string().optional(), + digestMultibase: z + .string() + .describe( + "An optional multibase-encoded multihash of the SVG image. The multibase value MUST be z and the multihash value MUST be SHA-2 with 256-bits of output (0x12)." + ) + .optional(), +}); + export const V4Document = _W3cVerifiableCredential .extend({ "@context": z @@ -190,36 +214,7 @@ export const V4Document = _W3cVerifiableCredential .optional(), // [Optional] Render Method - renderMethod: z - .array( - z.discriminatedUnion("type", [ - /* OA Decentralised Embedded Renderer */ - z.object({ - // Must have id match url pattern - id: z.string().url().describe("URL of a decentralised renderer to render this document"), - type: z.literal("OpenAttestationEmbeddedRenderer"), - templateName: z.string(), - }), - /* SVG Renderer (URL or Embedded) */ - z.object({ - // Must have id match url pattern or embeded SVG string - id: z - .union([z.string(), z.string().url()]) - .describe( - "A URL that dereferences to an SVG image [SVG] with an associated image/svg+xml media type. Or an embedded SVG image [SVG]" - ), - type: z.literal("SvgRenderingTemplate2023"), - name: z.string().optional(), - digestMultibase: z - .string() - .describe( - "An optional multibase-encoded multihash of the SVG image. The multibase value MUST be z and the multihash value MUST be SHA-2 with 256-bits of output (0x12)." - ) - .optional(), - }), - ]) - ) - .optional(), + renderMethod: z.array(z.discriminatedUnion("type", [DecentralisedEmbeddedRenderer, SvgRenderer])).optional(), // [Optional] Attachments attachments: z