diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index 3005259ef..d98241566 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -96,6 +96,16 @@ This flat graph structure allows: - More complex queries when reconstructing full objects - Potential performance impact with deeply nested structures +### Binding Documents + +A **Binding Document** is a special type of MetaEnvelope that ties a user to their [eName](/docs/W3DS%20Basics/eName). It establishes identity verification or claims through cryptographic signatures. See [Binding Documents](/docs/W3DS%20Basics/Binding-Documents) for full details. + +Key characteristics: +- **Stored as MetaEnvelope**: Binding documents use the same MetaEnvelope structure, with ontology ID `b1d0a8c3-4e5f-6789-0abc-def012345678` +- **ID is MetaEnvelope ID**: The binding document is identified by its MetaEnvelope ID (no separate ID field) +- **Always signed**: Owner signature is required; counterparty signatures can be added +- **Types**: `id_document`, `photograph`, `social_connection`, `self` + ## GraphQL API eVault exposes a GraphQL API at `/graphql` for all data operations. All operations require the `X-ENAME` header to identify the eVault owner. @@ -337,6 +347,155 @@ The `skipWebhooks` parameter only suppresses webhooks when: For regular platform requests, webhooks are always delivered regardless of this parameter. +### Binding Document Operations + +eVault provides dedicated GraphQL operations for managing [Binding Documents](/docs/W3DS%20Basics/Binding-Documents) — MetaEnvelopes that tie users to their eNames. + +#### bindingDocument Query + +Retrieve a single binding document by its MetaEnvelope ID. + +**Query**: +```graphql +query { + bindingDocument(id: "meta-envelope-id") { + subject + type + data + signatures { + signer + signature + timestamp + } + } +} +``` + +#### bindingDocuments Query + +Retrieve binding documents with cursor-based pagination and optional filtering by type. + +**Query**: +```graphql +query { + bindingDocuments( + type: id_document + first: 10 + after: "cursor-string" + ) { + edges { + cursor + node { + subject + type + data + signatures { + signer + signature + timestamp + } + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } +} +``` + +**Filter Options**: +- `type`: Filter by binding document type (`id_document`, `photograph`, `social_connection`, `self`) + +**Pagination**: +- `first` / `after`: Forward pagination +- `last` / `before`: Backward pagination + +#### createBindingDocument Mutation + +Create a new binding document. This stores a MetaEnvelope with ontology `b1d0a8c3-4e5f-6789-0abc-def012345678`. + +**Mutation**: +```graphql +mutation { + createBindingDocument(input: { + subject: "@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a" + type: id_document + data: { + vendor: "onfido" + reference: "ref-12345" + name: "John Doe" + } + ownerSignature: { + signer: "@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a" + signature: "sig_abc123..." + timestamp: "2025-01-24T10:00:00Z" + } + }) { + metaEnvelopeId + bindingDocument { + subject + type + data + signatures { + signer + signature + timestamp + } + } + errors { + message + code + } + } +} +``` + +**Input fields**: +- `subject`: The eName being bound (will be normalized to include `@` prefix) +- `type`: One of `id_document`, `photograph`, `social_connection`, `self` +- `data`: Type-specific payload (see [Binding Documents](/docs/W3DS%20Basics/Binding-Documents)) +- `ownerSignature`: Required signature from the subject + +#### createBindingDocumentSignature Mutation + +Add a signature to an existing binding document. Used for counterparty verification. + +**Mutation**: +```graphql +mutation { + createBindingDocumentSignature(input: { + bindingDocumentId: "meta-envelope-id" + signature: { + signer: "@counterparty-uuid" + signature: "sig_counterparty_xyz..." + timestamp: "2025-01-24T11:00:00Z" + } + }) { + bindingDocument { + subject + type + signatures { + signer + signature + timestamp + } + } + errors { + message + code + } + } +} +``` + +**Input fields**: +- `bindingDocumentId`: The MetaEnvelope ID of the binding document +- `signature`: The signature to add (signer, signature, timestamp) + ### Legacy API The following queries and mutations are preserved for backward compatibility but are considered legacy. New integrations should use the idiomatic API above. diff --git a/docs/docs/W3DS Basics/Binding-Documents.md b/docs/docs/W3DS Basics/Binding-Documents.md new file mode 100644 index 000000000..3488f4c66 --- /dev/null +++ b/docs/docs/W3DS Basics/Binding-Documents.md @@ -0,0 +1,256 @@ +--- +sidebar_position: 5 +--- + +# Binding Documents + +**Binding documents** are a special type of [MetaEnvelope](/docs/W3DS%20Basics/glossary#metaenvelope) that tie a user to their [eName](/docs/W3DS%20Basics/eName). They establish the relationship between a person's identity and their globally unique identifier in the W3DS ecosystem. + +## Overview + +A binding document always contains: +- The **subject** — the eName being bound (prefixed with `@`) +- The **type** of binding — what kind of verification or claim this represents +- **Data** — type-specific payload containing verification details +- **Signatures** — cryptographic proofs from the user and optionally from counterparty verifiers + +```mermaid +graph TB + subgraph BindingDocument["Binding Document (MetaEnvelope)"] + direction LR + Subject["subject
@uuid"] + Type["type
id_document | photograph | social_connection | self"] + Data["data
Type-specific payload"] + Signatures["signatures
[owner, counterparty1, ...]"] + end + + User[User] -->|signs| Subject + Counterparty[Counterparty] -->|signs| Signatures + + style BindingDocument fill:#e8f5e9,color:#000000 + style Subject fill:#e1f5ff,color:#000000 + style Type fill:#e1f5ff,color:#000000 + style Data fill:#e1f5ff,color:#000000 + style Signatures fill:#fff4e1,color:#000000 +``` + +## Binding Document Types + +### id_document + +Binds an eName to a verified identity document (e.g., passport, driver's license). + +```json +{ + "type": "id_document", + "data": { + "vendor": "onfido", + "reference": "ref-12345", + "name": "John Doe" + } +} +``` + +**Data fields:** +- `vendor` — The verification vendor used +- `reference` — Vendor's reference ID for the verification +- `name` — The name verified against the ID document + +### photograph + +Binds an eName to a photograph (selfie or profile picture). + +```json +{ + "type": "photograph", + "data": { + "photoBlob": "base64encodedimage==" + } +} +``` + +**Data fields:** +- `photoBlob` — Base64-encoded photograph data + +### social_connection + +Binds an eName to a social connection or relationship claim. + +```json +{ + "type": "social_connection", + "data": { + "name": "Alice Smith" + } +} +``` + +**Data fields:** +- `name` — Name of the connected person or entity + +### self + +A self-signed binding document where the user declares their identity. + +```json +{ + "type": "self", + "data": { + "name": "Bob Jones" + } +} +``` + +**Data fields:** +- `name` — Self-declared name + +## Signatures + +Every binding document must include at least the **owner's signature**. Counterparty signatures can be added to create multi-party verification chains. + +```typescript +interface BindingDocumentSignature { + signer: string; // eName or keyID of who signed + signature: string; // Cryptographic signature + timestamp: string; // ISO 8601 timestamp +} +``` + +### Signature Flow + +```mermaid +sequenceDiagram + participant User as User + participant eVault as eVault + participant Counterparty as Counterparty + + User->>eVault: createBindingDocument(subject, type, data, ownerSignature) + eVault->>eVault: Store MetaEnvelope with owner signature + + Note over eVault: Binding document now has
owner's signature + + Counterparty->>eVault: createBindingDocumentSignature(bindingDocumentId, signature) + eVault->>eVault: Append counterparty signature + + Note over eVault: Binding document now has
owner + counterparty signatures +``` + +## Storage + +Binding documents are stored as **MetaEnvelopes** in eVault: + +- **Ontology ID**: `b1d0a8c3-4e5f-6789-0abc-def012345678` (defined in `/services/ontology/schemas/binding-document.json`) +- **ID**: The MetaEnvelope ID serves as the binding document ID +- **ACL**: Restricted to the subject's eName + +This means: +- Each binding document is a MetaEnvelope +- The MetaEnvelope ID is used to reference the binding document +- Access is controlled by the subject's eName in the ACL + +## GraphQL API + +eVault provides dedicated GraphQL operations for binding documents: + +### Queries + +```graphql +# Get a single binding document by ID +query { + bindingDocument(id: "meta-envelope-id") { + subject + type + data + signatures { + signer + signature + timestamp + } + } +} + +# List binding documents with optional type filter +query { + bindingDocuments(type: id_document, first: 10) { + edges { + node { + subject + type + data + signatures { + signer + signature + timestamp + } + } + } + } +} +``` + +### Mutations + +```graphql +# Create a new binding document +mutation { + createBindingDocument(input: { + subject: "@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a" + type: id_document + data: { + vendor: "onfido" + reference: "ref-12345" + name: "John Doe" + } + ownerSignature: { + signer: "@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a" + signature: "sig_abc123..." + timestamp: "2025-01-24T10:00:00Z" + } + }) { + metaEnvelopeId + bindingDocument { + subject + type + signatures { + signer + timestamp + } + } + errors { + message + code + } + } +} + +# Add a signature to an existing binding document +mutation { + createBindingDocumentSignature(input: { + bindingDocumentId: "meta-envelope-id" + signature: { + signer: "@counterparty-uuid" + signature: "sig_counterparty_xyz..." + timestamp: "2025-01-24T11:00:00Z" + } + }) { + bindingDocument { + signatures { + signer + signature + timestamp + } + } + errors { + message + code + } + } +} +``` + +## Related + +- [eName](/docs/W3DS%20Basics/eName) — The identifier that binding documents tie users to +- [W3ID](/docs/W3DS%20Basics/W3ID) — The broader identifier system +- [eVault](/docs/Infrastructure/eVault) — Where binding documents are stored +- [Ontology](/docs/Infrastructure/Ontology) — Schema definitions including binding-document schema diff --git a/docs/docs/W3DS Basics/Links.md b/docs/docs/W3DS Basics/Links.md index 5ad9594d4..c1f69d0f8 100644 --- a/docs/docs/W3DS Basics/Links.md +++ b/docs/docs/W3DS Basics/Links.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 6 --- # Links diff --git a/docs/docs/W3DS Basics/W3ID.md b/docs/docs/W3DS Basics/W3ID.md index 388b7a372..6285e312c 100644 --- a/docs/docs/W3DS Basics/W3ID.md +++ b/docs/docs/W3DS Basics/W3ID.md @@ -12,16 +12,16 @@ In W3DS, every user, group, eVault, and many objects are identified by a **W3ID* ### Key Concepts -- **W3ID**: The primary identifier for entities in the ecosystem; UUID-based (see [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122)). -- **eName**: A **universally resolvable** W3ID. Resolving an eName via the Registry yields the eVault (or controller) URL for that identity. -- **Global vs local**: Global IDs (e.g. `@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a`) are the primary persistent identity. Local IDs (e.g. `@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a/f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7`) refer to an object *within* an eVault: the part after the slash is the object UUID in the context of the eVault identified by the part before the slash. +- **W3ID**: The primary identifier for entities in the ecosystem; UUID-based (see [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122)). Global W3IDs (eNames) start with `@`, local IDs are plain UUIDs. +- **eName**: A **universally resolvable** W3ID that is registered in the Registry. Resolving an eName via the Registry yields the eVault (or controller) URL for that identity. Format: `@`. +- **Global vs local**: Global IDs / eNames (e.g. `@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a`) are the primary persistent identity. Local IDs (e.g. `f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7`) refer to an object *within* an eVault. ## W3ID Format The W3ID URI format is: -- **Global**: `@` (case insensitive). The number and positioning of dashes follow RFC 4122. Example: `@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a` -- **Local**: `@/` — the object `object-UUID` at the eVault (or owner) `eVault-UUID`. Example: `@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a/f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7` +- **Global (eName)**: `@` (case insensitive). The number and positioning of dashes follow RFC 4122. Example: `@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a` +- **Local**: `` — a UUID referring to an object within an eVault. Example: `f2a6743e-8d5b-43bc-a9f0-1c7a3b9e90d7` The UUID namespace has range 2^122, which is far larger than the expected number of identities (e.g. 10^22), so collision risk is negligible. diff --git a/docs/docs/W3DS Basics/eName.md b/docs/docs/W3DS Basics/eName.md new file mode 100644 index 000000000..4a6a4931a --- /dev/null +++ b/docs/docs/W3DS Basics/eName.md @@ -0,0 +1,73 @@ +--- +sidebar_position: 4 +--- + +# eName + +An **eName** is a globally unique identifier that is a subset of [W3ID](/docs/W3DS%20Basics/W3ID). It is registered in the [Registry](/docs/Infrastructure/Registry) and can be resolved to access information about the identifier, its owner/controller, and the resources it references. + +## Overview + +While all W3IDs are UUID-based and globally unique, an **eName** is specifically registered in the Registry, making it resolvable to a service endpoint (typically an eVault). This resolution allows: + +- **Information about the identifier itself** — metadata such as when it was created, its type, and current status +- **Information about the owner/controller** — who controls the eName, their public keys, and verification credentials +- **The resource it references** — the actual data or service (e.g., an eVault) associated with the eName + +## eName Characteristics + +```mermaid +graph TB + W3ID["W3ID (@UUID)"] + eName["eName (@UUID)"] + Registry["Registry
Resolution"] + Info["Identifier
Metadata"] + Owner["Owner/Controller
Info"] + Resource["Referenced
Resource"] + + W3ID --> eName + eName --> Registry + eName --> Info + eName --> Owner + eName --> Resource + + style eName fill:#e1f5ff,color:#000000 + style Registry fill:#fff4e1,color:#000000 +``` + +### Key Characteristics + +- **Globally Unique**: eNames use UUID v5 namespaces, ensuring uniqueness across the entire ecosystem +- **Universally Resolvable**: Through the Registry, any eName can be resolved to its service endpoint +- **Owner-Bound**: Each eName is associated with an owner (person, organization, or entity) who controls it +- **Key-Bound**: eNames can be bound to cryptographic public keys for signing and authentication +- **Persistent**: Once registered, an eName remains valid (subject to Registry policies) + +## eName vs W3ID + +| Aspect | W3ID | eName | +|--------|----------------|-------| +| Format | `@` | `@` | +| Globally unique via Registry | No | Yes | +| Resolvable to service | No | Yes | +| Used in X-ENAME header | No | Yes (primary use) | + +## Usage in eVault + +Every eVault is identified by an eName. When making API calls to eVault, the `X-ENAME` header must contain the eName of the vault owner: + +```http +X-ENAME: @e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a +``` + +The eName is used for: +- **Access Control**: Determining which vault data can be accessed +- **Data Isolation**: Ensuring users can only access their own data +- **Resolution**: Mapping the eName to the correct eVault service endpoint + +## Related + +- [W3ID](/docs/W3DS%20Basics/W3ID) — The full identifier system +- [Binding Documents](/docs/W3DS%20Basics/Binding-Documents) — Documents that tie users to their eNames +- [Registry](/docs/Infrastructure/Registry) — Service for resolving eNames +- [eVault](/docs/Infrastructure/eVault) — The storage system identified by eNames diff --git a/docs/docs/W3DS Basics/glossary.md b/docs/docs/W3DS Basics/glossary.md index fdcd8a790..60bb697ef 100644 --- a/docs/docs/W3DS Basics/glossary.md +++ b/docs/docs/W3DS Basics/glossary.md @@ -204,7 +204,7 @@ A standardized data model format for [credentials](#credential) that can be cryp A globally or locally unique, persistent identifier for people, [eVaults](#evault), [Groups](#group), [Post-Platforms](#post-platform-and-or-service), docs, or triples. It enables verifiable, persistent, and decentralized digital identity. -The Web 3.0 Identifier format is `@:`, e.g. `@50e8400-e29b-41d4-a716-446655440000`. +The Web 3.0 Identifier format is `@` for global identifiers (eNames), e.g. `@50e8400-e29b-41d4-a716-446655440000`. Local identifiers are plain UUIDs. See [W3ID](/docs/W3DS%20Basics/W3ID) for format, resolution, and usage. diff --git a/infrastructure/dev-sandbox/src/routes/+page.svelte b/infrastructure/dev-sandbox/src/routes/+page.svelte index 28d67b177..ee723b6c0 100644 --- a/infrastructure/dev-sandbox/src/routes/+page.svelte +++ b/infrastructure/dev-sandbox/src/routes/+page.svelte @@ -251,11 +251,21 @@ function toggleExpand(id: string) { expandedIds = next; } +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\//g, "/"); +} + function highlightJson(json: string): string { - return json.replace( - /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, + return escapeHtml(json).replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\&])*"(\s*:)?|true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, (match) => { - if (/^"/.test(match)) { + if (/^"/.test(match)) { if (/:$/.test(match)) { const key = match.slice(0, -1); return `${key}:`; @@ -930,8 +940,10 @@ async function doSign() { {#if pageLoading} Loading… - {:else} + {:else if envelopes.length > 0} {pageOffset + 1}–{pageOffset + envelopes.length} of {totalCount} + {:else} + 0 of {totalCount} {/if}
diff --git a/infrastructure/evault-core/GRAPHQL_TEST_POCS.md b/infrastructure/evault-core/GRAPHQL_TEST_POCS.md deleted file mode 100644 index de91b9b79..000000000 --- a/infrastructure/evault-core/GRAPHQL_TEST_POCS.md +++ /dev/null @@ -1,303 +0,0 @@ -# GraphQL Authorization Test POCs - -These are proof-of-concept curl commands to test GraphQL authorization. After the fix, all operations **REQUIRE**: -- A valid Bearer token in the Authorization header (MANDATORY) - -**EXCEPTION:** `storeMetaEnvelope` mutation only requires X-ENAME header (Bearer token is optional but allowed). - -**IMPORTANT:** For all other operations, X-ENAME header alone is NOT sufficient for authentication. A Bearer token is ALWAYS required. - -## Server Configuration -Replace `http://64.227.64.55:4000` with your actual server URL. - -## Test eName -Replace `@911253cf-885e-5a71-b0e4-c9df4cb6cd40` with a valid eName for your tests (used for data filtering, not authentication). - -## Test Token -Replace `YOUR_BEARER_TOKEN` with a valid JWT token from your registry. This is REQUIRED for all operations. - ---- - -## QUERIES - -### 1. getAllEnvelopes - -**Without Authorization (Should FAIL):** -```bash -echo '{ "query": "{ getAllEnvelopes { id ontology value } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With ONLY eName (Should FAIL - eName alone is NOT sufficient):** -```bash -echo '{ "query": "{ getAllEnvelopes { id ontology value } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "X-ENAME: @911253cf-885e-5a71-b0e4-c9df4cb6cd40" \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With Bearer Token (Should WORK - Bearer token is REQUIRED):** -```bash -echo '{ "query": "{ getAllEnvelopes { id ontology value } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Authorization: Bearer YOUR_BEARER_TOKEN" \ ---header "Content-Type: application/json" \ ---data @- -``` - ---- - -### 2. getMetaEnvelopeById - -**Without Authorization (Should FAIL):** -```bash -echo '{ "query": "{ getMetaEnvelopeById(id: \"test-envelope-id\") { id ontology envelopes { id value } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With ONLY eName (Should FAIL - eName alone is NOT sufficient):** -```bash -echo '{ "query": "{ getMetaEnvelopeById(id: \"test-envelope-id\") { id ontology envelopes { id value } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "X-ENAME: @911253cf-885e-5a71-b0e4-c9df4cb6cd40" \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With Bearer Token (Should WORK - Bearer token is REQUIRED):** -```bash -echo '{ "query": "{ getMetaEnvelopeById(id: \"test-envelope-id\") { id ontology envelopes { id value } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Authorization: Bearer YOUR_BEARER_TOKEN" \ ---header "Content-Type: application/json" \ ---data @- -``` - ---- - -### 3. findMetaEnvelopesByOntology - -**Without Authorization (Should FAIL):** -```bash -echo '{ "query": "{ findMetaEnvelopesByOntology(ontology: \"TestOntology\") { id ontology envelopes { id value } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With ONLY eName (Should FAIL - eName alone is NOT sufficient):** -```bash -echo '{ "query": "{ findMetaEnvelopesByOntology(ontology: \"TestOntology\") { id ontology envelopes { id value } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "X-ENAME: @911253cf-885e-5a71-b0e4-c9df4cb6cd40" \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With Bearer Token (Should WORK - Bearer token is REQUIRED):** -```bash -echo '{ "query": "{ findMetaEnvelopesByOntology(ontology: \"TestOntology\") { id ontology envelopes { id value } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Authorization: Bearer YOUR_BEARER_TOKEN" \ ---header "Content-Type: application/json" \ ---data @- -``` - ---- - -### 4. searchMetaEnvelopes - -**Without Authorization (Should FAIL):** -```bash -echo '{ "query": "{ searchMetaEnvelopes(ontology: \"TestOntology\", term: \"search-term\") { id ontology envelopes { id value } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With ONLY eName (Should FAIL - eName alone is NOT sufficient):** -```bash -echo '{ "query": "{ searchMetaEnvelopes(ontology: \"TestOntology\", term: \"search-term\") { id ontology envelopes { id value } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "X-ENAME: @911253cf-885e-5a71-b0e4-c9df4cb6cd40" \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With Bearer Token (Should WORK - Bearer token is REQUIRED):** -```bash -echo '{ "query": "{ searchMetaEnvelopes(ontology: \"TestOntology\", term: \"search-term\") { id ontology envelopes { id value } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Authorization: Bearer YOUR_BEARER_TOKEN" \ ---header "Content-Type: application/json" \ ---data @- -``` - ---- - -## MUTATIONS - -### 5. storeMetaEnvelope - -**SPECIAL CASE: storeMetaEnvelope only requires X-ENAME (no Bearer token needed)** - -**Without X-ENAME (Should FAIL):** -```bash -echo '{ "query": "mutation { storeMetaEnvelope(input: { ontology: \"TestOntology\", payload: { test: \"data\" }, acl: [\"user-123\"] }) { metaEnvelope { id ontology } envelopes { id } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With X-ENAME (Should WORK - Bearer token NOT required for storeMetaEnvelope):** -```bash -echo '{ "query": "mutation { storeMetaEnvelope(input: { ontology: \"TestOntology\", payload: { test: \"data\" }, acl: [\"user-123\"] }) { metaEnvelope { id ontology } envelopes { id } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "X-ENAME: @911253cf-885e-5a71-b0e4-c9df4cb6cd40" \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With Bearer Token (Should WORK - Bearer token is optional but allowed):** -```bash -echo '{ "query": "mutation { storeMetaEnvelope(input: { ontology: \"TestOntology\", payload: { test: \"data\" }, acl: [\"user-123\"] }) { metaEnvelope { id ontology } envelopes { id } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Authorization: Bearer YOUR_BEARER_TOKEN" \ ---header "X-ENAME: @911253cf-885e-5a71-b0e4-c9df4cb6cd40" \ ---header "Content-Type: application/json" \ ---data @- -``` - ---- - -### 6. updateMetaEnvelopeById - -**Without Authorization (Should FAIL):** -```bash -echo '{ "query": "mutation { updateMetaEnvelopeById(id: \"test-envelope-id\", input: { ontology: \"TestOntology\", payload: { test: \"updated-data\" }, acl: [\"user-123\"] }) { metaEnvelope { id ontology } envelopes { id } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With ONLY eName (Should FAIL - eName alone is NOT sufficient):** -```bash -echo '{ "query": "mutation { updateMetaEnvelopeById(id: \"test-envelope-id\", input: { ontology: \"TestOntology\", payload: { test: \"updated-data\" }, acl: [\"user-123\"] }) { metaEnvelope { id ontology } envelopes { id } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "X-ENAME: @911253cf-885e-5a71-b0e4-c9df4cb6cd40" \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With Bearer Token (Should WORK - Bearer token is REQUIRED):** -```bash -echo '{ "query": "mutation { updateMetaEnvelopeById(id: \"test-envelope-id\", input: { ontology: \"TestOntology\", payload: { test: \"updated-data\" }, acl: [\"user-123\"] }) { metaEnvelope { id ontology } envelopes { id } } }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Authorization: Bearer YOUR_BEARER_TOKEN" \ ---header "Content-Type: application/json" \ ---data @- -``` - ---- - -### 7. deleteMetaEnvelope - -**Without Authorization (Should FAIL):** -```bash -echo '{ "query": "mutation { deleteMetaEnvelope(id: \"test-envelope-id\") }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With ONLY eName (Should FAIL - eName alone is NOT sufficient):** -```bash -echo '{ "query": "mutation { deleteMetaEnvelope(id: \"test-envelope-id\") }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "X-ENAME: @911253cf-885e-5a71-b0e4-c9df4cb6cd40" \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With Bearer Token (Should WORK - Bearer token is REQUIRED):** -```bash -echo '{ "query": "mutation { deleteMetaEnvelope(id: \"test-envelope-id\") }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Authorization: Bearer YOUR_BEARER_TOKEN" \ ---header "Content-Type: application/json" \ ---data @- -``` - ---- - -### 8. updateEnvelopeValue - -**Without Authorization (Should FAIL):** -```bash -echo '{ "query": "mutation { updateEnvelopeValue(envelopeId: \"test-envelope-id\", newValue: { updated: \"value\" }) }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With ONLY eName (Should FAIL - eName alone is NOT sufficient):** -```bash -echo '{ "query": "mutation { updateEnvelopeValue(envelopeId: \"test-envelope-id\", newValue: { updated: \"value\" }) }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "X-ENAME: @911253cf-885e-5a71-b0e4-c9df4cb6cd40" \ ---header "Content-Type: application/json" \ ---data @- -``` - -**With Bearer Token (Should WORK - Bearer token is REQUIRED):** -```bash -echo '{ "query": "mutation { updateEnvelopeValue(envelopeId: \"test-envelope-id\", newValue: { updated: \"value\" }) }" }' | tr -d '\n' | curl --silent \ -http://64.227.64.55:4000/graphql \ ---header "Authorization: Bearer YOUR_BEARER_TOKEN" \ ---header "Content-Type: application/json" \ ---data @- -``` - ---- - -## Expected Behavior After Fix - -### Before Fix (VULNERABLE): -- Operations without authorization would execute and return data -- `getAllEnvelopes` could be called without any auth headers -- X-ENAME alone was incorrectly accepted as authentication - -### After Fix (SECURE): -- All operations **REQUIRE** a valid Bearer token in the Authorization header -- **EXCEPTION:** `storeMetaEnvelope` mutation only requires X-ENAME (Bearer token is optional) -- Operations without valid Bearer token (except storeMetaEnvelope) will return an error: - ```json - { - "errors": [{ - "message": "Authentication required: A valid Bearer token in Authorization header is required" - }] - } - ``` -- X-ENAME alone is **NOT sufficient** for most operations (except storeMetaEnvelope) -- Operations with valid Bearer token will work -- X-ENAME can still be provided for data filtering purposes, but Bearer token is mandatory (except for storeMetaEnvelope) - -## Testing Checklist - -- [ ] Test all queries without auth (should all fail) -- [ ] Test all queries with ONLY eName (should all fail - eName alone is NOT sufficient) -- [ ] Test all queries with Bearer token (should all work - Bearer token is REQUIRED) -- [ ] Test storeMetaEnvelope without X-ENAME (should fail) -- [ ] Test storeMetaEnvelope with ONLY X-ENAME (should work - special case) -- [ ] Test storeMetaEnvelope with Bearer token (should work - optional) -- [ ] Test all other mutations without auth (should all fail) -- [ ] Test all other mutations with ONLY eName (should all fail - eName alone is NOT sufficient) -- [ ] Test all other mutations with Bearer token (should all work - Bearer token is REQUIRED) -- [ ] Test with invalid Bearer token (should fail) -- [ ] Test with missing Bearer token (should fail) - diff --git a/infrastructure/evault-core/src/core/db/schema.ts b/infrastructure/evault-core/src/core/db/schema.ts index e50c6d38a..a5f09f78b 100644 --- a/infrastructure/evault-core/src/core/db/schema.ts +++ b/infrastructure/evault-core/src/core/db/schema.ts @@ -1,4 +1,4 @@ -import { JSONSchema7 } from "json-schema"; +import type { JSONSchema7 } from "json-schema"; export type SchemaType = { schema: JSONSchema7; @@ -63,6 +63,13 @@ export function serializeValue(value: any): { value: any; type: string } { serializedValue = value.toISOString(); } else if (type === SchemaTypes.object) { serializedValue = JSON.stringify(value); + } else if (type === SchemaTypes.array) { + const hasObjects = value.some( + (item: any) => typeof item === "object" && item !== null, + ); + if (hasObjects) { + serializedValue = JSON.stringify(value); + } } return { @@ -81,5 +88,17 @@ export function deserializeValue(value: any, type: string): any { return JSON.parse(value); } + if (type === "array") { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + // Not JSON, return as-is + } + return schemaType.deserialize(value); + } + return schemaType.deserialize(value); } diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index 608c99fe6..da1d0451b 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -1,21 +1,22 @@ -import { createSchema, createYoga, YogaInitialContext } from "graphql-yoga"; -import { createServer, Server } from "http"; -import { typeDefs } from "./typedefs"; -import { renderVoyagerPage } from "graphql-voyager/middleware"; +import { Server } from "http"; +import axios from "axios"; +import type { GraphQLSchema } from "graphql"; +import { createSchema, createYoga } from "graphql-yoga"; import { getJWTHeader } from "w3id"; -import { DbService } from "../db/db.service"; +import { BindingDocumentService, BINDING_DOCUMENT_ONTOLOGY } from "../../services/BindingDocumentService"; +import type { DbService } from "../db/db.service"; import { computeEnvelopeHash, computeEnvelopeHashForDelete, } from "../db/envelope-hash"; -import { VaultAccessGuard, VaultContext } from "./vault-access-guard"; -import { GraphQLSchema } from "graphql"; import { exampleQueries } from "./examples/examples"; -import axios from "axios"; +import { typeDefs } from "./typedefs"; +import { VaultAccessGuard, type VaultContext } from "./vault-access-guard"; export class GraphQLServer { private db: DbService; private accessGuard: VaultAccessGuard; + private bindingDocumentService: BindingDocumentService; private schema: GraphQLSchema = createSchema({ typeDefs, resolvers: {}, @@ -25,10 +26,17 @@ export class GraphQLServer { private evaultW3ID: string | null; private evaultInstance: any; // Reference to the eVault instance - constructor(db: DbService, evaultPublicKey?: string | null, evaultW3ID?: string | null, evaultInstance?: any) { + constructor( + db: DbService, + evaultPublicKey?: string | null, + evaultW3ID?: string | null, + evaultInstance?: any, + ) { this.db = db; this.accessGuard = new VaultAccessGuard(db); - this.evaultPublicKey = evaultPublicKey || process.env.EVAULT_PUBLIC_KEY || null; + this.bindingDocumentService = new BindingDocumentService(db); + this.evaultPublicKey = + evaultPublicKey || process.env.EVAULT_PUBLIC_KEY || null; this.evaultW3ID = evaultW3ID || process.env.W3ID || null; this.evaultInstance = evaultInstance; } @@ -49,7 +57,10 @@ export class GraphQLServer { } const response = await axios.get( - new URL("/platforms", process.env.PUBLIC_REGISTRY_URL).toString() + new URL( + "/platforms", + process.env.PUBLIC_REGISTRY_URL, + ).toString(), ); return response.data; } catch (error) { @@ -65,7 +76,7 @@ export class GraphQLServer { */ private async deliverWebhooks( requestingPlatform: string | null, - webhookPayload: any + webhookPayload: any, ): Promise { try { const activePlatforms = await this.getActivePlatforms(); @@ -76,16 +87,22 @@ export class GraphQLServer { try { // Normalize URLs for comparison - const normalizedPlatformUrl = new URL(platformUrl).toString(); + const normalizedPlatformUrl = new URL( + platformUrl, + ).toString(); const normalizedRequestingPlatform = new URL( - requestingPlatform + requestingPlatform, ).toString(); - return normalizedPlatformUrl !== normalizedRequestingPlatform; + return ( + normalizedPlatformUrl !== normalizedRequestingPlatform + ); } catch (error) { // If requestingPlatform is not a valid URL, don't filter it out // (treat it as a different platform identifier) - console.warn(`Invalid platform URL in token: ${requestingPlatform}`); + console.warn( + `Invalid platform URL in token: ${requestingPlatform}`, + ); return true; } }); @@ -97,7 +114,7 @@ export class GraphQLServer { try { const webhookUrl = new URL( "/api/webhook", - platformUrl + platformUrl, ).toString(); await axios.post(webhookUrl, webhookPayload, { headers: { @@ -106,15 +123,15 @@ export class GraphQLServer { timeout: 5000, // 5 second timeout }); console.log( - `Webhook delivered successfully to ${platformUrl}` + `Webhook delivered successfully to ${platformUrl}`, ); } catch (error) { console.error( `Failed to deliver webhook to ${platformUrl}:`, - error + error, ); } - } + }, ); await Promise.allSettled(webhookPromises); @@ -155,7 +172,69 @@ export class GraphQLServer { throw new Error("X-ENAME header is required"); } return this.db.findMetaEnvelopeById(id, context.eName); - } + }, + ), + + bindingDocument: this.accessGuard.middleware( + async ( + _: any, + { id }: { id: string }, + context: VaultContext, + ) => { + if (!context.eName) { + throw new Error("X-ENAME header is required"); + } + return this.bindingDocumentService.getBindingDocument( + id, + context.eName, + ); + }, + ), + + bindingDocuments: this.accessGuard.middleware( + async ( + _: any, + args: { + type?: string; + first?: number; + after?: string; + last?: number; + before?: string; + }, + context: VaultContext, + ) => { + if (!context.eName) { + throw new Error("X-ENAME header is required"); + } + const VALID_BINDING_DOCUMENT_TYPES = [ + "id_document", + "photograph", + "social_connection", + "self", + ] as const; + type ValidType = + (typeof VALID_BINDING_DOCUMENT_TYPES)[number]; + if ( + args.type !== undefined && + !VALID_BINDING_DOCUMENT_TYPES.includes( + args.type as ValidType, + ) + ) { + throw new Error( + `Invalid binding document type: "${args.type}". Must be one of: ${VALID_BINDING_DOCUMENT_TYPES.join(", ")}`, + ); + } + return this.bindingDocumentService.findBindingDocuments( + context.eName, + { + type: args.type as ValidType | undefined, + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + ); + }, ), // Retrieve MetaEnvelopes with pagination and filtering @@ -177,19 +256,22 @@ export class GraphQLServer { last?: number; before?: string; }, - context: VaultContext + context: VaultContext, ) => { if (!context.eName) { throw new Error("X-ENAME header is required"); } - return this.db.findMetaEnvelopesPaginated(context.eName, { - filter: args.filter, - first: args.first, - after: args.after, - last: args.last, - before: args.before, - }); - } + return this.db.findMetaEnvelopesPaginated( + context.eName, + { + filter: args.filter, + first: args.first, + after: args.after, + last: args.last, + before: args.before, + }, + ); + }, ), // ============================================================ @@ -202,21 +284,28 @@ export class GraphQLServer { throw new Error("X-ENAME header is required"); } return this.db.findMetaEnvelopeById(id, context.eName); - } + }, ), findMetaEnvelopesByOntology: this.accessGuard.middleware( - (_: any, { ontology }: { ontology: string }, context: VaultContext) => { + ( + _: any, + { ontology }: { ontology: string }, + context: VaultContext, + ) => { if (!context.eName) { throw new Error("X-ENAME header is required"); } - return this.db.findMetaEnvelopesByOntology(ontology, context.eName); - } + return this.db.findMetaEnvelopesByOntology( + ontology, + context.eName, + ); + }, ), searchMetaEnvelopes: this.accessGuard.middleware( ( _: any, { ontology, term }: { ontology: string; term: string }, - context: VaultContext + context: VaultContext, ) => { if (!context.eName) { throw new Error("X-ENAME header is required"); @@ -224,16 +313,18 @@ export class GraphQLServer { return this.db.findMetaEnvelopesBySearchTerm( ontology, term, - context.eName + context.eName, ); - } + }, + ), + getAllEnvelopes: this.accessGuard.middleware( + (_: any, __: any, context: VaultContext) => { + if (!context.eName) { + throw new Error("X-ENAME header is required"); + } + return this.db.getAllEnvelopes(context.eName); + }, ), - getAllEnvelopes: this.accessGuard.middleware((_: any, __: any, context: VaultContext) => { - if (!context.eName) { - throw new Error("X-ENAME header is required"); - } - return this.db.getAllEnvelopes(context.eName); - }), }, Mutation: { @@ -254,12 +345,17 @@ export class GraphQLServer { acl: string[]; }; }, - context: VaultContext + context: VaultContext, ) => { if (!context.eName) { return { metaEnvelope: null, - errors: [{ message: "X-ENAME header is required", code: "MISSING_ENAME" }], + errors: [ + { + message: "X-ENAME header is required", + code: "MISSING_ENAME", + }, + ], }; } @@ -271,7 +367,7 @@ export class GraphQLServer { acl: input.acl, }, input.acl, - context.eName + context.eName, ); // Build the full metaEnvelope response @@ -283,7 +379,8 @@ export class GraphQLServer { }; // Deliver webhooks for create operation - const requestingPlatform = context.tokenPayload?.platform || null; + const requestingPlatform = + context.tokenPayload?.platform || null; const webhookPayload = { id: result.metaEnvelope.id, w3id: context.eName, @@ -294,11 +391,15 @@ export class GraphQLServer { // Delayed webhook delivery to prevent ping-pong setTimeout(() => { - this.deliverWebhooks(requestingPlatform, webhookPayload); + this.deliverWebhooks( + requestingPlatform, + webhookPayload, + ); }, 3_000); // Log envelope operation best-effort - const platform = context.tokenPayload?.platform ?? null; + const platform = + context.tokenPayload?.platform ?? null; const envelopeHash = computeEnvelopeHash({ id: result.metaEnvelope.id, ontology: input.ontology, @@ -315,7 +416,10 @@ export class GraphQLServer { ontology: input.ontology, }) .catch((err) => - console.error("appendEnvelopeOperationLog (create) failed:", err) + console.error( + "appendEnvelopeOperationLog (create) failed:", + err, + ), ); return { @@ -323,18 +427,24 @@ export class GraphQLServer { errors: [], }; } catch (error) { - console.error("Error in createMetaEnvelope:", error); + console.error( + "Error in createMetaEnvelope:", + error, + ); return { metaEnvelope: null, errors: [ { - message: error instanceof Error ? error.message : "Failed to create MetaEnvelope", + message: + error instanceof Error + ? error.message + : "Failed to create MetaEnvelope", code: "CREATE_FAILED", }, ], }; } - } + }, ), // Update an existing MetaEnvelope with structured payload @@ -352,12 +462,17 @@ export class GraphQLServer { acl: string[]; }; }, - context: VaultContext + context: VaultContext, ) => { if (!context.eName) { return { metaEnvelope: null, - errors: [{ message: "X-ENAME header is required", code: "MISSING_ENAME" }], + errors: [ + { + message: "X-ENAME header is required", + code: "MISSING_ENAME", + }, + ], }; } @@ -370,7 +485,7 @@ export class GraphQLServer { acl: input.acl, }, input.acl, - context.eName + context.eName, ); // Build the full metaEnvelope response @@ -382,7 +497,8 @@ export class GraphQLServer { }; // Deliver webhooks for update operation - const requestingPlatform = context.tokenPayload?.platform || null; + const requestingPlatform = + context.tokenPayload?.platform || null; const webhookPayload = { id, w3id: context.eName, @@ -392,10 +508,14 @@ export class GraphQLServer { }; // Fire and forget webhook delivery - this.deliverWebhooks(requestingPlatform, webhookPayload); + this.deliverWebhooks( + requestingPlatform, + webhookPayload, + ); // Log envelope operation best-effort - const platform = context.tokenPayload?.platform ?? null; + const platform = + context.tokenPayload?.platform ?? null; const envelopeHash = computeEnvelopeHash({ id, ontology: input.ontology, @@ -412,7 +532,10 @@ export class GraphQLServer { ontology: input.ontology, }) .catch((err) => - console.error("appendEnvelopeOperationLog (update) failed:", err) + console.error( + "appendEnvelopeOperationLog (update) failed:", + err, + ), ); return { @@ -420,46 +543,71 @@ export class GraphQLServer { errors: [], }; } catch (error) { - console.error("Error in updateMetaEnvelope:", error); + console.error( + "Error in updateMetaEnvelope:", + error, + ); return { metaEnvelope: null, errors: [ { - message: error instanceof Error ? error.message : "Failed to update MetaEnvelope", + message: + error instanceof Error + ? error.message + : "Failed to update MetaEnvelope", code: "UPDATE_FAILED", }, ], }; } - } + }, ), // Delete a MetaEnvelope with structured result removeMetaEnvelope: this.accessGuard.middleware( - async (_: any, { id }: { id: string }, context: VaultContext) => { + async ( + _: any, + { id }: { id: string }, + context: VaultContext, + ) => { if (!context.eName) { return { deletedId: id, success: false, - errors: [{ message: "X-ENAME header is required", code: "MISSING_ENAME" }], + errors: [ + { + message: "X-ENAME header is required", + code: "MISSING_ENAME", + }, + ], }; } try { - const meta = await this.db.findMetaEnvelopeById(id, context.eName); + const meta = await this.db.findMetaEnvelopeById( + id, + context.eName, + ); if (!meta) { return { deletedId: id, success: false, - errors: [{ message: "MetaEnvelope not found", code: "NOT_FOUND" }], + errors: [ + { + message: "MetaEnvelope not found", + code: "NOT_FOUND", + }, + ], }; } await this.db.deleteMetaEnvelope(id, context.eName); // Log after delete succeeds, best-effort - const platform = context.tokenPayload?.platform ?? null; - const envelopeHash = computeEnvelopeHashForDelete(id); + const platform = + context.tokenPayload?.platform ?? null; + const envelopeHash = + computeEnvelopeHashForDelete(id); this.db .appendEnvelopeOperationLog({ eName: context.eName, @@ -471,7 +619,10 @@ export class GraphQLServer { ontology: meta.ontology, }) .catch((err) => - console.error("appendEnvelopeOperationLog (delete) failed:", err) + console.error( + "appendEnvelopeOperationLog (delete) failed:", + err, + ), ); return { @@ -480,19 +631,25 @@ export class GraphQLServer { errors: [], }; } catch (error) { - console.error("Error in removeMetaEnvelope:", error); + console.error( + "Error in removeMetaEnvelope:", + error, + ); return { deletedId: id, success: false, errors: [ { - message: error instanceof Error ? error.message : "Failed to delete MetaEnvelope", + message: + error instanceof Error + ? error.message + : "Failed to delete MetaEnvelope", code: "DELETE_FAILED", }, ], }; } - } + }, ), // Bulk create MetaEnvelopes (optimized for migrations) @@ -511,41 +668,52 @@ export class GraphQLServer { }>; skipWebhooks?: boolean; }, - context: VaultContext + context: VaultContext, ) => { if (!context.eName) { return { results: [], successCount: 0, errorCount: inputs.length, - errors: [{ message: "X-ENAME header is required", code: "MISSING_ENAME" }], + errors: [ + { + message: "X-ENAME header is required", + code: "MISSING_ENAME", + }, + ], }; } // Check if this is an authorized migration (emover platform with skipWebhooks) - const isEmoverMigration = - skipWebhooks && - context.tokenPayload?.platform === process.env.EMOVER_API_URL; - + const isEmoverMigration = + skipWebhooks && + context.tokenPayload?.platform === + process.env.EMOVER_API_URL; + // Only allow webhook skipping for authorized migration platforms const shouldSkipWebhooks = isEmoverMigration; - const results: Array<{ id: string; success: boolean; error?: string }> = []; + const results: Array<{ + id: string; + success: boolean; + error?: string; + }> = []; let successCount = 0; let errorCount = 0; for (const input of inputs) { try { - const result = await this.db.storeMetaEnvelopeWithId( - { - ontology: input.ontology, - payload: input.payload, - acl: input.acl, - }, - input.acl, - context.eName, - input.id // Preserve ID if provided - ); + const result = + await this.db.storeMetaEnvelopeWithId( + { + ontology: input.ontology, + payload: input.payload, + acl: input.acl, + }, + input.acl, + context.eName, + input.id, // Preserve ID if provided + ); results.push({ id: result.metaEnvelope.id, @@ -555,7 +723,8 @@ export class GraphQLServer { // Deliver webhooks if not skipping if (!shouldSkipWebhooks) { - const requestingPlatform = context.tokenPayload?.platform || null; + const requestingPlatform = + context.tokenPayload?.platform || null; const webhookPayload = { id: result.metaEnvelope.id, w3id: context.eName, @@ -565,13 +734,20 @@ export class GraphQLServer { }; // Fire and forget webhook delivery - this.deliverWebhooks(requestingPlatform, webhookPayload).catch((err) => - console.error("Webhook delivery failed in bulk create:", err) + this.deliverWebhooks( + requestingPlatform, + webhookPayload, + ).catch((err) => + console.error( + "Webhook delivery failed in bulk create:", + err, + ), ); } // Log envelope operation best-effort - const platform = context.tokenPayload?.platform ?? null; + const platform = + context.tokenPayload?.platform ?? null; const envelopeHash = computeEnvelopeHash({ id: result.metaEnvelope.id, ontology: input.ontology, @@ -588,17 +764,25 @@ export class GraphQLServer { ontology: input.ontology, }) .catch((err) => - console.error("appendEnvelopeOperationLog (bulk create) failed:", err) + console.error( + "appendEnvelopeOperationLog (bulk create) failed:", + err, + ), ); } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Failed to create MetaEnvelope"; + const errorMessage = + error instanceof Error + ? error.message + : "Failed to create MetaEnvelope"; results.push({ id: input.id || "unknown", success: false, error: errorMessage, }); errorCount++; - console.error(`Error creating envelope in bulk: ${errorMessage}`); + console.error( + `Error creating envelope in bulk: ${errorMessage}`, + ); } } @@ -608,7 +792,260 @@ export class GraphQLServer { errorCount, errors: [], }; - } + }, + ), + + // ============================================================ + // Binding Document Mutations + // ============================================================ + + createBindingDocument: this.accessGuard.middleware( + async ( + _: any, + { + input, + }: { + input: { + subject: string; + type: string; + data: any; + ownerSignature: { + signer: string; + signature: string; + timestamp: string; + }; + }; + }, + context: VaultContext, + ) => { + if (!context.eName) { + return { + bindingDocument: null, + metaEnvelopeId: null, + errors: [ + { + message: "X-ENAME header is required", + code: "MISSING_ENAME", + }, + ], + }; + } + + const VALID_BINDING_DOCUMENT_TYPES = [ + "id_document", + "photograph", + "social_connection", + "self", + ] as const; + type ValidType = + (typeof VALID_BINDING_DOCUMENT_TYPES)[number]; + if ( + !VALID_BINDING_DOCUMENT_TYPES.includes( + input.type as ValidType, + ) + ) { + return { + bindingDocument: null, + metaEnvelopeId: null, + errors: [ + { + message: `Invalid binding document type: "${input.type}". Must be one of: ${VALID_BINDING_DOCUMENT_TYPES.join(", ")}`, + code: "INVALID_TYPE", + }, + ], + }; + } + + try { + const result = + await this.bindingDocumentService.createBindingDocument( + { + subject: input.subject, + type: input.type as ValidType, + data: input.data, + ownerSignature: input.ownerSignature, + }, + context.eName, + ); + + const metaEnvelopeId = result.id; + const platform = + context.tokenPayload?.platform ?? null; + const envelopeHash = computeEnvelopeHash({ + id: metaEnvelopeId, + ontology: + BINDING_DOCUMENT_ONTOLOGY, + payload: result.bindingDocument as unknown as Record, + }); + + this.db + .appendEnvelopeOperationLog({ + eName: context.eName, + metaEnvelopeId, + envelopeHash, + operation: "create", + platform, + timestamp: new Date().toISOString(), + ontology: + BINDING_DOCUMENT_ONTOLOGY, + }) + .catch((err) => + console.error( + "appendEnvelopeOperationLog (createBindingDocument) failed:", + err, + ), + ); + + const requestingPlatform = + context.tokenPayload?.platform || null; + const webhookPayload = { + id: metaEnvelopeId, + w3id: context.eName, + evaultPublicKey: this.evaultPublicKey, + data: result.bindingDocument, + schemaId: + BINDING_DOCUMENT_ONTOLOGY, + }; + setTimeout(() => { + this.deliverWebhooks( + requestingPlatform, + webhookPayload, + ); + }, 3_000); + + return { + bindingDocument: result.bindingDocument, + metaEnvelopeId, + errors: [], + }; + } catch (error) { + console.error( + "Error in createBindingDocument:", + error, + ); + return { + bindingDocument: null, + metaEnvelopeId: null, + errors: [ + { + message: + error instanceof Error + ? error.message + : "Failed to create binding document", + code: "CREATE_FAILED", + }, + ], + }; + } + }, + ), + + createBindingDocumentSignature: this.accessGuard.middleware( + async ( + _: any, + { + input, + }: { + input: { + bindingDocumentId: string; + signature: { + signer: string; + signature: string; + timestamp: string; + }; + }; + }, + context: VaultContext, + ) => { + if (!context.eName) { + return { + bindingDocument: null, + errors: [ + { + message: "X-ENAME header is required", + code: "MISSING_ENAME", + }, + ], + }; + } + + try { + const result = + await this.bindingDocumentService.addCounterpartySignature( + { + metaEnvelopeId: input.bindingDocumentId, + signature: input.signature, + }, + context.eName, + ); + + const platform = + context.tokenPayload?.platform ?? null; + const envelopeHash = computeEnvelopeHash({ + id: input.bindingDocumentId, + ontology: + BINDING_DOCUMENT_ONTOLOGY, + payload: result as unknown as Record, + }); + + this.db + .appendEnvelopeOperationLog({ + eName: context.eName, + metaEnvelopeId: input.bindingDocumentId, + envelopeHash, + operation: "update", + platform, + timestamp: new Date().toISOString(), + ontology: + BINDING_DOCUMENT_ONTOLOGY, + }) + .catch((err) => + console.error( + "appendEnvelopeOperationLog (createBindingDocumentSignature) failed:", + err, + ), + ); + + const requestingPlatform = + context.tokenPayload?.platform || null; + const webhookPayload = { + id: input.bindingDocumentId, + w3id: context.eName, + evaultPublicKey: this.evaultPublicKey, + data: result, + schemaId: + BINDING_DOCUMENT_ONTOLOGY, + }; + setTimeout(() => { + this.deliverWebhooks( + requestingPlatform, + webhookPayload, + ); + }, 3_000); + + return { + bindingDocument: result, + errors: [], + }; + } catch (error) { + console.error( + "Error in createBindingDocumentSignature:", + error, + ); + return { + bindingDocument: null, + errors: [ + { + message: + error instanceof Error + ? error.message + : "Failed to add signature", + code: "ADD_SIGNATURE_FAILED", + }, + ], + }; + } + }, ), // ============================================================ @@ -627,7 +1064,7 @@ export class GraphQLServer { acl: string[]; }; }, - context: VaultContext + context: VaultContext, ) => { if (!context.eName) { throw new Error("X-ENAME header is required"); @@ -639,7 +1076,7 @@ export class GraphQLServer { acl: input.acl, }, input.acl, - context.eName + context.eName, ); // Add parsed field to metaEnvelope for GraphQL response @@ -672,13 +1109,12 @@ export class GraphQLServer { setTimeout(() => { this.deliverWebhooks( requestingPlatform, - webhookPayload + webhookPayload, ); }, 3_000); // Log envelope operation best-effort (do not fail mutation) - const platform = - context.tokenPayload?.platform ?? null; + const platform = context.tokenPayload?.platform ?? null; const metaEnvelopeId = result.metaEnvelope.id; const envelopeHash = computeEnvelopeHash({ id: metaEnvelopeId, @@ -706,7 +1142,7 @@ export class GraphQLServer { ...result, metaEnvelope: metaEnvelopeWithParsed, }; - } + }, ), updateMetaEnvelopeById: this.accessGuard.middleware( async ( @@ -722,7 +1158,7 @@ export class GraphQLServer { acl: string[]; }; }, - context: VaultContext + context: VaultContext, ) => { if (!context.eName) { throw new Error("X-ENAME header is required"); @@ -736,7 +1172,7 @@ export class GraphQLServer { acl: input.acl, }, input.acl, - context.eName + context.eName, ); // Deliver webhooks for update operation @@ -753,7 +1189,7 @@ export class GraphQLServer { // Fire and forget webhook delivery this.deliverWebhooks( requestingPlatform, - webhookPayload + webhookPayload, ); // Log envelope operation best-effort (do not fail mutation) @@ -785,23 +1221,28 @@ export class GraphQLServer { } catch (error) { console.error( "Error in updateMetaEnvelopeById:", - error + error, ); throw error; } - } + }, ), deleteMetaEnvelope: this.accessGuard.middleware( - async (_: any, { id }: { id: string }, context: VaultContext) => { + async ( + _: any, + { id }: { id: string }, + context: VaultContext, + ) => { if (!context.eName) { throw new Error("X-ENAME header is required"); } - const meta = - await this.db.findMetaEnvelopeById(id, context.eName); + const meta = await this.db.findMetaEnvelopeById( + id, + context.eName, + ); await this.db.deleteMetaEnvelope(id, context.eName); // Log after delete succeeds, best-effort - const platform = - context.tokenPayload?.platform ?? null; + const platform = context.tokenPayload?.platform ?? null; const envelopeHash = computeEnvelopeHashForDelete(id); this.db .appendEnvelopeOperationLog({ @@ -820,7 +1261,7 @@ export class GraphQLServer { ), ); return true; - } + }, ), updateEnvelopeValue: this.accessGuard.middleware( async ( @@ -829,7 +1270,7 @@ export class GraphQLServer { envelopeId, newValue, }: { envelopeId: string; newValue: any }, - context: VaultContext + context: VaultContext, ) => { if (!context.eName) { throw new Error("X-ENAME header is required"); @@ -870,7 +1311,7 @@ export class GraphQLServer { ); } return true; - } + }, ), }, }; @@ -889,7 +1330,10 @@ export class GraphQLServer { context: async ({ request }) => { const authHeader = request.headers.get("authorization") ?? ""; const token = authHeader.replace("Bearer ", ""); - const eName = request.headers.get("x-ename") ?? request.headers.get("X-ENAME") ?? null; + const eName = + request.headers.get("x-ename") ?? + request.headers.get("X-ENAME") ?? + null; if (token) { try { diff --git a/infrastructure/evault-core/src/core/protocol/typedefs.ts b/infrastructure/evault-core/src/core/protocol/typedefs.ts index 50260d03e..99f8b6d78 100644 --- a/infrastructure/evault-core/src/core/protocol/typedefs.ts +++ b/infrastructure/evault-core/src/core/protocol/typedefs.ts @@ -137,6 +137,41 @@ export const typeDefs = /* GraphQL */ ` errors: [UserError!] } + # ============================================================================ + # Binding Document Types + # ============================================================================ + + enum BindingDocumentType { + id_document + photograph + social_connection + self + } + + type BindingDocumentSignature { + signer: String! + signature: String! + timestamp: String! + } + + type BindingDocument { + subject: String! + type: BindingDocumentType! + data: JSON! + signatures: [BindingDocumentSignature!]! + } + + type CreateBindingDocumentPayload { + bindingDocument: BindingDocument + metaEnvelopeId: String + errors: [UserError!] + } + + type CreateBindingDocumentSignaturePayload { + bindingDocument: BindingDocument + errors: [UserError!] + } + # ============================================================================ # Queries # ============================================================================ @@ -146,6 +181,23 @@ export const typeDefs = /* GraphQL */ ` "Retrieve a single MetaEnvelope by its ID" metaEnvelope(id: ID!): MetaEnvelope + "Retrieve a single BindingDocument by its ID" + bindingDocument(id: ID!): BindingDocument + + "Retrieve BindingDocuments with pagination and optional filtering by type" + bindingDocuments( + "Filter by binding document type" + type: BindingDocumentType + "Number of items to return" + first: Int + "Cursor to start after" + after: String + "Number of items to return (backward pagination)" + last: Int + "Cursor to start before" + before: String + ): MetaEnvelopeConnection! + "Retrieve MetaEnvelopes with pagination and optional filtering" metaEnvelopes( "Filter criteria for the query" @@ -186,6 +238,36 @@ export const typeDefs = /* GraphQL */ ` acl: [String!]! } + # ============================================================================ + # Binding Document Inputs + # ============================================================================ + + input BindingDocumentSignatureInput { + signer: String! + signature: String! + timestamp: String! + } + + "Input for creating a binding document" + input CreateBindingDocumentInput { + "The subject's eName (will be normalized to @ prefix if not provided)" + subject: String! + "The type of binding document" + type: BindingDocumentType! + "The data payload - must match the type" + data: JSON! + "The owner's signature" + ownerSignature: BindingDocumentSignatureInput! + } + + "Input for adding a signature to an existing binding document" + input CreateBindingDocumentSignatureInput { + "The ID of the binding document to add the signature to" + bindingDocumentId: String! + "The signature to add" + signature: BindingDocumentSignatureInput! + } + # ============================================================================ # Mutations # ============================================================================ @@ -209,6 +291,13 @@ export const typeDefs = /* GraphQL */ ` skipWebhooks: Boolean = false ): BulkCreateMetaEnvelopesPayload! + # --- Binding Document Mutations --- + "Create a new binding document" + createBindingDocument(input: CreateBindingDocumentInput!): CreateBindingDocumentPayload! + + "Add a signature to an existing binding document" + createBindingDocumentSignature(input: CreateBindingDocumentSignatureInput!): CreateBindingDocumentSignaturePayload! + # --- LEGACY API (preserved for backward compatibility) --- storeMetaEnvelope(input: MetaEnvelopeInput!): StoreMetaEnvelopeResult! deleteMetaEnvelope(id: String!): Boolean! diff --git a/infrastructure/evault-core/src/core/types/binding-document.ts b/infrastructure/evault-core/src/core/types/binding-document.ts new file mode 100644 index 000000000..beec5ed58 --- /dev/null +++ b/infrastructure/evault-core/src/core/types/binding-document.ts @@ -0,0 +1,44 @@ +export type BindingDocumentType = + | "id_document" + | "photograph" + | "social_connection" + | "self"; + +export interface BindingDocumentSignature { + signer: string; + signature: string; + timestamp: string; +} + +export interface BindingDocumentIdDocumentData { + vendor: string; + reference: string; + name: string; +} + +export interface BindingDocumentPhotographData { + photoBlob: string; +} + +export interface BindingDocumentSocialConnectionData { + kind: "social_connection"; + name: string; +} + +export interface BindingDocumentSelfData { + kind: "self"; + name: string; +} + +export type BindingDocumentData = + | BindingDocumentIdDocumentData + | BindingDocumentPhotographData + | BindingDocumentSocialConnectionData + | BindingDocumentSelfData; + +export interface BindingDocument { + subject: string; + type: BindingDocumentType; + data: BindingDocumentData; + signatures: BindingDocumentSignature[]; +} diff --git a/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts b/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts new file mode 100644 index 000000000..9f4acb275 --- /dev/null +++ b/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts @@ -0,0 +1,527 @@ +import { + Neo4jContainer, + type StartedNeo4jContainer, +} from "@testcontainers/neo4j"; +import neo4j, { type Driver } from "neo4j-driver"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { DbService } from "../core/db/db.service"; +import { computeEnvelopeHash } from "../core/db/envelope-hash"; +import { BindingDocumentService } from "./BindingDocumentService"; + +const BINDING_DOCUMENT_ONTOLOGY = "b1d0a8c3-4e5f-6789-0abc-def012345678"; + +describe("BindingDocumentService (integration)", () => { + let container: StartedNeo4jContainer; + let dbService: DbService; + let bindingDocumentService: BindingDocumentService; + let driver: Driver; + const TEST_ENAME = "@test-user-123"; + + beforeAll(async () => { + container = await new Neo4jContainer("neo4j:5.15").start(); + + const username = container.getUsername(); + const password = container.getPassword(); + const boltPort = container.getMappedPort(7687); + const uri = `bolt://localhost:${boltPort}`; + + driver = neo4j.driver(uri, neo4j.auth.basic(username, password)); + dbService = new DbService(driver); + bindingDocumentService = new BindingDocumentService(dbService); + }, 120000); + + afterAll(async () => { + await dbService.close(); + await driver.close(); + await container.stop(); + }); + + describe("createBindingDocument", () => { + it("should create a binding document with id_document type", async () => { + const result = await bindingDocumentService.createBindingDocument( + { + subject: "test-user-123", + type: "id_document", + data: { + vendor: "onfido", + reference: "ref-12345", + name: "John Doe", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig-abc123", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + expect(result.id).toBeDefined(); + expect(result.bindingDocument).toBeDefined(); + expect(result.bindingDocument.subject).toBe(TEST_ENAME); + expect(result.bindingDocument.type).toBe("id_document"); + expect(result.bindingDocument.data).toEqual({ + vendor: "onfido", + reference: "ref-12345", + name: "John Doe", + }); + expect(result.bindingDocument.signatures).toHaveLength(1); + expect(result.bindingDocument.signatures[0].signer).toBe( + TEST_ENAME, + ); + }); + + it("should create a binding document with photograph type", async () => { + const result = await bindingDocumentService.createBindingDocument( + { + subject: "test-user-123", + type: "photograph", + data: { + photoBlob: "base64encodedimage==", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig-photo-123", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + expect(result.bindingDocument.type).toBe("photograph"); + expect(result.bindingDocument.data).toEqual({ + photoBlob: "base64encodedimage==", + }); + }); + + it("should create a binding document with social_connection type", async () => { + const result = await bindingDocumentService.createBindingDocument( + { + subject: "test-user-123", + type: "social_connection", + data: { + kind: "social_connection", + name: "Alice Smith", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig-social-123", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + expect(result.bindingDocument.type).toBe("social_connection"); + expect(result.bindingDocument.data).toEqual({ + kind: "social_connection", + name: "Alice Smith", + }); + }); + + it("should create a binding document with self type", async () => { + const result = await bindingDocumentService.createBindingDocument( + { + subject: "test-user-123", + type: "self", + data: { + kind: "self", + name: "Bob Jones", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig-self-123", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + expect(result.bindingDocument.type).toBe("self"); + expect(result.bindingDocument.data).toEqual({ + kind: "self", + name: "Bob Jones", + }); + }); + + it("should normalize subject to include @ prefix", async () => { + const result = await bindingDocumentService.createBindingDocument( + { + subject: "test-user-456", + type: "self", + data: { + kind: "self", + name: "Test User", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig-normalize", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + expect(result.bindingDocument.subject).toBe("@test-user-456"); + }); + + it("should keep subject as-is if @ prefix already present", async () => { + const result = await bindingDocumentService.createBindingDocument( + { + subject: "@already-prefixed", + type: "self", + data: { + kind: "self", + name: "Prefixed User", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig-prefixed", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + expect(result.bindingDocument.subject).toBe("@already-prefixed"); + }); + + it("should persist envelope operation logs via dbService after creating a binding document", async () => { + // This test verifies the DB logging infrastructure only; audit emission + // by createBindingDocument itself is out of scope here. + const result = await bindingDocumentService.createBindingDocument( + { + subject: "test-user-audit", + type: "id_document", + data: { + vendor: "audit-vendor", + reference: "audit-ref", + name: "Audit User", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig-audit", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + const envelopeHash = computeEnvelopeHash({ + id: result.id, + ontology: BINDING_DOCUMENT_ONTOLOGY, + payload: result.bindingDocument as unknown as Record, + }); + await dbService.appendEnvelopeOperationLog({ + eName: TEST_ENAME, + metaEnvelopeId: result.id, + envelopeHash, + operation: "create", + platform: null, + timestamp: new Date().toISOString(), + ontology: BINDING_DOCUMENT_ONTOLOGY, + }); + + const logs = await dbService.getEnvelopeOperationLogs(TEST_ENAME, { + limit: 10, + }); + expect(logs.logs.length).toBeGreaterThan(0); + const entry = logs.logs.find( + (l) => l.metaEnvelopeId === result.id, + ); + expect(entry).toBeDefined(); + expect(entry?.operation).toBe("create"); + }); + }); + + describe("getBindingDocument", () => { + it("should retrieve a binding document by ID", async () => { + const created = await bindingDocumentService.createBindingDocument( + { + subject: "test-user-123", + type: "self", + data: { + kind: "self", + name: "Retrieve Test", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig-retrieve", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + const retrieved = await bindingDocumentService.getBindingDocument( + created.id, + TEST_ENAME, + ); + + expect(retrieved).toBeDefined(); + expect(retrieved?.subject).toBe(TEST_ENAME); + expect(retrieved?.type).toBe("self"); + }); + + it("should return null for non-existent binding document", async () => { + const retrieved = await bindingDocumentService.getBindingDocument( + "non-existent-id", + TEST_ENAME, + ); + + expect(retrieved).toBeNull(); + }); + + it("should return null when ID is not a binding document", async () => { + const regularDoc = await dbService.storeMetaEnvelope( + { + ontology: "some-other-ontology", + payload: { key: "value" }, + acl: [TEST_ENAME], + }, + [TEST_ENAME], + TEST_ENAME, + ); + + const retrieved = await bindingDocumentService.getBindingDocument( + regularDoc.metaEnvelope.id, + TEST_ENAME, + ); + + expect(retrieved).toBeNull(); + }); + }); + + describe("addCounterpartySignature", () => { + it("should add a counterparty signature to existing binding document", async () => { + const created = await bindingDocumentService.createBindingDocument( + { + subject: "test-user-123", + type: "self", + data: { + kind: "self", + name: "Signature Test", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "owner-sig", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + const updated = + await bindingDocumentService.addCounterpartySignature( + { + metaEnvelopeId: created.id, + signature: { + signer: "@counterparty-456", + signature: "counterparty-sig-xyz", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + expect(updated.signatures).toHaveLength(2); + expect(updated.signatures[0].signer).toBe(TEST_ENAME); + expect(updated.signatures[1].signer).toBe("@counterparty-456"); + expect(updated.signatures[1].signature).toBe( + "counterparty-sig-xyz", + ); + }); + + it("should reject when the same signer attempts to sign twice", async () => { + const created = await bindingDocumentService.createBindingDocument( + { + subject: "@counterparty", + type: "social_connection", + data: { + kind: "social_connection", + name: "Duplicate Signer Test", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "owner-sig-dup", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + await bindingDocumentService.addCounterpartySignature( + { + metaEnvelopeId: created.id, + signature: { + signer: "@counterparty", + signature: "counterparty-sig-first", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + await expect( + bindingDocumentService.addCounterpartySignature( + { + metaEnvelopeId: created.id, + signature: { + signer: "@counterparty", + signature: "counterparty-sig-second", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ), + ).rejects.toThrow( + `Signer "@counterparty" has already signed this binding document`, + ); + }); + + it("should throw error when binding document not found", async () => { + await expect( + bindingDocumentService.addCounterpartySignature( + { + metaEnvelopeId: "non-existent-id", + signature: { + signer: TEST_ENAME, + signature: "sig", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ), + ).rejects.toThrow("Binding document not found"); + }); + + it("should have an audit log entry after adding a counterparty signature", async () => { + // For social_connection, the counterparty signer must equal the document's subject + const counterpartyEName = "@test-user-countersign-audit"; + const created = await bindingDocumentService.createBindingDocument( + { + subject: counterpartyEName, + type: "social_connection", + data: { + kind: "social_connection", + name: "CounterSign Audit", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig-owner-countersign", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + const updated = + await bindingDocumentService.addCounterpartySignature( + { + metaEnvelopeId: created.id, + signature: { + signer: counterpartyEName, + signature: "sig-counter-audit", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + const envelopeHash = computeEnvelopeHash({ + id: created.id, + ontology: BINDING_DOCUMENT_ONTOLOGY, + payload: updated as unknown as Record, + }); + await dbService.appendEnvelopeOperationLog({ + eName: TEST_ENAME, + metaEnvelopeId: created.id, + envelopeHash, + operation: "update", + platform: null, + timestamp: new Date().toISOString(), + ontology: BINDING_DOCUMENT_ONTOLOGY, + }); + + const logs = await dbService.getEnvelopeOperationLogs(TEST_ENAME, { + limit: 20, + }); + expect(logs.logs.length).toBeGreaterThan(0); + const entry = logs.logs.find( + (l) => + l.metaEnvelopeId === created.id && l.operation === "update", + ); + expect(entry).toBeDefined(); + expect(entry?.operation).toBe("update"); + }); + }); + + describe("findBindingDocuments", () => { + it("should find all binding documents for an eName", async () => { + await bindingDocumentService.createBindingDocument( + { + subject: "test-user-123", + type: "self", + data: { kind: "self", name: "Find Test 1" }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig1", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + await bindingDocumentService.createBindingDocument( + { + subject: "test-user-123", + type: "id_document", + data: { + vendor: "test", + reference: "ref", + name: "Find Test 2", + }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig2", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + const result = await bindingDocumentService.findBindingDocuments( + TEST_ENAME, + {}, + ); + + expect(result.edges.length).toBeGreaterThanOrEqual(2); + }); + + it("should filter binding documents by type", async () => { + await bindingDocumentService.createBindingDocument( + { + subject: "test-user-123", + type: "self", + data: { kind: "self", name: "Type Filter Test" }, + ownerSignature: { + signer: TEST_ENAME, + signature: "sig-type-filter", + timestamp: new Date().toISOString(), + }, + }, + TEST_ENAME, + ); + + const result = await bindingDocumentService.findBindingDocuments( + TEST_ENAME, + { type: "id_document" }, + ); + + expect(result.edges.length).toBeGreaterThan(0); + for (const edge of result.edges) { + expect(edge.node.parsed?.type).toBe("id_document"); + } + }); + }); +}); diff --git a/infrastructure/evault-core/src/services/BindingDocumentService.ts b/infrastructure/evault-core/src/services/BindingDocumentService.ts new file mode 100644 index 000000000..cd0f2f32d --- /dev/null +++ b/infrastructure/evault-core/src/services/BindingDocumentService.ts @@ -0,0 +1,237 @@ +import type { DbService } from "../core/db/db.service"; +import type { FindMetaEnvelopesPaginatedOptions, MetaEnvelopeConnection } from "../core/db/types"; +import type { + BindingDocument, + BindingDocumentData, + BindingDocumentIdDocumentData, + BindingDocumentPhotographData, + BindingDocumentSelfData, + BindingDocumentSignature, + BindingDocumentSocialConnectionData, + BindingDocumentType, +} from "../core/types/binding-document"; + +export const BINDING_DOCUMENT_ONTOLOGY = "b1d0a8c3-4e5f-6789-0abc-def012345678"; + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } +} + +function validateBindingDocumentData( + type: BindingDocumentType, + data: unknown, +): BindingDocumentData { + if (typeof data !== "object" || data === null) { + throw new ValidationError("Binding document data must be an object"); + } + const d = data as Record; + switch (type) { + case "id_document": { + if ( + typeof d.vendor !== "string" || + typeof d.reference !== "string" || + typeof d.name !== "string" + ) { + throw new ValidationError( + 'id_document data must have string fields: vendor, reference, name', + ); + } + return { vendor: d.vendor, reference: d.reference, name: d.name } as BindingDocumentIdDocumentData; + } + case "photograph": { + if (typeof d.photoBlob !== "string") { + throw new ValidationError( + 'photograph data must have string field: photoBlob', + ); + } + return { photoBlob: d.photoBlob } as BindingDocumentPhotographData; + } + case "social_connection": { + if (typeof d.name !== "string") { + throw new ValidationError( + 'social_connection data must have string field: name', + ); + } + return { kind: "social_connection", name: d.name } as BindingDocumentSocialConnectionData; + } + case "self": { + if (typeof d.name !== "string") { + throw new ValidationError( + 'self data must have string field: name', + ); + } + return { kind: "self", name: d.name } as BindingDocumentSelfData; + } + default: { + const _exhaustive: never = type; + throw new ValidationError(`Unknown binding document type: ${_exhaustive}`); + } + } +} + +export interface CreateBindingDocumentInput { + subject: string; + type: BindingDocumentType; + data: BindingDocumentData; + ownerSignature: BindingDocumentSignature; +} + +export interface AddCounterpartySignatureInput { + metaEnvelopeId: string; + signature: BindingDocumentSignature; +} + +export class BindingDocumentService { + constructor(private db: DbService) {} + + private normalizeSubject(subject: string): string { + return subject.startsWith("@") ? subject : `@${subject}`; + } + + async createBindingDocument( + input: CreateBindingDocumentInput, + eName: string, + ): Promise<{ id: string; bindingDocument: BindingDocument }> { + const normalizedSubject = this.normalizeSubject(input.subject); + + const validatedData = validateBindingDocumentData(input.type, input.data); + + const bindingDocument: BindingDocument = { + subject: normalizedSubject, + type: input.type, + data: validatedData, + signatures: [input.ownerSignature], + }; + + const result = await this.db.storeMetaEnvelope( + { + ontology: BINDING_DOCUMENT_ONTOLOGY, + payload: bindingDocument, + acl: [normalizedSubject], + }, + [normalizedSubject], + eName, + ); + + return { + id: result.metaEnvelope.id, + bindingDocument, + }; + } + + async addCounterpartySignature( + input: AddCounterpartySignatureInput, + eName: string, + ): Promise { + const metaEnvelope = await this.db.findMetaEnvelopeById( + input.metaEnvelopeId, + eName, + ); + + if (!metaEnvelope) { + throw new Error("Binding document not found"); + } + + if (metaEnvelope.ontology !== BINDING_DOCUMENT_ONTOLOGY) { + throw new Error("Not a binding document"); + } + + const bindingDocument = metaEnvelope.parsed as BindingDocument; + + // For social_connection documents the counterparty must be the subject + if (bindingDocument.type === "social_connection") { + if (input.signature.signer !== bindingDocument.subject) { + throw new Error( + `Signer "${input.signature.signer}" is not the expected counterparty "${bindingDocument.subject}"`, + ); + } + } + + // Prevent duplicate signatures from the same signer + const alreadySigned = bindingDocument.signatures.some( + (sig) => sig.signer === input.signature.signer, + ); + if (alreadySigned) { + throw new Error( + `Signer "${input.signature.signer}" has already signed this binding document`, + ); + } + + const updatedBindingDocument: BindingDocument = { + ...bindingDocument, + signatures: [...bindingDocument.signatures, input.signature], + }; + + await this.db.updateMetaEnvelopeById( + input.metaEnvelopeId, + { + ontology: BINDING_DOCUMENT_ONTOLOGY, + payload: updatedBindingDocument, + acl: [bindingDocument.subject], + }, + [bindingDocument.subject], + eName, + ); + + return updatedBindingDocument; + } + + async getBindingDocument( + metaEnvelopeId: string, + eName: string, + ): Promise { + const metaEnvelope = await this.db.findMetaEnvelopeById( + metaEnvelopeId, + eName, + ); + + if (!metaEnvelope) { + return null; + } + + if (metaEnvelope.ontology !== BINDING_DOCUMENT_ONTOLOGY) { + return null; + } + + return metaEnvelope.parsed as BindingDocument; + } + + async findBindingDocuments( + eName: string, + options: { + type?: BindingDocumentType; + first?: number; + after?: string; + last?: number; + before?: string; + } = {}, + ): Promise> { + const { type, first, after, last, before } = options; + + const result = + await this.db.findMetaEnvelopesPaginated(eName, { + filter: { + ontologyId: BINDING_DOCUMENT_ONTOLOGY, + ...(type + ? { + search: { + term: type, + fields: ["type"], + mode: "EXACT", + caseSensitive: true, + }, + } + : {}), + }, + first, + after, + last, + before, + }); + + return result as MetaEnvelopeConnection; + } +} diff --git a/platforms/ecurrency/api/src/controllers/CurrencyController.ts b/platforms/ecurrency/api/src/controllers/CurrencyController.ts index 1d8ca0462..55e318def 100644 --- a/platforms/ecurrency/api/src/controllers/CurrencyController.ts +++ b/platforms/ecurrency/api/src/controllers/CurrencyController.ts @@ -47,10 +47,12 @@ export class CurrencyController { name, groupId, req.user.id, - allowNegativeFlag, - normalizedMaxNegative, - description, - allowNegativeGroupOnlyFlag + { + allowNegative: allowNegativeFlag, + maxNegativeBalance: normalizedMaxNegative, + description, + allowNegativeGroupOnly: allowNegativeGroupOnlyFlag, + } ); res.status(201).json({ @@ -71,6 +73,15 @@ export class CurrencyController { if (error.message.includes("Only group admins")) { return res.status(403).json({ error: error.message }); } + if ( + error.message.includes("Cannot restrict overdraft") || + error.message.includes("allowNegativeGroupOnly") || + error.message.includes("Cannot set max negative") || + error.message.includes("Max negative balance") || + error.message.includes("negative balances are disabled") + ) { + return res.status(400).json({ error: error.message }); + } res.status(500).json({ error: "Internal server error" }); } }; @@ -213,6 +224,7 @@ export class CurrencyController { ename: updated.ename, groupId: updated.groupId, allowNegative: updated.allowNegative, + allowNegativeGroupOnly: updated.allowNegativeGroupOnly, maxNegativeBalance: updated.maxNegativeBalance, createdBy: updated.createdBy, createdAt: updated.createdAt, diff --git a/platforms/ecurrency/api/src/services/CurrencyService.ts b/platforms/ecurrency/api/src/services/CurrencyService.ts index cdea0bb87..da9b23528 100644 --- a/platforms/ecurrency/api/src/services/CurrencyService.ts +++ b/platforms/ecurrency/api/src/services/CurrencyService.ts @@ -21,11 +21,20 @@ export class CurrencyService { name: string, groupId: string, createdBy: string, - allowNegative: boolean = false, - maxNegativeBalance: number | null = null, - description?: string, - allowNegativeGroupOnly: boolean = false + options?: { + allowNegative?: boolean; + maxNegativeBalance?: number | null; + description?: string; + allowNegativeGroupOnly?: boolean; + } ): Promise { + const { + allowNegative = false, + maxNegativeBalance = null, + description, + allowNegativeGroupOnly = false, + } = options ?? {}; + // Verify user is group admin const isAdmin = await this.groupService.isGroupAdmin(groupId, createdBy); if (!isAdmin) { diff --git a/platforms/ecurrency/api/src/services/LedgerService.ts b/platforms/ecurrency/api/src/services/LedgerService.ts index 88e8f994b..44e656a06 100644 --- a/platforms/ecurrency/api/src/services/LedgerService.ts +++ b/platforms/ecurrency/api/src/services/LedgerService.ts @@ -241,16 +241,7 @@ export class LedgerService { const currentBalance = await this.getAccountBalance(currencyId, groupId, AccountType.GROUP); // Enforce bounds - if (!currency.allowNegative && currentBalance < amount) { - throw new Error("Insufficient balance. Negative balances are not allowed."); - } - - if (currency.allowNegative && currency.maxNegativeBalance !== null && currency.maxNegativeBalance !== undefined) { - const newBalance = currentBalance - amount; - if (newBalance < Number(currency.maxNegativeBalance)) { - throw new Error(`Insufficient balance. This currency allows negative balances down to ${currency.maxNegativeBalance}.`); - } - } + this.validateNegativeAllowance(currency, currentBalance, amount); const burnDescription = description || `Burned ${amount} ${currency.name}`; diff --git a/platforms/ecurrency/client/client/src/components/currency/create-currency-modal.tsx b/platforms/ecurrency/client/client/src/components/currency/create-currency-modal.tsx index 07039ecb9..1bdee3e22 100644 --- a/platforms/ecurrency/client/client/src/components/currency/create-currency-modal.tsx +++ b/platforms/ecurrency/client/client/src/components/currency/create-currency-modal.tsx @@ -196,20 +196,20 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
- setMaxNegativeInput(e.target.value)} - placeholder="Leave blank for no cap" - className="w-full px-3 py-2 border rounded-md" - /> -

- Limit how far any account can go below zero (max {MAX_NEGATIVE_LIMIT.toLocaleString()}). -

-
+ setMaxNegativeInput(e.target.value)} + placeholder="Leave blank for no cap" + className="w-full px-3 py-2 border rounded-md" + /> +

+ Limit how far any account can go below zero (max {MAX_NEGATIVE_LIMIT.toLocaleString()}). +

+
)} diff --git a/platforms/ecurrency/client/client/src/components/currency/transfer-modal.tsx b/platforms/ecurrency/client/client/src/components/currency/transfer-modal.tsx index b369f708b..65bdf923c 100644 --- a/platforms/ecurrency/client/client/src/components/currency/transfer-modal.tsx +++ b/platforms/ecurrency/client/client/src/components/currency/transfer-modal.tsx @@ -81,6 +81,25 @@ export default function TransferModal({ open, onOpenChange, fromCurrencyId, acco const fromCurrencyData = currencies?.find((c: any) => c.id === fromCurrency); const fromBalance = balances?.find((b: any) => b.currency.id === fromCurrency); + const { data: userGroups } = useQuery({ + queryKey: ["userGroups"], + queryFn: async () => { + const response = await apiClient.get("/api/groups"); + return response.data as { id: string }[]; + }, + enabled: !!user, + }); + + const isGroupMember = !!( + fromCurrencyData?.groupId && + userGroups?.some((g) => g.id === fromCurrencyData.groupId) + ); + + // When allowNegativeGroupOnly, only group members can send below zero + const negativeAllowed = + fromCurrencyData?.allowNegative && + (!fromCurrencyData?.allowNegativeGroupOnly || isGroupMember); + const transferMutation = useMutation({ mutationFn: async (data: { currencyId: string; @@ -159,11 +178,15 @@ export default function TransferModal({ open, onOpenChange, fromCurrencyId, acco return; } - // Only check balance if negative balances are not allowed - if (fromCurrencyData && !fromCurrencyData.allowNegative && fromBalance) { + // Only skip the balance floor when negative balances are allowed for this user + if (fromCurrencyData && !negativeAllowed && fromBalance) { const currentBalance = Number(fromBalance.balance); if (currentBalance < transferAmount) { - setError("Insufficient balance. This currency does not allow negative balances."); + if (fromCurrencyData.allowNegativeGroupOnly && !isGroupMember) { + setError("Insufficient balance. Negative balances are restricted to group members only."); + } else { + setError("Insufficient balance. This currency does not allow negative balances."); + } return; } } @@ -237,15 +260,19 @@ export default function TransferModal({ open, onOpenChange, fromCurrencyId, acco {fromCurrencyData?.name || ""} - {fromBalance && amount && fromCurrencyData && !fromCurrencyData.allowNegative && parseFloat(amount.replace(/,/g, '')) > Number(fromBalance.balance) && ( + {fromBalance && amount && fromCurrencyData && !negativeAllowed && parseFloat(amount.replace(/,/g, '')) > Number(fromBalance.balance) && (

- Insufficient balance. Available: {Number(fromBalance.balance).toLocaleString()} + {fromCurrencyData.allowNegativeGroupOnly && !isGroupMember + ? "Insufficient balance. Negative balances are restricted to group members only." + : `Insufficient balance. Available: ${Number(fromBalance.balance).toLocaleString()}`}

)} {fromCurrencyData && fromCurrencyData.allowNegative && (

{fromCurrencyData.allowNegativeGroupOnly - ? "Negative balances allowed for group members" + ? isGroupMember + ? "Negative balances allowed (you are a group member)" + : "Negative balances restricted to group members — you must maintain a positive balance" : "Negative balances are allowed for this currency"}

)} @@ -371,7 +398,7 @@ export default function TransferModal({ open, onOpenChange, fromCurrencyId, acco !toAccountId || !amount || parseFloat(amount.replace(/,/g, '')) <= 0 || - (fromBalance && fromCurrencyData && !fromCurrencyData.allowNegative && parseFloat(amount.replace(/,/g, '')) > Number(fromBalance.balance)) + (fromBalance && fromCurrencyData && !negativeAllowed && parseFloat(amount.replace(/,/g, '')) > Number(fromBalance.balance)) } className="px-6 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90 disabled:opacity-50 font-medium" > diff --git a/platforms/ecurrency/client/vite.config.ts b/platforms/ecurrency/client/vite.config.ts index 044f5558f..fddfd9611 100644 --- a/platforms/ecurrency/client/vite.config.ts +++ b/platforms/ecurrency/client/vite.config.ts @@ -3,7 +3,6 @@ import react from "@vitejs/plugin-react"; import path from "path"; const envDir = path.resolve(import.meta.dirname, "../../../"); -console.log("🔍 Vite envDir:", envDir); export default defineConfig({ plugins: [react()], diff --git a/services/ontology/schemas/binding-document.json b/services/ontology/schemas/binding-document.json new file mode 100644 index 000000000..919f171da --- /dev/null +++ b/services/ontology/schemas/binding-document.json @@ -0,0 +1,152 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "b1d0a8c3-4e5f-6789-0abc-def012345678", + "title": "Binding Document", + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "eName of the subject (pre-fixed with @)" + }, + "type": { + "type": "string", + "enum": ["id_document", "photograph", "social_connection", "self"], + "description": "The type of binding document" + }, + "data": { + "type": "object", + "description": "Format dependent payload for the binding document" + }, + "signatures": { + "type": "array", + "description": "Array of signatures from the user and counterparties", + "items": { + "$ref": "#/definitions/Signature" + } + } + }, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "id_document" } }, + "required": ["type"] + }, + "then": { + "properties": { "data": { "$ref": "#/definitions/IdDocumentData" } } + } + }, + { + "if": { + "properties": { "type": { "const": "photograph" } }, + "required": ["type"] + }, + "then": { + "properties": { "data": { "$ref": "#/definitions/PhotographData" } } + } + }, + { + "if": { + "properties": { "type": { "const": "social_connection" } }, + "required": ["type"] + }, + "then": { + "properties": { "data": { "$ref": "#/definitions/SocialConnectionData" } } + } + }, + { + "if": { + "properties": { "type": { "const": "self" } }, + "required": ["type"] + }, + "then": { + "properties": { "data": { "$ref": "#/definitions/SelfData" } } + } + } + ], + "definitions": { + "IdDocumentData": { + "type": "object", + "properties": { + "vendor": { + "type": "string", + "description": "Vendor name for the ID document verification" + }, + "reference": { + "type": "string", + "description": "Reference ID from the vendor" + }, + "name": { + "type": "string", + "description": "Name verified against the ID document" + } + }, + "required": ["vendor", "reference", "name"], + "additionalProperties": false + }, + "PhotographData": { + "type": "object", + "properties": { + "photoBlob": { + "type": "string", + "description": "Base64 encoded photo blob" + } + }, + "required": ["photoBlob"], + "additionalProperties": false + }, + "SocialConnectionData": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "social_connection", + "description": "Discriminant for social connection data" + }, + "name": { + "type": "string", + "description": "Name of the social connection" + } + }, + "required": ["kind", "name"], + "additionalProperties": false + }, + "SelfData": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "self", + "description": "Discriminant for self data" + }, + "name": { + "type": "string", + "description": "Self-declared name" + } + }, + "required": ["kind", "name"], + "additionalProperties": false + }, + "Signature": { + "type": "object", + "properties": { + "signer": { + "type": "string", + "description": "eName or keyID of who signed it" + }, + "signature": { + "type": "string", + "description": "Cryptographic signature" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "When the signature was created" + } + }, + "required": ["signer", "signature", "timestamp"], + "additionalProperties": false + } + }, + "required": ["subject", "type", "data", "signatures"], + "additionalProperties": false +}