Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/W3DS Protocol/Company-Delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ All of these live in the **company's own eVault**, are public (`acl: ["*"]`) so
- Only a **sitting director** may change the board.
- A board only counts if the Company record's **very first version** already carries one, signed by one of its own directors. A Company record that started without directors can never acquire a board, so an existing company cannot be claimed by whoever adds directors first.
- The company's eVault `/whois` reports `type: "company"` with this record as its manifest. Verifiers start there.
- The record is also the company's **legal identity**: legal name and form, registration number and jurisdiction, registered address, founding date, IBAN and statutes. Every field is covered by the director's signature in `authorization`, so changing any of them takes a director's signature: nobody else can, for instance, swap the account payers see. Details people edit day to day (a display name, branding, a visiting address) belong in a record the community's admins can write without a board signature.

### Role

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export class GraphQLServer {
"security_question",
"deployment_key",
"software_version",
"consent",
] as const;
type ValidType =
(typeof VALID_BINDING_DOCUMENT_TYPES)[number];
Expand Down Expand Up @@ -954,6 +955,7 @@ export class GraphQLServer {
"security_question",
"deployment_key",
"software_version",
"consent",
] as const;
type ValidType =
(typeof VALID_BINDING_DOCUMENT_TYPES)[number];
Expand Down
1 change: 1 addition & 0 deletions infrastructure/evault-core/src/core/protocol/typedefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ export const typeDefs = /* GraphQL */ `
security_question
deployment_key
software_version
consent
}

type BindingDocumentSignature {
Expand Down
18 changes: 16 additions & 2 deletions infrastructure/evault-core/src/core/types/binding-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export type BindingDocumentType =
| "personal_parameters"
| "security_question"
| "deployment_key"
| "software_version";
| "software_version"
| "consent";

export interface BindingDocumentSignature {
signer: string;
Expand Down Expand Up @@ -79,6 +80,18 @@ export interface BindingDocumentSecurityQuestionData {
answerHash: string;
}

/** A person's consent to one proposal in a SignatureRequest. Created by the
* requesting platform; the subject consents by counter-signing. */
export interface BindingDocumentConsentData {
kind: "consent";
statement: string;
requestOwnerEName: string;
requestId: string;
slotId: string;
requesterEName: string;
proposal: { ontology: string; recordId: string; recordSha256: string };
}

export type BindingDocumentData =
| BindingDocumentIdDocumentData
| BindingDocumentPhotographData
Expand All @@ -87,7 +100,8 @@ export type BindingDocumentData =
| BindingDocumentPersonalParametersData
| BindingDocumentSecurityQuestionData
| BindingDocumentDeploymentKeyData
| BindingDocumentSoftwareVersionData;
| BindingDocumentSoftwareVersionData
| BindingDocumentConsentData;

export interface BindingDocument {
subject: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,62 @@ describe("BindingDocumentService (integration)", () => {
}
});

const consentData = {
kind: "consent" as const,
statement: "Become head of Test Org (requested by @sarah)",
requestOwnerEName: "@org",
requestId: "request-1",
slotId: "subject",
requesterEName: "@sarah",
proposal: { ontology: "0b2f15d8-c3f9-4dba-b959-5cfa11272dae", recordId: "record-1", recordSha256: "a".repeat(64) },
};

it("should create a binding document with consent type", async () => {
const result = await bindingDocumentService.createBindingDocument(
{
subject: "test-user-123",
type: "consent",
data: consentData,
ownerSignature: {
signer: TEST_ENAME,
signature: computeBindingDocumentHash({ subject: "@test-user-123", type: "consent", data: consentData }),
timestamp: new Date().toISOString(),
},
},
TEST_ENAME,
);

expect(result.bindingDocument.type).toBe("consent");
expect(result.bindingDocument.data).toEqual(consentData);
});

it("should reject consent data that doesn't name its request and proposal", async () => {
const broken = [
{ ...consentData, statement: " " },
{ ...consentData, requestOwnerEName: "org" },
{ ...consentData, slotId: "" },
{ ...consentData, proposal: { ...consentData.proposal, recordSha256: "not-a-hash" } },
{ ...consentData, proposal: undefined },
];
for (const data of broken as any[]) {
await expect(
bindingDocumentService.createBindingDocument(
{
subject: "test-user-123",
type: "consent",
data,
ownerSignature: {
signer: TEST_ENAME,
signature: computeBindingDocumentHash({ subject: "@test-user-123", type: "consent", data }),
timestamp: new Date().toISOString(),
},
},
TEST_ENAME,
),
).rejects.toThrow("consent data is invalid");
}
});

it("should create a binding document with self type", async () => {
const data = { kind: "self" as const, name: "Bob Jones" };
const result = await bindingDocumentService.createBindingDocument(
Expand Down Expand Up @@ -513,6 +569,42 @@ describe("BindingDocumentService (integration)", () => {
);
});

it("should only let the subject counter-sign a consent", async () => {
const subject = "@test-user-consent";
const data = {
kind: "consent" as const,
statement: "Become head of Test Org (requested by @sarah)",
requestOwnerEName: "@org",
requestId: "request-2",
slotId: "subject",
requesterEName: "@sarah",
proposal: { ontology: "0b2f15d8-c3f9-4dba-b959-5cfa11272dae", recordId: "record-2", recordSha256: "b".repeat(64) },
};
const docHash = computeBindingDocumentHash({ subject, type: "consent", data });
const created = await bindingDocumentService.createBindingDocument(
{
subject,
type: "consent",
data,
ownerSignature: { signer: TEST_ENAME, signature: docHash, timestamp: new Date().toISOString() },
},
TEST_ENAME,
);

await expect(
bindingDocumentService.addCounterpartySignature(
{ metaEnvelopeId: created.id, signature: { signer: "@stranger", signature: docHash, timestamp: new Date().toISOString() } },
TEST_ENAME,
),
).rejects.toThrow(`Signer "@stranger" is not the expected counterparty "${subject}"`);

const updated = await bindingDocumentService.addCounterpartySignature(
{ metaEnvelopeId: created.id, signature: { signer: subject, signature: docHash, timestamp: new Date().toISOString() } },
TEST_ENAME,
);
expect(updated.signatures.map((x) => x.signer)).toEqual([TEST_ENAME, subject]);
});

it("should throw error when binding document not found", async () => {
await expect(
bindingDocumentService.addCounterpartySignature(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
BindingDocumentSignature,
BindingDocumentSocialConnectionData,
BindingDocumentSoftwareVersionData,
BindingDocumentConsentData,
BindingDocumentType,
} from "../core/types/binding-document";

Expand Down Expand Up @@ -220,6 +221,44 @@ function validateBindingDocumentData(
commitSha: d.commitSha.toLowerCase(),
} as BindingDocumentSoftwareVersionData;
}
case "consent": {
const p = d.proposal as Record<string, unknown> | undefined;
const isEName = (v: unknown) =>
typeof v === "string" && v.startsWith("@") && v.length > 1;
if (
d.kind !== "consent" ||
typeof d.statement !== "string" ||
!d.statement.trim() ||
!isEName(d.requestOwnerEName) ||
typeof d.requestId !== "string" ||
!d.requestId ||
typeof d.slotId !== "string" ||
!d.slotId ||
!isEName(d.requesterEName) ||
!p ||
typeof p !== "object" ||
typeof p.ontology !== "string" ||
typeof p.recordId !== "string" ||
!p.recordId ||
typeof p.recordSha256 !== "string" ||
!/^[0-9a-f]{64}$/.test(p.recordSha256)
) {
throw new ValidationError("consent data is invalid");
}
return {
kind: "consent",
statement: d.statement,
requestOwnerEName: d.requestOwnerEName as string,
requestId: d.requestId,
slotId: d.slotId,
requesterEName: d.requesterEName as string,
proposal: {
ontology: p.ontology,
recordId: p.recordId,
recordSha256: p.recordSha256,
},
} as BindingDocumentConsentData;
}
default: {
const _exhaustive: never = type;
throw new ValidationError(
Expand Down Expand Up @@ -527,8 +566,11 @@ export class BindingDocumentService {
);
}

// For social_connection documents the counterparty must be the subject
if (bindingDocument.type === "social_connection") {
// For social_connection and consent documents the counterparty must be the subject
if (
bindingDocument.type === "social_connection" ||
bindingDocument.type === "consent"
) {
if (input.signature.signer !== bindingDocument.subject) {
throw new Error(
`Signer "${input.signature.signer}" is not the expected counterparty "${bindingDocument.subject}"`,
Expand Down
83 changes: 82 additions & 1 deletion services/ontology/schemas/binding-document.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"personal_parameters",
"security_question",
"deployment_key",
"software_version"
"software_version",
"consent"
],
"description": "The type of binding document"
},
Expand Down Expand Up @@ -129,9 +130,89 @@
"then": {
"properties": { "data": { "$ref": "#/definitions/SoftwareVersionData" } }
}
},
{
"if": {
"properties": { "type": { "const": "consent" } },
"required": ["type"]
},
"then": {
"properties": { "data": { "$ref": "#/definitions/ConsentData" } }
}
}
],
"definitions": {
"ConsentData": {
"description": "A person's consent to one specific proposal in a SignatureRequest, kept as a BindingDocument in their own vault. The platform that created the request creates the document (its own signature is the owner signature); the subject consents by adding their signature as the counterparty, which for this type must be the subject. Both signatures cover the BindingDocument's canonical form, so the consent names exactly this request, this slot and this proposal. Declining is deleting the document.",
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "consent",
"description": "Discriminant for consent data."
},
"statement": {
"type": "string",
"minLength": 1,
"description": "What the subject agrees to, in words, shown by their wallet before they sign, e.g. \"Become head of Test Org (requested by @Sarah)\"."
},
"requestOwnerEName": {
"type": "string",
"pattern": "^@[^\\s]+$",
"description": "eName of the vault holding the SignatureRequest."
},
"requestId": {
"type": "string",
"description": "MetaEnvelope id of the SignatureRequest."
},
"slotId": {
"type": "string",
"minLength": 1,
"description": "The request slot this consent fills."
},
"requesterEName": {
"type": "string",
"pattern": "^@[^\\s]+$",
"description": "eName of the person who asked for the change."
},
"proposal": {
"type": "object",
"description": "The proposal consented to, copied from the request, so the consent cannot be reused for a different change.",
"properties": {
"ontology": {
"type": "string",
"format": "uuid",
"description": "schemaId of the proposed record."
},
"recordId": {
"type": "string",
"description": "MetaEnvelope id the proposed record will be written under."
},
"recordSha256": {
"type": "string",
"pattern": "^[0-9a-f]{64}$",
"description": "Hex SHA-256 of the proposed record's canonical JSON, as in the request."
}
},
"required": [
"ontology",
"recordId",
"recordSha256"
],
"additionalProperties": false
}
},
"required": [
"kind",
"statement",
"requestOwnerEName",
"requestId",
"slotId",
"requesterEName",
"proposal"
],
"additionalProperties": false
},
"IdDocumentData": {
"type": "object",
"properties": {
Expand Down
26 changes: 23 additions & 3 deletions services/ontology/schemas/company.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"schemaId": "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402",
"title": "Company",
"title": "Organization",
"domain": "work",
"description": "A company's profile. The earliest Company record naming its own eVault is that eVault's manifest.",
"description": "An organisation's legal identity and board: a company, foundation, association or cooperative. The earliest record naming its own eVault is that eVault's manifest. Titled \"Company\" until 2026-10; the schemaId is unchanged.",
"type": "object",
"properties": {
"id": {
Expand Down Expand Up @@ -55,7 +55,8 @@
"type": "string"
},
"address": {
"type": "string"
"type": "string",
"description": "Registered address: the legal seat, as registered in the jurisdiction. A visiting address is a different fact and does not belong here."
},
"registrationNumber": {
"type": "string",
Expand All @@ -65,6 +66,25 @@
"type": "string",
"description": "Jurisdiction of registration, e.g. an ISO 3166 code"
},
"legalForm": {
"type": "string",
"description": "Legal form in its jurisdiction, as registered, e.g. \"BV\", \"stichting\", \"vereniging\", \"GmbH\", \"LLC\"."
},
"foundingDate": {
"type": "string",
"format": "date",
"description": "Date the organisation was legally founded (YYYY-MM-DD)."
},
"iban": {
"type": "string",
"pattern": "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$",
"description": "IBAN of the account payments to the organisation go to, without spaces. It is part of the signed record, so only a director can change it, and nobody can swap the account a payer sees without a director's signature."
},
"statutesFileUri": {
"type": "string",
"pattern": "^w3ds://file\\?id=@[^\\s/]+/[^\\s]+$",
"description": "The organisation's statutes (articles of association), as a w3ds://file URI."
},
"createdAt": {
"type": "string",
"format": "date-time"
Expand Down
Loading