diff --git a/src/4.0/documentBuilder.ts b/src/4.0/documentBuilder.ts new file mode 100644 index 00000000..e29010e9 --- /dev/null +++ b/src/4.0/documentBuilder.ts @@ -0,0 +1,291 @@ +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 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); + } +} + +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 create documents + */ +export class DocumentBuilder { + private documentMainProps: DocumentProps | DocumentProps[]; + private renderMethod: V4Document["renderMethod"]; + private issuer: V4Document["issuer"] | undefined; + + constructor(props: Props) { + this.documentMainProps = props; + } + + 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>; + }; + + 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; + }) => { + 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; + }) => { + 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 }) => { + 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 = Data extends Array + ? Override< + V4SignedWrappedDocument, + { + name: Data[number]["name"]; + credentialSubject: Data[number]["content"]; + } + >[] + : Data extends DocumentProps + ? Override< + V4SignedWrappedDocument, + { + name: Data["name"]; + credentialSubject: Data["content"]; + } + > + : never; + +type WrappedReturn = Data extends Array + ? Override< + V4WrappedDocument, + { + name: Data[number]["name"]; + credentialSubject: Data[number]["content"]; + } + >[] + : Data extends DocumentProps + ? Override< + V4WrappedDocument, + { + name: Data["name"]; + credentialSubject: Data["content"]; + } + > + : never; + +const { UnableToInterpretContextError } = wrapDocumentErrors; +const { CouldNotSignDocumentError } = signDocumentErrors; +export const DocumentBuilderErrors = { + PropsValidationError, + UnableToInterpretContextError, + CouldNotSignDocumentError, +}; + +// Example usage +// import { SAMPLE_SIGNING_KEYS } from "./fixtures"; +// new DocumentBuilder({ +// name: "Republic of Singapore Driving Licence", +// content: { +// id: "urn:uuid:a013fb9d-bb03-4056-b696-05575eceaf42", +// type: ["DriversLicense"], +// name: "John Doe", +// licenses: [ +// { +// class: "3", +// description: "Motor cars with unladen weight <= 3000kg", +// effectiveDate: "2013-05-16T00:00:00+08:00", +// }, +// { +// class: "3A", +// description: "Motor cars with unladen weight <= 3000kg", +// effectiveDate: "2013-05-16T00:00:00+08:00", +// }, +// ], +// }, +// }) +// .embeddedRenderer({ +// templateName: "GOVTECH_DEMO", +// rendererUrl: "https://demo-renderer.opencerts.io", +// }) +// .dnsTxtIssuance({ +// identityProofDomain: "example.openattestation.com", +// issuerName: "Government Technology Agency of Singapore (GovTech)", +// issuerId: "urn:uuid:a013fb9d-bb03-4056-b696-05575eceaf42", +// }) +// .wrapAndSign({ +// signer: SAMPLE_SIGNING_KEYS, +// }) +// .then((signed) => { +// console.log(signed.credentialSubject); +// }); 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