diff --git a/model/protocol.cto b/model/protocol.cto index df8fb04..6a8bc99 100644 --- a/model/protocol.cto +++ b/model/protocol.cto @@ -87,6 +87,7 @@ concept TemplateMetadata { @description("An Accord Project template") concept Template identified by uri { o URI uri + o String hash optional o String author o String displayName optional o String version @@ -115,7 +116,7 @@ concept Metadata { @description("Runtime status of an agreement") enum AgreementStatusType { o DRAFT // No signatories have signed yet - o SIGNNG // Signed by some but not all signatories + o SIGNING // Signed by some but not all signatories o COMPLETED // Signing by all signatories completed o SUPERSEDED // Superseded by subsequent agreement } @@ -125,6 +126,7 @@ enum AgreementStatusType { concept Agreement identified by uri { o URI uri o JSON data + o String templateHash optional // Resolved hash of the template at the time the agreement was created --> Template template // Template for the the agreement // Data for the agreement (an instance of the data for its template) o JSON state optional // Runtime state of the agreement o AgreementStatusType agreementStatus // Current status of agreement @@ -169,7 +171,8 @@ concept Signature { o AgreementParty signatory // The Agreement Party signing the Agreement o DateTime signedAt optional // When the signing occurred o Metadata metadata // Geolocation data, IP address, etc. - o Blob[] signatureImage // Selfie, proof-of-id, sign-on-glass image + o Blob[] signatureImage optional // Selfie, proof-of-id, sign-on-glass image + o JSON verifiableCredential optional // W3C Verifiable Credential carrying the cryptographic proof } @description("Abstract conversion options") diff --git a/server/db/schema.ts b/server/db/schema.ts index 68b98bc..485ef7d 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -36,8 +36,8 @@ export const CodeEncodingType = pgEnum('CodeEncodingType', ['PLAIN_TEXT', export const Template = pgTable("Template", { id: serial().primaryKey(), uri: text().unique().notNull(), + hash: text(), author: text().notNull(), - hash: varchar({ length: 64 }).unique(), displayName: text(), version: text().notNull(), description: text(), @@ -55,7 +55,7 @@ export const TemplateInsertSchema = createInsertSchema(Template); export const TemplateUpdateSchema = createUpdateSchema(Template); export const AgreementStatusType = pgEnum('AgreementStatusType', ['DRAFT', -'SIGNNG', +'SIGNING', 'COMPLETED', 'SUPERSEDED']); @@ -64,8 +64,8 @@ export const Agreement = pgTable("Agreement", { id: serial().primaryKey(), uri: text().unique().notNull(), data: json().notNull(), + templateHash: text(), template: text(), - templateHash: varchar({ length: 64 }), state: json(), agreementStatus: AgreementStatusType().notNull(), agreementParties: json().array(), @@ -92,4 +92,4 @@ export const FeatureType = pgEnum('FeatureType', ['TEMPLATE_MANAGE', 'AGREEMENT_CONVERT_HTML', 'AGREEMENT_SIGNING', 'SHARED_MODEL_MANAGE']); -export const MODEL="QGRlc2NyaXB0aW9uKCJBY2NvcmQgUHJvamVjdCBBZ3JlZW1lbnQgUHJvdG9jb2wiKQpuYW1lc3BhY2Ugb3JnLmFjY29yZHByb2plY3QucHJvdG9jb2xAMS4wLjAKCmltcG9ydCBjb25jZXJ0by5tZXRhbW9kZWxAMC40LjAue1Byb3BlcnR5LENvbmNlcHREZWNsYXJhdGlvbixNb2RlbH0gZnJvbSBodHRwczovL21vZGVscy5hY2NvcmRwcm9qZWN0Lm9yZy9jb25jZXJ0by9tZXRhbW9kZWxAMC40LjAuY3RvCmltcG9ydCBvcmcuYWNjb3JkcHJvamVjdC5jb21tb25tYXJrQDAuNS4wLkRvY3VtZW50IGZyb20gaHR0cHM6Ly9tb2RlbHMuYWNjb3JkcHJvamVjdC5vcmcvbWFya2Rvd24vY29tbW9ubWFya0AwLjUuMC5jdG8KaW1wb3J0IG9yZy5hY2NvcmRwcm9qZWN0LnBhcnR5QDAuMi4wLntQYXJ0eX0gZnJvbSBodHRwczovL21vZGVscy5hY2NvcmRwcm9qZWN0Lm9yZy9hY2NvcmRwcm9qZWN0L3BhcnR5QDAuMi4wLmN0bwoKc2NhbGFyIFVSSSBleHRlbmRzIFN0cmluZwpzY2FsYXIgSlNPTiBleHRlbmRzIFN0cmluZwpzY2FsYXIgRnVsbHlRdWFsaWZpZWRUeXBlTmFtZSBleHRlbmRzIFN0cmluZwoKQGRlc2NyaXB0aW9uKCJCeXRlcyBhbmQgbWltZSB0eXBlIGZvciBhIGJsb2Igb2YgZGF0YSwgc3VjaCBhcyBpbWFnZXMsIGZpbGVzLCBldGMuIikKY29uY2VwdCBCbG9iIHsKICAgIG8gU3RyaW5nIGJhc2U2NAogICAgbyBTdHJpbmcgbWltZVR5cGUgICAvLyBlLmcuICJpbWFnZS9qcGVnIiwgImFwcGxpY2F0aW9uL3BkZiIsICJhdWRpby9tcGVnIiwgInZpZGVvL21wNCIsIGV0Yy4uLgp9CgpAZGVzY3JpcHRpb24oIlRoZSB0ZXh0IGZvciBhIHRlbXBsYXRlIikKY29uY2VwdCBUZXh0IHsKICAgIG8gRG9jdW1lbnQgdGVtcGxhdGVNYXJrIG9wdGlvbmFsIC8vIG11c3QgaW5jbHVkZSBvbmUgb3IgdGhlIG90aGVyLCBub3QgYm90aAogICAgbyBTdHJpbmcgdGVtcGxhdGVUZXh0IG9wdGlvbmFsCn0KCmFic3RyYWN0IGNvbmNlcHQgRG9tYWluTW9kZWwge30KCmNvbmNlcHQgQ3RvRmlsZSB7CiAgICBvIFN0cmluZyBjb250ZW50cwogICAgbyBTdHJpbmcgZmlsZW5hbWUKfQoKY29uY2VwdCBDdG9Nb2RlbCBleHRlbmRzIERvbWFpbk1vZGVsIHsKICAgIG8gQ3RvRmlsZVtdIGN0b0ZpbGVzCn0KCmNvbmNlcHQgSnNvbk1vZGVsIGV4dGVuZHMgRG9tYWluTW9kZWwgewogICAgbyBNb2RlbCBtb2RlbCBvcHRpb25hbCAgICAgICAgICAgICAgICAgIC8vIGEgbW9kZWwsIGFzIGFuIEFTVAp9CgpAZGVzY3JpcHRpb24oIlRoZSBjb25jZXB0IGRlY2xhcmF0aW9uIGFzc29jaWF0ZWQgd2l0aCBhIHRlbXBsYXRlIikKY29uY2VwdCBUZW1wbGF0ZU1vZGVsIHsKICAgIG8gRnVsbHlRdWFsaWZpZWRUeXBlTmFtZSB0eXBlTmFtZSAgICAgICAvLyBuYW1lIG9mIHRoZSB0eXBlIGZvciB0aGlzIHRlbXBsYXRlCiAgICAtLT4gU2hhcmVkTW9kZWwgc2hhcmVkTW9kZWwgb3B0aW9uYWwgICAgLy8gcmVmZXJlbmNlIHRvIGEgc2hhcmVkIG1vZGVsCiAgICBvIERvbWFpbk1vZGVsIG1vZGVsIG9wdGlvbmFsICAgICAgICAgICAgLy8gYW4gaW5saW5lIG1vZGVsCn0KCkBkZXNjcmlwdGlvbigiQSBzaGFyZWQgZGF0YSBtb2RlbCIpCkByZXNvdXJjZQpjb25jZXB0IFNoYXJlZE1vZGVsIGlkZW50aWZpZWQgYnkgdXJpIHsKICAgIG8gVVJJIHVyaQogICAgbyBEb21haW5Nb2RlbCBtb2RlbAp9CgpAZGVzY3JpcHRpb24oIlRoZSB0eXBlIChsYW5ndWFnZSkgb2YgY29kZSIpCmVudW0gQ29kZVR5cGUgewogICAgbyBFUzIwMTUKICAgIG8gV0FTTV9CWVRFUwogICAgbyBUWVBFU0NSSVBUCn0KCkBkZXNjcmlwdGlvbigiQ29kZSBlbmNvZGluZyBzY2hlbWUiKQplbnVtIENvZGVFbmNvZGluZ1R5cGUgewogICAgbyBQTEFJTl9URVhUCiAgICBvIEJBU0U2NAp9CgpAZGVzY3JpcHRpb24oIkV4ZWN1dGFibGUgY29kZSIpCmNvbmNlcHQgQ29kZSBpZGVudGlmaWVkIGJ5IGlkIHsKICAgIG8gU3RyaW5nIGlkCiAgICBvIENvZGVUeXBlIHR5cGUKICAgIG8gQ29kZUVuY29kaW5nVHlwZSBlbmNvZGluZwogICAgbyBTdHJpbmcgdmFsdWUKfQoKQGRlc2NyaXB0aW9uKCJUaGUgZnVuY3Rpb25zIGZvciBhIHRlbXBsYXRlIikKY29uY2VwdCBMb2dpYyB7CiAgICBvIEZ1bGx5UXVhbGlmaWVkVHlwZU5hbWUgc3RhdGVUeXBlIG9wdGlvbmFsCiAgICBvIENvZGVbXSBjb2Rlcwp9Cgpjb25jZXB0IFRlbXBsYXRlTWV0YWRhdGEgewogICAgbyBTdHJpbmcgcnVudGltZQogICAgbyBTdHJpbmcgdGVtcGxhdGUKICAgIG8gU3RyaW5nIGNpY2Vybwp9CgpAcmVzb3VyY2UKQGRlc2NyaXB0aW9uKCJBbiBBY2NvcmQgUHJvamVjdCB0ZW1wbGF0ZSIpCmNvbmNlcHQgVGVtcGxhdGUgaWRlbnRpZmllZCBieSB1cmkgewogICAgbyBVUkkgdXJpCiAgICBvIFN0cmluZyBhdXRob3IKICAgIG8gU3RyaW5nIGRpc3BsYXlOYW1lIG9wdGlvbmFsCiAgICBvIFN0cmluZyB2ZXJzaW9uCiAgICBvIFN0cmluZyBkZXNjcmlwdGlvbiBvcHRpb25hbAogICAgbyBTdHJpbmcgbGljZW5zZQogICAgbyBTdHJpbmdbXSBrZXl3b3JkcyBvcHRpb25hbAogICAgbyBUZW1wbGF0ZU1ldGFkYXRhIG1ldGFkYXRhCiAgICBvIEJsb2IgbG9nbyBvcHRpb25hbAogICAgbyBUZW1wbGF0ZU1vZGVsIHRlbXBsYXRlTW9kZWwKICAgIG8gVGV4dCB0ZXh0CiAgICBvIExvZ2ljIGxvZ2ljIG9wdGlvbmFsCiAgICBvIEpTT04gc2FtcGxlUmVxdWVzdCBvcHRpb25hbAp9CgpAZGVzY3JpcHRpb24oIkEga2V5L3ZhbHVlIHBhaXIiKQpjb25jZXB0IEtleVZhbHVlIHsKICAgIG8gU3RyaW5nIGtleQogICAgbyBTdHJpbmcgdmFsdWUKfQoKQGRlc2NyaXB0aW9uKCJHZW5lcmljIG1ldGFkYXRhIGNvbXByaXNlZCBvZiBrZXkvdmFsdWUgcGFpcnMiKQpjb25jZXB0IE1ldGFkYXRhIHsKICAgIG8gS2V5VmFsdWVbXSB2YWx1ZXMKfQoKQGRlc2NyaXB0aW9uKCJSdW50aW1lIHN0YXR1cyBvZiBhbiBhZ3JlZW1lbnQiKQplbnVtIEFncmVlbWVudFN0YXR1c1R5cGUgewogICAgbyBEUkFGVCAgICAgICAgIC8vIE5vIHNpZ25hdG9yaWVzIGhhdmUgc2lnbmVkIHlldAogICAgbyBTSUdOTkcgICAgICAgIC8vIFNpZ25lZCBieSBzb21lIGJ1dCBub3QgYWxsIHNpZ25hdG9yaWVzCiAgICBvIENPTVBMRVRFRCAgICAgLy8gU2lnbmluZyBieSBhbGwgc2lnbmF0b3JpZXMgY29tcGxldGVkCiAgICBvIFNVUEVSU0VERUQgICAgLy8gU3VwZXJzZWRlZCBieSBzdWJzZXF1ZW50IGFncmVlbWVudAp9CgpAcmVzb3VyY2UKQGRlc2NyaXB0aW9uKCJBbiBBY2NvcmQgUHJvamVjdCBBZ3JlZW1lbnQsIGFuIGluc3RhbmNlIG9mIGEgdGVtcGxhdGUiKQpjb25jZXB0IEFncmVlbWVudCBpZGVudGlmaWVkIGJ5IHVyaSB7CiAgICBvIFVSSSB1cmkKICAgIG8gSlNPTiBkYXRhCiAgICAtLT4gVGVtcGxhdGUgdGVtcGxhdGUgICAgICAgICAgICAgICAgICAgICAgICAgICAgLy8gVGVtcGxhdGUgZm9yIHRoZSB0aGUgYWdyZWVtZW50ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC8vIERhdGEgZm9yIHRoZSBhZ3JlZW1lbnQgKGFuIGluc3RhbmNlIG9mIHRoZSBkYXRhIGZvciBpdHMgdGVtcGxhdGUpCiAgICBvIEpTT04gc3RhdGUgb3B0aW9uYWwgICAgICAgICAgICAgICAgICAgICAgICAgICAgLy8gUnVudGltZSBzdGF0ZSBvZiB0aGUgYWdyZWVtZW50CiAgICBvIEFncmVlbWVudFN0YXR1c1R5cGUgYWdyZWVtZW50U3RhdHVzICAgICAgICAgICAgLy8gQ3VycmVudCBzdGF0dXMgb2YgYWdyZWVtZW50CiAgICBvIEFncmVlbWVudFBhcnR5W10gYWdyZWVtZW50UGFydGllcyBvcHRpb25hbCAgICAgLy8gUGFydGllcyB0byB0aGUgYWdyZWVtZW50CiAgICBvIFNpZ25hdHVyZVtdIHNpZ25hdHVyZXMgb3B0aW9uYWwgICAgICAgICAgICAgICAgLy8gU2lnbmF0dXJlcyBvZiB0aGUgcGFydGllcyB0byB0aGUgYWdyZWVtZW50CiAgICBvIEhpc3RvcnlFbnRyeVtdIGhpc3RvcnlFbnRyaWVzIG9wdGlvbmFsICAgICAgICAgLy8gSGlzdG9yeSBvZiBkb2N1bWVudCBzdGF0ZSBhbmQgZGV0YWlscy4KICAgIG8gQmxvYltdIGF0dGFjaG1lbnRzIG9wdGlvbmFsICAgICAgICAgICAgICAgICAgICAvLyBQZGZzLCBpbWFnZXMsIG11bHRpbWVkaWEsIGV0Yy4gdGhhdCBmb3JtIHBhcnQgb2YgdGhlIGFncmVlbWVudAogICAgbyBTdHJpbmdbXSByZWZlcmVuY2VzIG9wdGlvbmFsICAgICAgICAgICAgICAgICAgIC8vIHVyaS91cmxzIHRvIGV4dGVybmFsIHJlZmVyZW5jZXMgcmVsZXZhbnQgdG8gYWdyZWVtZW50CiAgICBvIE1ldGFkYXRhIG1ldGFkYXRhIG9wdGlvbmFsICAgICAgICAgICAgICAgICAgICAgLy8gQWRkaXRpb25hbCBkYXRhIHRoYXQgbWF5IGJlIHJlbGV2YW50IHRvIHRoZSBhZ3JlZW1lbnQKfQoKQGRlc2NyaXB0aW9uKCJBIFBhcnR5IHRvIGFuIEFncmVlbWVudCIpCnBhcnRpY2lwYW50IEFncmVlbWVudFBhcnR5IGV4dGVuZHMgUGFydHkgewogICAgbyBTdHJpbmcgbmFtZQogICAgbyBCb29sZWFuIHNpZ25hdG9yeSAgICAgICAgICAgICAgICAgICAgIC8vIFNvbWV0aW1lcyBhIHBhcnR5IG5hbWVkIG9uIGFuIGFncmVlbWVudCBhcmUgbm90IHJlcXVpcmVkIHRvIHNpZ24gKGUuZy4gYmVuZWZpY2lhcnkpCiAgICBvIFN0cmluZyByb2xlIG9wdGlvbmFsICAgICAgICAgICAgICAgICAgLy8gUm9sZSBvZiBBZ3JlZW1lbnRQYXJ0eSAoZS5nLiAiT3duZXIiLCAiQ29tcGFueSBEaXJlY3RvciIsIGV0Yy4uKQoJbyBTdHJpbmcgZW1haWwgb3B0aW9uYWwKCW8gU3RyaW5nIHBob25lIG9wdGlvbmFsCiAgICBvIFN0cmluZyBjb21wYW55IG9wdGlvbmFsCiAgICBvIFN0cmluZyBuZXR3b3JrIG9wdGlvbmFsCiAgICBvIEFkZHJlc3MgYWRkcmVzcyBvcHRpb25hbAp9CgpAZGVzY3JpcHRpb24oIkFuIEFkZHJlc3Mgb2YgYW4gQWdyZWVtZW50IFBhcnR5IikKY29uY2VwdCBBZGRyZXNzIHsKICBvIFN0cmluZ1tdIHN0cmVldFJvYWQKICBvIFN0cmluZyBzdWJ1cmJUb3duQ2l0eSBvcHRpb25hbAogIG8gU3RyaW5nIHN0YXRlVGVycml0b3J5UmVnaW9uIG9wdGlvbmFsCiAgbyBTdHJpbmcgcG9zdGFsQ29kZSBvcHRpb25hbAogIG8gU3RyaW5nIGNvdW50cnkgb3B0aW9uYWwKfQoKQGRlc2NyaXB0aW9uKCJBIGhpc3RvcnkgZW50cnkgZm9yIGFuIEFncmVlbWVudCIpCnRyYW5zYWN0aW9uIEhpc3RvcnlFbnRyeSB7CiAgICBvIEFncmVlbWVudFN0YXR1c1R5cGUgYWdyZWVtZW50U3RhdHVzICAgLy8gU3RhdHVzIGF0IHRpbWUgb2YgY2hhbmdlCiAgICBvIEpTT04gZGF0YSAgICAgICAgICAgICAgICAgICAgICAgICAgICAgLy8gRGF0YSBhdCB0aW1lIG9mIGNoYW5nZQogICAgbyBNZXRhZGF0YSBtZXRhZGF0YSAgICAgICAgICAgICAgICAgICAgIC8vIEFkZGl0aW9uYWwgZGF0YSB0aGF0IG1heSBiZSByZWxldmFudCB0byB0aGUgYWdyZWVtZW50IGF0IHRpbWUgb2YgY2hhbmdlCn0KCkBkZXNjcmlwdGlvbigiQSBzaWduYXR1cmUgb2YgYW4gQWdyZWVtZW50IFBhcnR5IikKY29uY2VwdCBTaWduYXR1cmUgewogICAgbyBBZ3JlZW1lbnRQYXJ0eSBzaWduYXRvcnkgICAgICAgICAgICAgIC8vIFRoZSBBZ3JlZW1lbnQgUGFydHkgc2lnbmluZyB0aGUgQWdyZWVtZW50CiAgICBvIERhdGVUaW1lIHNpZ25lZEF0IG9wdGlvbmFsICAgICAgICAgICAgLy8gV2hlbiB0aGUgc2lnbmluZyBvY2N1cnJlZAogICAgbyBNZXRhZGF0YSBtZXRhZGF0YSAgICAgICAgICAgICAgICAgICAgIC8vIEdlb2xvY2F0aW9uIGRhdGEsIElQIGFkZHJlc3MsIGV0Yy4KICAgIG8gQmxvYltdIHNpZ25hdHVyZUltYWdlICAgICAgICAgICAgICAgICAvLyBTZWxmaWUsIHByb29mLW9mLWlkLCBzaWduLW9uLWdsYXNzIGltYWdlCn0KCkBkZXNjcmlwdGlvbigiQWJzdHJhY3QgY29udmVyc2lvbiBvcHRpb25zIikKYWJzdHJhY3QgY29uY2VwdCBDb252ZXJzaW9uT3B0aW9ucyB7Cn0KCkBkZXNjcmlwdGlvbigiSFRNTCBjb252ZXJzaW9uIG9wdGlvbnMiKQpjb25jZXB0IEh0bWxDb252ZXJzaW9uT3B0aW9ucyBleHRlbmRzIENvbnZlcnNpb25PcHRpb25zIHsKfQoKQGRlc2NyaXB0aW9uKCJTZXJ2ZXIgZmVhdHVyZSBpZGVudGlmaWVycyIpCmVudW0gRmVhdHVyZVR5cGUgewogICAgbyBURU1QTEFURV9NQU5BR0UgICAgICAgICAgICAgLy8gY3J1ZCBvcGVyYXRpb25zIG9uIHRlbXBsYXRlcwoKICAgIG8gVEVNUExBVEVfVkVSSUZZX1NJR05BVFVSRVMgICAvLyB2ZXJpZnkgYSBzaWduZWQgdGVtcGxhdGUKICAgIG8gVEVNUExBVEVfTE9HSUMgICAgICAgICAgICAgICAvLyB0ZW1wbGF0ZXMgd2l0aCBsb2dpYwogICAgbyBURU1QTEFURV9TVEFURUZVTCAgICAgICAgICAgIC8vIHN0YXRlZnVsIHRlbXBsYXRlcwoKICAgIC8vIHdoaWNoIGxvZ2ljIGxhbmd1YWdlcyBhcmUgc3VwcG9ydGVkCiAgICBvIExPR0lDX1dBU00KICAgIG8gTE9HSUNfRVMyMDE1CiAgICBvIExPR0lDX1RZUEVTQ1JJUFQKCiAgICBvIEFHUkVFTUVOVF9NQU5BR0UgICAgICAgICAgICAgLy8gY3J1ZCBvcGVyYXRpb25zIG9uIGFncmVlbWVudHMKICAgIG8gQUdSRUVNRU5UX1RSSUdHRVIgICAgICAgICAgICAvLyB0cmlnZ2VyIGFncmVlbWVudHMKICAgIG8gQUdSRUVNRU5UX1NUQVRFICAgICAgICAgICAgICAvLyBnZXQgdGhlIHN0YXRlIG9mIGFuIGFncmVlbWVudAogICAgbyBBR1JFRU1FTlRfQ09OVkVSVF9IVE1MICAgICAgIC8vIGRyYWZ0IGFuIGFncmVlbWVudCB0byBhbiBvdXRwdXQgZm9ybWF0CgogICAgLy8gc2VuZCBhbiBhZ3JlZW1lbnQgZm9yIHNpZ25hdHVyZQogICAgbyBBR1JFRU1FTlRfU0lHTklORwoKICAgIC8vIGNydWQgb3BlcmF0aW9ucyBvdmVyIHNoYXJlZCBtb2RlbHMKICAgIG8gU0hBUkVEX01PREVMX01BTkFHRSAgICAgICAgICAvLyBtYW5hZ2Ugc2hhcmVkIG1vZGVscwp9CgpAZGVzY3JpcHRpb24oIlNlcnZlciBjYXBhYmlsaXRpZXMiKQpjb25jZXB0IENhcGFiaWxpdGllcyB7CiAgICBvIEZlYXR1cmVUeXBlW10gZmVhdHVyZXMKfQoKQGRlc2NyaXB0aW9uKCJUcmlnZ2VyIHdpdGggYSBKU09OIHBheWxvYWQiKQpjb25jZXB0IFRyaWdnZXJSZXF1ZXN0IHsKICAgIG8gSlNPTiBwYXlsb2FkCn0KCkBkZXNjcmlwdGlvbigiUmVzcG9uc2Ugb2YgdHJpZ2dlcmluZyBhIGZ1bmN0aW9uIikKY29uY2VwdCBUcmlnZ2VyUmVzcG9uc2UgewogICAgbyBKU09OIHJlc3VsdCBvcHRpb25hbAogICAgbyBCb29sZWFuIGlzRXJyb3IKICAgIG8gU3RyaW5nIGVycm9yTWVzc2FnZSBvcHRpb25hbAogICAgbyBTdHJpbmcgZXJyb3JEZXRhaWxzIG9wdGlvbmFsCn0=" +export const MODEL="QGRlc2NyaXB0aW9uKCJBY2NvcmQgUHJvamVjdCBBZ3JlZW1lbnQgUHJvdG9jb2wiKQpuYW1lc3BhY2Ugb3JnLmFjY29yZHByb2plY3QucHJvdG9jb2xAMS4wLjAKCmltcG9ydCBjb25jZXJ0by5tZXRhbW9kZWxAMC40LjAue1Byb3BlcnR5LENvbmNlcHREZWNsYXJhdGlvbixNb2RlbH0gZnJvbSBodHRwczovL21vZGVscy5hY2NvcmRwcm9qZWN0Lm9yZy9jb25jZXJ0by9tZXRhbW9kZWxAMC40LjAuY3RvCmltcG9ydCBvcmcuYWNjb3JkcHJvamVjdC5jb21tb25tYXJrQDAuNS4wLkRvY3VtZW50IGZyb20gaHR0cHM6Ly9tb2RlbHMuYWNjb3JkcHJvamVjdC5vcmcvbWFya2Rvd24vY29tbW9ubWFya0AwLjUuMC5jdG8KaW1wb3J0IG9yZy5hY2NvcmRwcm9qZWN0LnBhcnR5QDAuMi4wLntQYXJ0eX0gZnJvbSBodHRwczovL21vZGVscy5hY2NvcmRwcm9qZWN0Lm9yZy9hY2NvcmRwcm9qZWN0L3BhcnR5QDAuMi4wLmN0bwoKc2NhbGFyIFVSSSBleHRlbmRzIFN0cmluZwpzY2FsYXIgSlNPTiBleHRlbmRzIFN0cmluZwpzY2FsYXIgRnVsbHlRdWFsaWZpZWRUeXBlTmFtZSBleHRlbmRzIFN0cmluZwoKQGRlc2NyaXB0aW9uKCJCeXRlcyBhbmQgbWltZSB0eXBlIGZvciBhIGJsb2Igb2YgZGF0YSwgc3VjaCBhcyBpbWFnZXMsIGZpbGVzLCBldGMuIikKY29uY2VwdCBCbG9iIHsKICAgIG8gU3RyaW5nIGJhc2U2NAogICAgbyBTdHJpbmcgbWltZVR5cGUgICAvLyBlLmcuICJpbWFnZS9qcGVnIiwgImFwcGxpY2F0aW9uL3BkZiIsICJhdWRpby9tcGVnIiwgInZpZGVvL21wNCIsIGV0Yy4uLgp9CgpAZGVzY3JpcHRpb24oIlRoZSB0ZXh0IGZvciBhIHRlbXBsYXRlIikKY29uY2VwdCBUZXh0IHsKICAgIG8gRG9jdW1lbnQgdGVtcGxhdGVNYXJrIG9wdGlvbmFsIC8vIG11c3QgaW5jbHVkZSBvbmUgb3IgdGhlIG90aGVyLCBub3QgYm90aAogICAgbyBTdHJpbmcgdGVtcGxhdGVUZXh0IG9wdGlvbmFsCn0KCmFic3RyYWN0IGNvbmNlcHQgRG9tYWluTW9kZWwge30KCmNvbmNlcHQgQ3RvRmlsZSB7CiAgICBvIFN0cmluZyBjb250ZW50cwogICAgbyBTdHJpbmcgZmlsZW5hbWUKfQoKY29uY2VwdCBDdG9Nb2RlbCBleHRlbmRzIERvbWFpbk1vZGVsIHsKICAgIG8gQ3RvRmlsZVtdIGN0b0ZpbGVzCn0KCmNvbmNlcHQgSnNvbk1vZGVsIGV4dGVuZHMgRG9tYWluTW9kZWwgewogICAgbyBNb2RlbCBtb2RlbCBvcHRpb25hbCAgICAgICAgICAgICAgICAgIC8vIGEgbW9kZWwsIGFzIGFuIEFTVAp9CgpAZGVzY3JpcHRpb24oIlRoZSBjb25jZXB0IGRlY2xhcmF0aW9uIGFzc29jaWF0ZWQgd2l0aCBhIHRlbXBsYXRlIikKY29uY2VwdCBUZW1wbGF0ZU1vZGVsIHsKICAgIG8gRnVsbHlRdWFsaWZpZWRUeXBlTmFtZSB0eXBlTmFtZSAgICAgICAvLyBuYW1lIG9mIHRoZSB0eXBlIGZvciB0aGlzIHRlbXBsYXRlCiAgICAtLT4gU2hhcmVkTW9kZWwgc2hhcmVkTW9kZWwgb3B0aW9uYWwgICAgLy8gcmVmZXJlbmNlIHRvIGEgc2hhcmVkIG1vZGVsCiAgICBvIERvbWFpbk1vZGVsIG1vZGVsIG9wdGlvbmFsICAgICAgICAgICAgLy8gYW4gaW5saW5lIG1vZGVsCn0KCkBkZXNjcmlwdGlvbigiQSBzaGFyZWQgZGF0YSBtb2RlbCIpCkByZXNvdXJjZQpjb25jZXB0IFNoYXJlZE1vZGVsIGlkZW50aWZpZWQgYnkgdXJpIHsKICAgIG8gVVJJIHVyaQogICAgbyBEb21haW5Nb2RlbCBtb2RlbAp9CgpAZGVzY3JpcHRpb24oIlRoZSB0eXBlIChsYW5ndWFnZSkgb2YgY29kZSIpCmVudW0gQ29kZVR5cGUgewogICAgbyBFUzIwMTUKICAgIG8gV0FTTV9CWVRFUwogICAgbyBUWVBFU0NSSVBUCn0KCkBkZXNjcmlwdGlvbigiQ29kZSBlbmNvZGluZyBzY2hlbWUiKQplbnVtIENvZGVFbmNvZGluZ1R5cGUgewogICAgbyBQTEFJTl9URVhUCiAgICBvIEJBU0U2NAp9CgpAZGVzY3JpcHRpb24oIkV4ZWN1dGFibGUgY29kZSIpCmNvbmNlcHQgQ29kZSBpZGVudGlmaWVkIGJ5IGlkIHsKICAgIG8gU3RyaW5nIGlkCiAgICBvIENvZGVUeXBlIHR5cGUKICAgIG8gQ29kZUVuY29kaW5nVHlwZSBlbmNvZGluZwogICAgbyBTdHJpbmcgdmFsdWUKfQoKQGRlc2NyaXB0aW9uKCJUaGUgZnVuY3Rpb25zIGZvciBhIHRlbXBsYXRlIikKY29uY2VwdCBMb2dpYyB7CiAgICBvIEZ1bGx5UXVhbGlmaWVkVHlwZU5hbWUgc3RhdGVUeXBlIG9wdGlvbmFsCiAgICBvIENvZGVbXSBjb2Rlcwp9Cgpjb25jZXB0IFRlbXBsYXRlTWV0YWRhdGEgewogICAgbyBTdHJpbmcgcnVudGltZQogICAgbyBTdHJpbmcgdGVtcGxhdGUKICAgIG8gU3RyaW5nIGNpY2Vybwp9CgpAcmVzb3VyY2UKQGRlc2NyaXB0aW9uKCJBbiBBY2NvcmQgUHJvamVjdCB0ZW1wbGF0ZSIpCmNvbmNlcHQgVGVtcGxhdGUgaWRlbnRpZmllZCBieSB1cmkgewogICAgbyBVUkkgdXJpCiAgICBvIFN0cmluZyBoYXNoIG9wdGlvbmFsCiAgICBvIFN0cmluZyBhdXRob3IKICAgIG8gU3RyaW5nIGRpc3BsYXlOYW1lIG9wdGlvbmFsCiAgICBvIFN0cmluZyB2ZXJzaW9uCiAgICBvIFN0cmluZyBkZXNjcmlwdGlvbiBvcHRpb25hbAogICAgbyBTdHJpbmcgbGljZW5zZQogICAgbyBTdHJpbmdbXSBrZXl3b3JkcyBvcHRpb25hbAogICAgbyBUZW1wbGF0ZU1ldGFkYXRhIG1ldGFkYXRhCiAgICBvIEJsb2IgbG9nbyBvcHRpb25hbAogICAgbyBUZW1wbGF0ZU1vZGVsIHRlbXBsYXRlTW9kZWwKICAgIG8gVGV4dCB0ZXh0CiAgICBvIExvZ2ljIGxvZ2ljIG9wdGlvbmFsCiAgICBvIEpTT04gc2FtcGxlUmVxdWVzdCBvcHRpb25hbAp9CgpAZGVzY3JpcHRpb24oIkEga2V5L3ZhbHVlIHBhaXIiKQpjb25jZXB0IEtleVZhbHVlIHsKICAgIG8gU3RyaW5nIGtleQogICAgbyBTdHJpbmcgdmFsdWUKfQoKQGRlc2NyaXB0aW9uKCJHZW5lcmljIG1ldGFkYXRhIGNvbXByaXNlZCBvZiBrZXkvdmFsdWUgcGFpcnMiKQpjb25jZXB0IE1ldGFkYXRhIHsKICAgIG8gS2V5VmFsdWVbXSB2YWx1ZXMKfQoKQGRlc2NyaXB0aW9uKCJSdW50aW1lIHN0YXR1cyBvZiBhbiBhZ3JlZW1lbnQiKQplbnVtIEFncmVlbWVudFN0YXR1c1R5cGUgewogICAgbyBEUkFGVCAgICAgICAgIC8vIE5vIHNpZ25hdG9yaWVzIGhhdmUgc2lnbmVkIHlldAogICAgbyBTSUdOSU5HICAgICAgIC8vIFNpZ25lZCBieSBzb21lIGJ1dCBub3QgYWxsIHNpZ25hdG9yaWVzCiAgICBvIENPTVBMRVRFRCAgICAgLy8gU2lnbmluZyBieSBhbGwgc2lnbmF0b3JpZXMgY29tcGxldGVkCiAgICBvIFNVUEVSU0VERUQgICAgLy8gU3VwZXJzZWRlZCBieSBzdWJzZXF1ZW50IGFncmVlbWVudAp9CgpAcmVzb3VyY2UKQGRlc2NyaXB0aW9uKCJBbiBBY2NvcmQgUHJvamVjdCBBZ3JlZW1lbnQsIGFuIGluc3RhbmNlIG9mIGEgdGVtcGxhdGUiKQpjb25jZXB0IEFncmVlbWVudCBpZGVudGlmaWVkIGJ5IHVyaSB7CiAgICBvIFVSSSB1cmkKICAgIG8gSlNPTiBkYXRhCiAgICBvIFN0cmluZyB0ZW1wbGF0ZUhhc2ggb3B0aW9uYWwgICAgICAgICAgICAgICAgICAgLy8gUmVzb2x2ZWQgaGFzaCBvZiB0aGUgdGVtcGxhdGUgYXQgdGhlIHRpbWUgdGhlIGFncmVlbWVudCB3YXMgY3JlYXRlZAogICAgLS0+IFRlbXBsYXRlIHRlbXBsYXRlICAgICAgICAgICAgICAgICAgICAgICAgICAgIC8vIFRlbXBsYXRlIGZvciB0aGUgdGhlIGFncmVlbWVudCAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAvLyBEYXRhIGZvciB0aGUgYWdyZWVtZW50IChhbiBpbnN0YW5jZSBvZiB0aGUgZGF0YSBmb3IgaXRzIHRlbXBsYXRlKQogICAgbyBKU09OIHN0YXRlIG9wdGlvbmFsICAgICAgICAgICAgICAgICAgICAgICAgICAgIC8vIFJ1bnRpbWUgc3RhdGUgb2YgdGhlIGFncmVlbWVudAogICAgbyBBZ3JlZW1lbnRTdGF0dXNUeXBlIGFncmVlbWVudFN0YXR1cyAgICAgICAgICAgIC8vIEN1cnJlbnQgc3RhdHVzIG9mIGFncmVlbWVudAogICAgbyBBZ3JlZW1lbnRQYXJ0eVtdIGFncmVlbWVudFBhcnRpZXMgb3B0aW9uYWwgICAgIC8vIFBhcnRpZXMgdG8gdGhlIGFncmVlbWVudAogICAgbyBTaWduYXR1cmVbXSBzaWduYXR1cmVzIG9wdGlvbmFsICAgICAgICAgICAgICAgIC8vIFNpZ25hdHVyZXMgb2YgdGhlIHBhcnRpZXMgdG8gdGhlIGFncmVlbWVudAogICAgbyBIaXN0b3J5RW50cnlbXSBoaXN0b3J5RW50cmllcyBvcHRpb25hbCAgICAgICAgIC8vIEhpc3Rvcnkgb2YgZG9jdW1lbnQgc3RhdGUgYW5kIGRldGFpbHMuCiAgICBvIEJsb2JbXSBhdHRhY2htZW50cyBvcHRpb25hbCAgICAgICAgICAgICAgICAgICAgLy8gUGRmcywgaW1hZ2VzLCBtdWx0aW1lZGlhLCBldGMuIHRoYXQgZm9ybSBwYXJ0IG9mIHRoZSBhZ3JlZW1lbnQKICAgIG8gU3RyaW5nW10gcmVmZXJlbmNlcyBvcHRpb25hbCAgICAgICAgICAgICAgICAgICAvLyB1cmkvdXJscyB0byBleHRlcm5hbCByZWZlcmVuY2VzIHJlbGV2YW50IHRvIGFncmVlbWVudAogICAgbyBNZXRhZGF0YSBtZXRhZGF0YSBvcHRpb25hbCAgICAgICAgICAgICAgICAgICAgIC8vIEFkZGl0aW9uYWwgZGF0YSB0aGF0IG1heSBiZSByZWxldmFudCB0byB0aGUgYWdyZWVtZW50Cn0KCkBkZXNjcmlwdGlvbigiQSBQYXJ0eSB0byBhbiBBZ3JlZW1lbnQiKQpwYXJ0aWNpcGFudCBBZ3JlZW1lbnRQYXJ0eSBleHRlbmRzIFBhcnR5IHsKICAgIG8gU3RyaW5nIG5hbWUKICAgIG8gQm9vbGVhbiBzaWduYXRvcnkgICAgICAgICAgICAgICAgICAgICAvLyBTb21ldGltZXMgYSBwYXJ0eSBuYW1lZCBvbiBhbiBhZ3JlZW1lbnQgYXJlIG5vdCByZXF1aXJlZCB0byBzaWduIChlLmcuIGJlbmVmaWNpYXJ5KQogICAgbyBTdHJpbmcgcm9sZSBvcHRpb25hbCAgICAgICAgICAgICAgICAgIC8vIFJvbGUgb2YgQWdyZWVtZW50UGFydHkgKGUuZy4gIk93bmVyIiwgIkNvbXBhbnkgRGlyZWN0b3IiLCBldGMuLikKCW8gU3RyaW5nIGVtYWlsIG9wdGlvbmFsCglvIFN0cmluZyBwaG9uZSBvcHRpb25hbAogICAgbyBTdHJpbmcgY29tcGFueSBvcHRpb25hbAogICAgbyBTdHJpbmcgbmV0d29yayBvcHRpb25hbAogICAgbyBBZGRyZXNzIGFkZHJlc3Mgb3B0aW9uYWwKfQoKQGRlc2NyaXB0aW9uKCJBbiBBZGRyZXNzIG9mIGFuIEFncmVlbWVudCBQYXJ0eSIpCmNvbmNlcHQgQWRkcmVzcyB7CiAgbyBTdHJpbmdbXSBzdHJlZXRSb2FkCiAgbyBTdHJpbmcgc3VidXJiVG93bkNpdHkgb3B0aW9uYWwKICBvIFN0cmluZyBzdGF0ZVRlcnJpdG9yeVJlZ2lvbiBvcHRpb25hbAogIG8gU3RyaW5nIHBvc3RhbENvZGUgb3B0aW9uYWwKICBvIFN0cmluZyBjb3VudHJ5IG9wdGlvbmFsCn0KCkBkZXNjcmlwdGlvbigiQSBoaXN0b3J5IGVudHJ5IGZvciBhbiBBZ3JlZW1lbnQiKQp0cmFuc2FjdGlvbiBIaXN0b3J5RW50cnkgewogICAgbyBBZ3JlZW1lbnRTdGF0dXNUeXBlIGFncmVlbWVudFN0YXR1cyAgIC8vIFN0YXR1cyBhdCB0aW1lIG9mIGNoYW5nZQogICAgbyBKU09OIGRhdGEgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC8vIERhdGEgYXQgdGltZSBvZiBjaGFuZ2UKICAgIG8gTWV0YWRhdGEgbWV0YWRhdGEgICAgICAgICAgICAgICAgICAgICAvLyBBZGRpdGlvbmFsIGRhdGEgdGhhdCBtYXkgYmUgcmVsZXZhbnQgdG8gdGhlIGFncmVlbWVudCBhdCB0aW1lIG9mIGNoYW5nZQp9CgpAZGVzY3JpcHRpb24oIkEgc2lnbmF0dXJlIG9mIGFuIEFncmVlbWVudCBQYXJ0eSIpCmNvbmNlcHQgU2lnbmF0dXJlIHsKICAgIG8gQWdyZWVtZW50UGFydHkgc2lnbmF0b3J5ICAgICAgICAgICAgICAvLyBUaGUgQWdyZWVtZW50IFBhcnR5IHNpZ25pbmcgdGhlIEFncmVlbWVudAogICAgbyBEYXRlVGltZSBzaWduZWRBdCBvcHRpb25hbCAgICAgICAgICAgIC8vIFdoZW4gdGhlIHNpZ25pbmcgb2NjdXJyZWQKICAgIG8gTWV0YWRhdGEgbWV0YWRhdGEgICAgICAgICAgICAgICAgICAgICAvLyBHZW9sb2NhdGlvbiBkYXRhLCBJUCBhZGRyZXNzLCBldGMuCiAgICBvIEJsb2JbXSBzaWduYXR1cmVJbWFnZSBvcHRpb25hbCAgICAgICAgLy8gU2VsZmllLCBwcm9vZi1vZi1pZCwgc2lnbi1vbi1nbGFzcyBpbWFnZQogICAgbyBKU09OIHZlcmlmaWFibGVDcmVkZW50aWFsIG9wdGlvbmFsICAgIC8vIFczQyBWZXJpZmlhYmxlIENyZWRlbnRpYWwgY2FycnlpbmcgdGhlIGNyeXB0b2dyYXBoaWMgcHJvb2YKfQoKQGRlc2NyaXB0aW9uKCJBYnN0cmFjdCBjb252ZXJzaW9uIG9wdGlvbnMiKQphYnN0cmFjdCBjb25jZXB0IENvbnZlcnNpb25PcHRpb25zIHsKfQoKQGRlc2NyaXB0aW9uKCJIVE1MIGNvbnZlcnNpb24gb3B0aW9ucyIpCmNvbmNlcHQgSHRtbENvbnZlcnNpb25PcHRpb25zIGV4dGVuZHMgQ29udmVyc2lvbk9wdGlvbnMgewp9CgpAZGVzY3JpcHRpb24oIlNlcnZlciBmZWF0dXJlIGlkZW50aWZpZXJzIikKZW51bSBGZWF0dXJlVHlwZSB7CiAgICBvIFRFTVBMQVRFX01BTkFHRSAgICAgICAgICAgICAvLyBjcnVkIG9wZXJhdGlvbnMgb24gdGVtcGxhdGVzCgogICAgbyBURU1QTEFURV9WRVJJRllfU0lHTkFUVVJFUyAgIC8vIHZlcmlmeSBhIHNpZ25lZCB0ZW1wbGF0ZQogICAgbyBURU1QTEFURV9MT0dJQyAgICAgICAgICAgICAgIC8vIHRlbXBsYXRlcyB3aXRoIGxvZ2ljCiAgICBvIFRFTVBMQVRFX1NUQVRFRlVMICAgICAgICAgICAgLy8gc3RhdGVmdWwgdGVtcGxhdGVzCgogICAgLy8gd2hpY2ggbG9naWMgbGFuZ3VhZ2VzIGFyZSBzdXBwb3J0ZWQKICAgIG8gTE9HSUNfV0FTTQogICAgbyBMT0dJQ19FUzIwMTUKICAgIG8gTE9HSUNfVFlQRVNDUklQVAoKICAgIG8gQUdSRUVNRU5UX01BTkFHRSAgICAgICAgICAgICAvLyBjcnVkIG9wZXJhdGlvbnMgb24gYWdyZWVtZW50cwogICAgbyBBR1JFRU1FTlRfVFJJR0dFUiAgICAgICAgICAgIC8vIHRyaWdnZXIgYWdyZWVtZW50cwogICAgbyBBR1JFRU1FTlRfU1RBVEUgICAgICAgICAgICAgIC8vIGdldCB0aGUgc3RhdGUgb2YgYW4gYWdyZWVtZW50CiAgICBvIEFHUkVFTUVOVF9DT05WRVJUX0hUTUwgICAgICAgLy8gZHJhZnQgYW4gYWdyZWVtZW50IHRvIGFuIG91dHB1dCBmb3JtYXQKCiAgICAvLyBzZW5kIGFuIGFncmVlbWVudCBmb3Igc2lnbmF0dXJlCiAgICBvIEFHUkVFTUVOVF9TSUdOSU5HCgogICAgLy8gY3J1ZCBvcGVyYXRpb25zIG92ZXIgc2hhcmVkIG1vZGVscwogICAgbyBTSEFSRURfTU9ERUxfTUFOQUdFICAgICAgICAgIC8vIG1hbmFnZSBzaGFyZWQgbW9kZWxzCn0KCkBkZXNjcmlwdGlvbigiU2VydmVyIGNhcGFiaWxpdGllcyIpCmNvbmNlcHQgQ2FwYWJpbGl0aWVzIHsKICAgIG8gRmVhdHVyZVR5cGVbXSBmZWF0dXJlcwp9CgpAZGVzY3JpcHRpb24oIlRyaWdnZXIgd2l0aCBhIEpTT04gcGF5bG9hZCIpCmNvbmNlcHQgVHJpZ2dlclJlcXVlc3QgewogICAgbyBKU09OIHBheWxvYWQKfQoKQGRlc2NyaXB0aW9uKCJSZXNwb25zZSBvZiB0cmlnZ2VyaW5nIGEgZnVuY3Rpb24iKQpjb25jZXB0IFRyaWdnZXJSZXNwb25zZSB7CiAgICBvIEpTT04gcmVzdWx0IG9wdGlvbmFsCiAgICBvIEJvb2xlYW4gaXNFcnJvcgogICAgbyBTdHJpbmcgZXJyb3JNZXNzYWdlIG9wdGlvbmFsCiAgICBvIFN0cmluZyBlcnJvckRldGFpbHMgb3B0aW9uYWwKfQ==" diff --git a/server/drizzle/0002_rename_signing_enum.sql b/server/drizzle/0002_rename_signing_enum.sql new file mode 100644 index 0000000..a2dd805 --- /dev/null +++ b/server/drizzle/0002_rename_signing_enum.sql @@ -0,0 +1,4 @@ +-- Rename the misspelled enum value SIGNNG -> SIGNING in AgreementStatusType. +-- RENAME VALUE is atomic and auto-updates every row that holds the old value +-- (Postgres >= 10), so no separate UPDATE is needed. +ALTER TYPE "public"."AgreementStatusType" RENAME VALUE 'SIGNNG' TO 'SIGNING'; diff --git a/server/drizzle/meta/0002_snapshot.json b/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..afaa9be --- /dev/null +++ b/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,323 @@ +{ + "id": "c9fb89b7-a21f-43a6-b5ef-6255cc442569", + "prevId": "bac484a3-fa45-45ea-a9ae-222276cc52bd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Agreement": { + "name": "Agreement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "template_hash": { + "name": "template_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "agreement_status": { + "name": "agreement_status", + "type": "AgreementStatusType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "agreement_parties": { + "name": "agreement_parties", + "type": "json[]", + "primaryKey": false, + "notNull": false + }, + "signatures": { + "name": "signatures", + "type": "json[]", + "primaryKey": false, + "notNull": false + }, + "history_entries": { + "name": "history_entries", + "type": "json[]", + "primaryKey": false, + "notNull": false + }, + "attachments": { + "name": "attachments", + "type": "json[]", + "primaryKey": false, + "notNull": false + }, + "references": { + "name": "references", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Agreement_uri_unique": { + "name": "Agreement_uri_unique", + "nullsNotDistinct": false, + "columns": [ + "uri" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.SharedModel": { + "name": "SharedModel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "json", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "SharedModel_uri_unique": { + "name": "SharedModel_uri_unique", + "nullsNotDistinct": false, + "columns": [ + "uri" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Template": { + "name": "Template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "keywords": { + "name": "keywords", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "template_model": { + "name": "template_model", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "logic": { + "name": "logic", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "sample_request": { + "name": "sample_request", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Template_uri_unique": { + "name": "Template_uri_unique", + "nullsNotDistinct": false, + "columns": [ + "uri" + ] + }, + "Template_hash_unique": { + "name": "Template_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.AgreementStatusType": { + "name": "AgreementStatusType", + "schema": "public", + "values": [ + "DRAFT", + "SIGNING", + "COMPLETED", + "SUPERSEDED" + ] + }, + "public.CodeEncodingType": { + "name": "CodeEncodingType", + "schema": "public", + "values": [ + "PLAIN_TEXT", + "BASE64" + ] + }, + "public.CodeType": { + "name": "CodeType", + "schema": "public", + "values": [ + "ES2015", + "WASM_BYTES", + "TYPESCRIPT" + ] + }, + "public.FeatureType": { + "name": "FeatureType", + "schema": "public", + "values": [ + "TEMPLATE_MANAGE", + "TEMPLATE_VERIFY_SIGNATURES", + "TEMPLATE_LOGIC", + "TEMPLATE_STATEFUL", + "LOGIC_WASM", + "LOGIC_ES2015", + "LOGIC_TYPESCRIPT", + "AGREEMENT_MANAGE", + "AGREEMENT_TRIGGER", + "AGREEMENT_STATE", + "AGREEMENT_CONVERT_HTML", + "AGREEMENT_SIGNING", + "SHARED_MODEL_MANAGE" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 9a162da..6be9688 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1771737227233, "tag": "0001_simple_clint_barton", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1779996510694, + "tag": "0002_rename_signing_enum", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/handlers/agreements.ts b/server/handlers/agreements.ts index b11f8e2..a159605 100644 --- a/server/handlers/agreements.ts +++ b/server/handlers/agreements.ts @@ -7,6 +7,14 @@ import { eq } from 'drizzle-orm'; import { TemplateArchiveProcessor } from '@accordproject/template-engine'; import { HttpTemplateRetriever } from './retrievers/HttpTemplateRetriever'; import { Template as CiceroTemplate } from '@accordproject/cicero-core'; +import { + AgreementLite, + nextAgreementStatus, + SignatureRecord, + validateSignatureCredential, + VerifiableCredential, + verifyAllSignatures, +} from './signatures'; async function resolveAgreement(db: any, agreementId: string) { console.log('Getting agreement: ' + agreementId); @@ -177,5 +185,80 @@ crudRouter.post('/:id/trigger', async function (req, res) { } }); +async function loadAgreementRow(db: any, agreementId: string) { + const rows = await db.select().from(Agreement).where(eq(Agreement.id, Number.parseInt(agreementId))).limit(1); + if (!rows.length) throw new Error(`Agreement with id ${agreementId} does not exist`); + return rows[0]; +} + +crudRouter.post('/:id/sign', async function (req, res) { + try { + const agreement = await loadAgreementRow(res.locals.db, req.params.id); + const vc = req.body as VerifiableCredential; + + const lite: AgreementLite = { + uri: agreement.uri, + data: agreement.data, + templateHash: agreement.templateHash ?? null, + agreementParties: agreement.agreementParties ?? [], + signatures: agreement.signatures ?? [], + }; + + const failure = await validateSignatureCredential(vc, lite); + if (failure) { + return res.status(400).json({ error: failure.code, message: failure.message }); + } + + const subject = vc.credentialSubject!; + const partyId = subject.signatory!.partyId!; + const party = lite.agreementParties!.find((p: any) => p.partyId === partyId || p.id === partyId)!; + + const newSig: SignatureRecord = { + signatory: party, + signedAt: subject.signedAt ?? new Date().toISOString(), + metadata: req.body.metadata ?? { values: [] }, + verifiableCredential: vc, + }; + + const updatedSignatures = [...(agreement.signatures ?? []), newSig]; + const updatedAgreement = { + ...agreement, + signatures: updatedSignatures, + agreementStatus: nextAgreementStatus({ ...lite, signatures: updatedSignatures }), + historyEntries: [ + ...(agreement.historyEntries ?? []), + { + $class: 'org.accordproject.protocol@1.0.0.HistoryEntry', + agreementStatus: nextAgreementStatus({ ...lite, signatures: updatedSignatures }), + data: agreement.data, + metadata: { values: [{ key: 'event', value: `signed_by:${partyId}` }] }, + }, + ], + }; + + await res.locals.db.update(Agreement).set(updatedAgreement).where(eq(Agreement.id, Number.parseInt(agreement.id))); + res.json(updatedAgreement); + } catch (err: any) { + res.status(500).json({ error: 'Signing failed', details: err.message }); + } +}); + +crudRouter.post('/:id/verify-signatures', async function (req, res) { + try { + const agreement = await loadAgreementRow(res.locals.db, req.params.id); + const lite: AgreementLite = { + uri: agreement.uri, + data: agreement.data, + templateHash: agreement.templateHash ?? null, + agreementParties: agreement.agreementParties ?? [], + signatures: agreement.signatures ?? [], + }; + const result = await verifyAllSignatures(lite); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: 'Verification failed', details: err.message }); + } +}); + router.use('/', crudRouter); export default router; \ No newline at end of file diff --git a/server/handlers/signatures.test.ts b/server/handlers/signatures.test.ts new file mode 100644 index 0000000..efcd67a --- /dev/null +++ b/server/handlers/signatures.test.ts @@ -0,0 +1,368 @@ +import { jest } from '@jest/globals'; +import crypto from 'crypto'; +import request from 'supertest'; +import express from 'express'; + +import { hashAgreementData, nextAgreementStatus, validateSignatureCredential, verifyAllSignatures } from './signatures'; +import * as vcSigner from './vcSigner'; +import agreementsRouter from './agreements'; +import { Agreement } from '../db/schema'; + +jest.mock('../db/schema'); +jest.setTimeout(30000); + +/** + * Mints an encrypted Ed25519 PEM, loads it via vc-signer, derives a did:key, + * and returns everything a test needs to issue a signed VC. + */ +async function newSigner(): Promise<{ keyInfo: any; did: string; verificationMethodId: string }> { + const passphrase = 'test-passphrase'; + // @types/node 10 predates ed25519; cast through any. + const { privateKey } = (crypto as any).generateKeyPairSync('ed25519'); + const pem = (privateKey as any).export({ + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase, + }) as string; + + const keyInfo = await vcSigner.loadSigningKey({ privateKeyPem: { pem, passphrase } }); + const { did, verificationMethodId } = await vcSigner.deriveDidKey(keyInfo.publicJwk); + return { keyInfo, did, verificationMethodId }; +} + +const sampleAgreementData = { offer: 'pay $10', deadline: '2026-12-31' }; + +async function buildSignedVc(opts: { + keyInfo: any; + did: string; + agreementUri: string; + agreementDataHash: string; + templateHash: string | null; + partyId: string; + partyName?: string; + overrides?: Record; +}) { + const subject = { + id: opts.agreementUri, + type: 'AgreementSignature', + agreementUri: opts.agreementUri, + agreementDataHash: opts.agreementDataHash, + templateHash: opts.templateHash, + signatory: { did: opts.did, partyId: opts.partyId, name: opts.partyName ?? opts.partyId }, + signedAt: new Date().toISOString(), + ...opts.overrides, + }; + return vcSigner.signCredential({ + type: ['VerifiableCredential', 'AgreementSignatureCredential'], + subject, + signer: { keyInfo: opts.keyInfo }, + }); +} + +describe('signatures helper', () => { + it('hashAgreementData is deterministic regardless of key ordering', async () => { + const a = await hashAgreementData({ b: 2, a: 1, nested: { y: 'y', x: 'x' } }); + const b = await hashAgreementData({ a: 1, nested: { x: 'x', y: 'y' }, b: 2 }); + expect(a).toBe(b); + }); + + it('nextAgreementStatus transitions DRAFT -> SIGNING -> COMPLETED', () => { + const parties = [ + { partyId: 'alice', signatory: true }, + { partyId: 'bob', signatory: true }, + { partyId: 'observer', signatory: false }, + ]; + const base = { uri: 'u', data: {}, agreementParties: parties }; + expect(nextAgreementStatus({ ...base, signatures: [] })).toBe('DRAFT'); + expect(nextAgreementStatus({ ...base, signatures: [{ signatory: parties[0], verifiableCredential: {} as any }] })).toBe('SIGNING'); + expect(nextAgreementStatus({ + ...base, + signatures: [ + { signatory: parties[0], verifiableCredential: {} as any }, + { signatory: parties[1], verifiableCredential: {} as any }, + ], + })).toBe('COMPLETED'); + }); +}); + +describe('validateSignatureCredential', () => { + const agreementUri = 'test://agreement/42'; + const parties = [ + { partyId: 'alice', name: 'Alice', signatory: true }, + { partyId: 'bob', name: 'Bob', signatory: true }, + { partyId: 'observer', name: 'Observer', signatory: false }, + ]; + + async function freshAgreement() { + return { + uri: agreementUri, + data: sampleAgreementData, + templateHash: 'tpl-hash-1', + agreementParties: parties, + signatures: [] as any[], + }; + } + + it('accepts a well-formed VC', async () => { + const signer = await newSigner(); + const agreement = await freshAgreement(); + const vc = await buildSignedVc({ + keyInfo: signer.keyInfo, + did: signer.did, + agreementUri, + agreementDataHash: await hashAgreementData(agreement.data), + templateHash: agreement.templateHash, + partyId: 'alice', + }); + expect(await validateSignatureCredential(vc, agreement)).toBeNull(); + }); + + it('rejects when agreementDataHash does not match', async () => { + const signer = await newSigner(); + const agreement = await freshAgreement(); + const vc = await buildSignedVc({ + keyInfo: signer.keyInfo, + did: signer.did, + agreementUri, + agreementDataHash: 'sha256:wrong', + templateHash: agreement.templateHash, + partyId: 'alice', + }); + const failure = await validateSignatureCredential(vc, agreement); + expect(failure?.code).toBe('DATA_HASH_MISMATCH'); + }); + + it('rejects when issuer DID does not match signatory.did', async () => { + const a = await newSigner(); + const b = await newSigner(); + const agreement = await freshAgreement(); + // Sign with key A but claim party "alice" has DID = B's DID. + const vc = await buildSignedVc({ + keyInfo: a.keyInfo, + did: b.did, // mismatch + agreementUri, + agreementDataHash: await hashAgreementData(agreement.data), + templateHash: agreement.templateHash, + partyId: 'alice', + }); + const failure = await validateSignatureCredential(vc, agreement); + expect(failure?.code).toBe('ISSUER_SIGNATORY_MISMATCH'); + }); + + it('rejects an unknown party', async () => { + const signer = await newSigner(); + const agreement = await freshAgreement(); + const vc = await buildSignedVc({ + keyInfo: signer.keyInfo, + did: signer.did, + agreementUri, + agreementDataHash: await hashAgreementData(agreement.data), + templateHash: agreement.templateHash, + partyId: 'mallory', + }); + const failure = await validateSignatureCredential(vc, agreement); + expect(failure?.code).toBe('UNKNOWN_PARTY'); + }); + + it('rejects a non-signatory party', async () => { + const signer = await newSigner(); + const agreement = await freshAgreement(); + const vc = await buildSignedVc({ + keyInfo: signer.keyInfo, + did: signer.did, + agreementUri, + agreementDataHash: await hashAgreementData(agreement.data), + templateHash: agreement.templateHash, + partyId: 'observer', + }); + const failure = await validateSignatureCredential(vc, agreement); + expect(failure?.code).toBe('NON_SIGNATORY_PARTY'); + }); + + it('rejects a duplicate signature from the same party', async () => { + const signer = await newSigner(); + const agreement = await freshAgreement(); + const vc = await buildSignedVc({ + keyInfo: signer.keyInfo, + did: signer.did, + agreementUri, + agreementDataHash: await hashAgreementData(agreement.data), + templateHash: agreement.templateHash, + partyId: 'alice', + }); + agreement.signatures = [{ signatory: parties[0], verifiableCredential: vc }]; + const failure = await validateSignatureCredential(vc, agreement); + expect(failure?.code).toBe('DUPLICATE_SIGNATURE'); + }); +}); + +describe('verifyAllSignatures', () => { + it('detects post-signing tampering of agreement data', async () => { + const signer = await newSigner(); + const parties = [{ partyId: 'alice', name: 'Alice', signatory: true }]; + const agreement: any = { + uri: 'test://agreement/99', + data: sampleAgreementData, + templateHash: null, + agreementParties: parties, + signatures: [] as any[], + }; + const vc = await buildSignedVc({ + keyInfo: signer.keyInfo, + did: signer.did, + agreementUri: agreement.uri, + agreementDataHash: await hashAgreementData(agreement.data), + templateHash: null, + partyId: 'alice', + }); + agreement.signatures.push({ signatory: parties[0], verifiableCredential: vc }); + + // Pristine: all valid. + const before = await verifyAllSignatures(agreement); + expect(before.allValid).toBe(true); + + // Tamper. + agreement.data = { ...sampleAgreementData, offer: 'pay $1' }; + const after = await verifyAllSignatures(agreement); + expect(after.allValid).toBe(false); + expect(after.results[0].reason).toMatch(/DATA_HASH_MISMATCH/); + }); +}); + +describe('POST /agreements/:id/sign', () => { + let app: express.Application; + let mockDb: any; + const agreementUri = 'test://agreement/77'; + const parties = [ + { partyId: 'alice', name: 'Alice', signatory: true }, + { partyId: 'bob', name: 'Bob', signatory: true }, + ]; + + const agreementRow = { + id: 1, + uri: agreementUri, + data: sampleAgreementData, + template: 'test://template/1', + templateHash: 'tpl-hash-1', + agreementStatus: 'DRAFT', + agreementParties: parties, + signatures: [] as any[], + historyEntries: [] as any[], + }; + const templateRow = { id: 1, uri: 'test://template/1', hash: 'tpl-hash-1' }; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json({ limit: '2mb' })); + + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + }; + app.use((_req, res, next) => { res.locals.db = mockDb; next(); }); + app.use('/agreements', agreementsRouter); + }); + + function mockResolve(agreementOverride: any = {}) { + const row = { ...agreementRow, ...agreementOverride }; + mockDb.limit.mockImplementationOnce(() => Promise.resolve([row])); + return row; + } + + it('accepts a valid signature and advances status to SIGNING', async () => { + const signer = await newSigner(); + mockResolve(); + + const vc = await buildSignedVc({ + keyInfo: signer.keyInfo, + did: signer.did, + agreementUri, + agreementDataHash: await hashAgreementData(sampleAgreementData), + templateHash: 'tpl-hash-1', + partyId: 'alice', + }); + + const response = await request(app).post('/agreements/1/sign').send(vc); + if (response.status !== 200) console.error(response.body); + expect(response.status).toBe(200); + expect(response.body.agreementStatus).toBe('SIGNING'); + expect(response.body.signatures).toHaveLength(1); + expect(response.body.signatures[0].verifiableCredential).toBeDefined(); + expect(mockDb.update).toHaveBeenCalledWith(Agreement); + }); + + it('completes the agreement when all signatories have signed', async () => { + const aSigner = await newSigner(); + const aVc = await buildSignedVc({ + keyInfo: aSigner.keyInfo, + did: aSigner.did, + agreementUri, + agreementDataHash: await hashAgreementData(sampleAgreementData), + templateHash: 'tpl-hash-1', + partyId: 'alice', + }); + mockResolve({ + signatures: [{ signatory: parties[0], verifiableCredential: aVc }], + agreementStatus: 'SIGNING', + }); + + const bSigner = await newSigner(); + const bVc = await buildSignedVc({ + keyInfo: bSigner.keyInfo, + did: bSigner.did, + agreementUri, + agreementDataHash: await hashAgreementData(sampleAgreementData), + templateHash: 'tpl-hash-1', + partyId: 'bob', + }); + + const response = await request(app).post('/agreements/1/sign').send(bVc); + expect(response.status).toBe(200); + expect(response.body.agreementStatus).toBe('COMPLETED'); + expect(response.body.signatures).toHaveLength(2); + }); + + it('rejects a tampered data hash with 400 DATA_HASH_MISMATCH', async () => { + const signer = await newSigner(); + mockResolve(); + const vc = await buildSignedVc({ + keyInfo: signer.keyInfo, + did: signer.did, + agreementUri, + agreementDataHash: 'wrong-hash', + templateHash: 'tpl-hash-1', + partyId: 'alice', + }); + + const response = await request(app).post('/agreements/1/sign').send(vc); + expect(response.status).toBe(400); + expect(response.body.error).toBe('DATA_HASH_MISMATCH'); + expect(mockDb.update).not.toHaveBeenCalled(); + }); + + it('rejects a flipped proof byte with 400 INVALID_PROOF', async () => { + const signer = await newSigner(); + mockResolve(); + const vc: any = await buildSignedVc({ + keyInfo: signer.keyInfo, + did: signer.did, + agreementUri, + agreementDataHash: await hashAgreementData(sampleAgreementData), + templateHash: 'tpl-hash-1', + partyId: 'alice', + }); + // Flip the last char of proofValue to invalidate the signature. + const pv: string = vc.proof.proofValue; + vc.proof.proofValue = pv.slice(0, -1) + (pv.endsWith('A') ? 'B' : 'A'); + + const response = await request(app).post('/agreements/1/sign').send(vc); + expect(response.status).toBe(400); + expect(response.body.error).toBe('INVALID_PROOF'); + }); +}); diff --git a/server/handlers/signatures.ts b/server/handlers/signatures.ts new file mode 100644 index 0000000..fa9d646 --- /dev/null +++ b/server/handlers/signatures.ts @@ -0,0 +1,190 @@ +import { createHash } from 'crypto'; +import * as vcSigner from './vcSigner'; + +// canonicalize@3 is ESM-only with a default export. Same dynamic-import trick +// as vcSigner.ts to avoid tsc downleveling to require(). +let canonicalizeFn: ((value: unknown) => string) | null = null; +async function loadCanonicalize(): Promise<(value: unknown) => string> { + if (canonicalizeFn) return canonicalizeFn; + const mod = await (Function('return import("canonicalize")')() as Promise<{ default: (v: unknown) => string }>); + canonicalizeFn = mod.default; + return canonicalizeFn; +} + +export type AgreementPartyLite = { + partyId?: string; + name?: string; + signatory?: boolean; + [k: string]: unknown; +}; + +export type CredentialSubject = { + id?: string; + type?: string; + agreementUri?: string; + agreementDataHash?: string; + templateHash?: string | null; + signatory?: { did?: string; partyId?: string; name?: string }; + signedAt?: string; + evidence?: unknown; +}; + +export type VerifiableCredential = { + '@context'?: unknown; + type?: string[] | string; + issuer?: string | { id: string }; + credentialSubject?: CredentialSubject; + proof?: unknown; +}; + +export type SignatureRecord = { + signatory: AgreementPartyLite; + signedAt?: string; + metadata?: unknown; + signatureImage?: unknown; + verifiableCredential?: VerifiableCredential; +}; + +export type AgreementLite = { + uri: string; + data: unknown; + templateHash?: string | null; + agreementParties?: AgreementPartyLite[]; + signatures?: SignatureRecord[]; +}; + +/** + * SHA-256 hex of the JCS-canonicalized agreement data. This is the value clients + * must put into credentialSubject.agreementDataHash so the server can re-derive + * and compare without trusting client-supplied bytes. + */ +export async function hashAgreementData(data: unknown): Promise { + const canonicalize = await loadCanonicalize(); + const json = canonicalize(data); + if (typeof json !== 'string') { + throw new Error('Failed to canonicalize agreement data'); + } + return createHash('sha256').update(json).digest('hex'); +} + +function issuerDid(vc: VerifiableCredential): string | undefined { + if (!vc.issuer) return undefined; + return typeof vc.issuer === 'string' ? vc.issuer : vc.issuer.id; +} + +function partyMatches(party: AgreementPartyLite, partyId: string): boolean { + return party.partyId === partyId || (party as any).id === partyId; +} + +export type ValidationFailure = { code: string; message: string }; + +/** + * Validates that a posted VC is a well-formed AgreementSignatureCredential for + * the given agreement. Returns a failure descriptor on the first problem, or + * null on success. Cryptographic verification is performed first; everything + * after that is structural / business-rule checks. + */ +export async function validateSignatureCredential( + vc: VerifiableCredential, + agreement: AgreementLite, +): Promise { + if (!vc || typeof vc !== 'object') { + return { code: 'INVALID_BODY', message: 'Request body must be a VerifiableCredential object' }; + } + + try { + await vcSigner.verifyCredential(vc); + } catch (err: any) { + return { code: 'INVALID_PROOF', message: `Proof verification failed: ${err?.message ?? String(err)}` }; + } + + const subject = vc.credentialSubject; + if (!subject) { + return { code: 'MISSING_SUBJECT', message: 'credentialSubject is required' }; + } + if (subject.agreementUri !== agreement.uri) { + return { code: 'AGREEMENT_URI_MISMATCH', message: `credentialSubject.agreementUri ${subject.agreementUri} does not match agreement.uri ${agreement.uri}` }; + } + + const expectedHash = await hashAgreementData(agreement.data); + if (subject.agreementDataHash !== expectedHash) { + return { code: 'DATA_HASH_MISMATCH', message: 'credentialSubject.agreementDataHash does not match the current agreement data' }; + } + + if ((agreement.templateHash ?? null) !== (subject.templateHash ?? null)) { + return { code: 'TEMPLATE_HASH_MISMATCH', message: 'credentialSubject.templateHash does not match the agreement template' }; + } + + const sigSubject = subject.signatory; + if (!sigSubject?.partyId || !sigSubject?.did) { + return { code: 'INVALID_SIGNATORY', message: 'credentialSubject.signatory.{partyId,did} are required' }; + } + + const issuer = issuerDid(vc); + if (!issuer || issuer !== sigSubject.did) { + return { code: 'ISSUER_SIGNATORY_MISMATCH', message: 'VC issuer DID must equal credentialSubject.signatory.did' }; + } + + const party = (agreement.agreementParties ?? []).find(p => partyMatches(p, sigSubject.partyId!)); + if (!party) { + return { code: 'UNKNOWN_PARTY', message: `No AgreementParty with partyId ${sigSubject.partyId}` }; + } + if (party.signatory !== true) { + return { code: 'NON_SIGNATORY_PARTY', message: `Party ${sigSubject.partyId} is not marked as a signatory` }; + } + + const alreadySigned = (agreement.signatures ?? []).some(s => + s.verifiableCredential && s.signatory && partyMatches(s.signatory, sigSubject.partyId!) + ); + if (alreadySigned) { + return { code: 'DUPLICATE_SIGNATURE', message: `Party ${sigSubject.partyId} has already signed` }; + } + + return null; +} + +/** + * Re-verifies every cryptographic signature on an agreement against the agreement's + * current state. Used by /verify-signatures to detect post-signing tampering. + */ +export async function verifyAllSignatures(agreement: AgreementLite): Promise<{ + results: Array<{ partyId: string | undefined; valid: boolean; reason?: string }>; + allValid: boolean; +}> { + const sigs = (agreement.signatures ?? []).filter(s => s.verifiableCredential); + const results = await Promise.all(sigs.map(async s => { + const vc = s.verifiableCredential!; + const partyId = vc.credentialSubject?.signatory?.partyId ?? s.signatory?.partyId; + const failure = await validateSignatureCredentialForVerify(vc, agreement); + return failure + ? { partyId, valid: false, reason: `${failure.code}: ${failure.message}` } + : { partyId, valid: true }; + })); + return { results, allValid: results.every(r => r.valid) }; +} + +/** + * Like validateSignatureCredential but skips the "already signed" check (we + * expect every signature to appear in agreement.signatures during verify). + */ +async function validateSignatureCredentialForVerify( + vc: VerifiableCredential, + agreement: AgreementLite, +): Promise { + const withoutSelf: AgreementLite = { + ...agreement, + signatures: (agreement.signatures ?? []).filter(s => s.verifiableCredential !== vc), + }; + return validateSignatureCredential(vc, withoutSelf); +} + +/** + * Computes the next AgreementStatusType after adding/removing signatures. + */ +export function nextAgreementStatus(agreement: AgreementLite): 'DRAFT' | 'SIGNING' | 'COMPLETED' { + const requiredCount = (agreement.agreementParties ?? []).filter(p => p.signatory === true).length; + const signedCount = (agreement.signatures ?? []).filter(s => s.verifiableCredential).length; + if (signedCount === 0) return 'DRAFT'; + if (signedCount >= requiredCount && requiredCount > 0) return 'COMPLETED'; + return 'SIGNING'; +} diff --git a/server/handlers/vcSigner.ts b/server/handlers/vcSigner.ts new file mode 100644 index 0000000..041cf57 --- /dev/null +++ b/server/handlers/vcSigner.ts @@ -0,0 +1,39 @@ +// Thin async wrapper around the ESM-only `vc-signer` package. +// apap compiles with `module: commonjs`, so a plain dynamic `import('vc-signer')` +// would be downleveled to `require()` and fail at runtime. Wrapping it in +// `Function('return import(...)')` keeps the import truly dynamic. + +type VerifyResult = { verified: true; issuer: string; verificationMethodId: string; subject: any }; + +type VcSignerModule = { + // Throws on any failure; resolves with { verified: true, ... } on success. + verifyCredential: (vc: unknown, opts?: unknown) => Promise; + signCredential: (opts: any) => Promise; + loadSigningKey: (opts: any) => Promise; + deriveDidKey: (publicJwk: any) => { did: string; verificationMethodId: string }; + resolveIssuerKey: (issuerDid: string, verificationMethodId?: string) => Promise; +}; + +let cached: VcSignerModule | null = null; + +async function load(): Promise { + if (cached) return cached; + cached = await (Function('return import("vc-signer")')() as Promise); + return cached; +} + +export async function verifyCredential(vc: unknown) { + return (await load()).verifyCredential(vc); +} + +export async function signCredential(opts: any) { + return (await load()).signCredential(opts); +} + +export async function loadSigningKey(opts: any) { + return (await load()).loadSigningKey(opts); +} + +export async function deriveDidKey(publicJwk: any) { + return (await load()).deriveDidKey(publicJwk); +} diff --git a/server/package-lock.json b/server/package-lock.json index e206d07..d39b62d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -26,6 +26,7 @@ "openapi-backend": "5.12.0", "postgres": "3.4.5", "source-map-support": "0.5.10", + "vc-signer": "file:./vendor/vc-signer-0.1.2.tgz", "zod": "3.24.3" }, "devDependencies": { @@ -4608,6 +4609,42 @@ "node": ">= 0.6" } }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/ed25519": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.1.0.tgz", + "integrity": "sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -6431,6 +6468,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canonicalize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz", + "integrity": "sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/capture-stack-trace": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/capture-stack-trace/-/capture-stack-trace-1.0.2.tgz", @@ -11037,6 +11086,15 @@ "node": ">=8.9.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jpeg-exif": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz", @@ -11920,6 +11978,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/multiformats": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-14.0.0.tgz", + "integrity": "sha512-iWK1RrAS58p2NDfeZFuSUSv3ZPewTIhsGbh/5NgeGGJwJmRljLxGtjRR3nkn+loG3zl+IrfR/W1590QnrSK+Gg==", + "license": "Apache-2.0 OR MIT" + }, "node_modules/nan": { "version": "2.23.1", "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz", @@ -16140,6 +16204,36 @@ "node": ">= 0.8" } }, + "node_modules/vc-signer": { + "version": "0.1.2", + "resolved": "file:vendor/vc-signer-0.1.2.tgz", + "integrity": "sha512-bcB9+4o4EDpo2JxUwqYPI7k9vYqLFTOwes/zXPaVjChEQGUZFnkOzVx03jUQ5zJqHgeqj3euF+yw9UROMEqwEw==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^2.2.0", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "canonicalize": "^3.0.0", + "jose": "^6.2.3", + "multiformats": "^14.0.0" + }, + "engines": { + "node": ">=20", + "npm": ">=10" + } + }, + "node_modules/vc-signer/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", diff --git a/server/package.json b/server/package.json index 57a0b32..f832f47 100644 --- a/server/package.json +++ b/server/package.json @@ -40,6 +40,7 @@ "openapi-backend": "5.12.0", "postgres": "3.4.5", "source-map-support": "0.5.10", + "vc-signer": "file:./vendor/vc-signer-0.1.2.tgz", "zod": "3.24.3" }, "devDependencies": { diff --git a/server/scripts/demo-keys.ts b/server/scripts/demo-keys.ts new file mode 100644 index 0000000..9ff3d9d --- /dev/null +++ b/server/scripts/demo-keys.ts @@ -0,0 +1,17 @@ +import crypto from 'crypto'; + +/** + * Generates an encrypted PKCS#8 Ed25519 PEM that vc-signer's loadSigningKey + * can consume directly. For demo/test use only — real clients should manage + * keys with proper rotation and storage. + */ +export function generateEncryptedEd25519Pem(passphrase: string): { pem: string; passphrase: string } { + const { privateKey } = (crypto as any).generateKeyPairSync('ed25519'); + const pem = (privateKey as any).export({ + type: 'pkcs8', + format: 'pem', + cipher: 'aes-256-cbc', + passphrase, + }) as string; + return { pem, passphrase }; +} diff --git a/server/scripts/sign-agreement-demo.ts b/server/scripts/sign-agreement-demo.ts new file mode 100644 index 0000000..62de731 --- /dev/null +++ b/server/scripts/sign-agreement-demo.ts @@ -0,0 +1,84 @@ +/** + * Client-side signing demo for an apap agreement. + * + * npx tsx scripts/sign-agreement-demo.ts + * + * Env vars: + * APAP_BASE_URL default http://localhost:3000 + * AGREEMENT_ID required, numeric DB id + * PARTY_ID required, must match an AgreementParty.partyId with signatory=true + * PARTY_NAME optional, defaults to PARTY_ID + * KEY_PEM_PATH optional path to an encrypted PKCS#8 PEM; otherwise a fresh + * Ed25519 key is generated for the demo + * KEY_PASSPHRASE passphrase for the PEM (or for the generated key) + */ +import fs from 'fs'; +import * as vcSigner from '../handlers/vcSigner'; +import { hashAgreementData } from '../handlers/signatures'; +import { generateEncryptedEd25519Pem } from './demo-keys'; + +async function main() { + const baseUrl = process.env.APAP_BASE_URL ?? 'http://localhost:3000'; + const agreementId = required('AGREEMENT_ID'); + const partyId = required('PARTY_ID'); + const partyName = process.env.PARTY_NAME ?? partyId; + const passphrase = process.env.KEY_PASSPHRASE ?? 'demo-passphrase'; + + const pem = process.env.KEY_PEM_PATH + ? fs.readFileSync(process.env.KEY_PEM_PATH, 'utf8') + : (() => { + const generated = generateEncryptedEd25519Pem(passphrase); + console.error('[demo] No KEY_PEM_PATH provided, generated an ephemeral Ed25519 key.'); + return generated.pem; + })(); + + const keyInfo = await vcSigner.loadSigningKey({ privateKeyPem: { pem, passphrase } }); + const { did, verificationMethodId } = await vcSigner.deriveDidKey(keyInfo.publicJwk); + console.error(`[demo] DID for ${partyId}: ${did}`); + + const agreementRes = await fetch(`${baseUrl}/agreements/${agreementId}`); + if (!agreementRes.ok) throw new Error(`GET /agreements/${agreementId} → ${agreementRes.status}`); + const agreement = await agreementRes.json() as any; + + const agreementDataHash = await hashAgreementData(agreement.data); + + const subject = { + id: agreement.uri, + type: 'AgreementSignature', + agreementUri: agreement.uri, + agreementDataHash, + templateHash: agreement.templateHash ?? null, + signatory: { did, partyId, name: partyName }, + signedAt: new Date().toISOString(), + }; + + const vc = await vcSigner.signCredential({ + type: ['VerifiableCredential', 'AgreementSignatureCredential'], + subject, + signer: { keyInfo, verificationMethodId }, + }); + + const signRes = await fetch(`${baseUrl}/agreements/${agreementId}/sign`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(vc), + }); + const body = await signRes.text(); + console.log(`POST /agreements/${agreementId}/sign → ${signRes.status}`); + console.log(body); + if (!signRes.ok) process.exit(1); +} + +function required(name: string): string { + const v = process.env[name]; + if (!v) { + console.error(`Missing required env var ${name}`); + process.exit(2); + } + return v; +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/server/vendor/vc-signer-0.1.2.tgz b/server/vendor/vc-signer-0.1.2.tgz new file mode 100644 index 0000000..f4bd0e0 Binary files /dev/null and b/server/vendor/vc-signer-0.1.2.tgz differ