From 13ec0e92681e9ff81dba25bdb8d8aa0a97fafbd8 Mon Sep 17 00:00:00 2001
From: coodos
Date: Mon, 23 Feb 2026 00:43:01 +0530
Subject: [PATCH 01/10] feat: create binding document typedef
---
.../src/core/types/binding-document.ts | 43 +++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 infrastructure/evault-core/src/core/types/binding-document.ts
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..b1dbc0073
--- /dev/null
+++ b/infrastructure/evault-core/src/core/types/binding-document.ts
@@ -0,0 +1,43 @@
+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 {
+ name: string;
+}
+
+export interface BindingDocumentSelfData {
+ name: string;
+}
+
+export type BindingDocumentData =
+ | BindingDocumentIdDocumentData
+ | BindingDocumentPhotographData
+ | BindingDocumentSocialConnectionData
+ | BindingDocumentSelfData;
+
+export interface BindingDocument {
+ id: string;
+ subject: string;
+ type: BindingDocumentType;
+ data: BindingDocumentData;
+ signatures: BindingDocumentSignature[];
+}
From 19de581f88e34da1c119b56da6dbd3ba09ccece5 Mon Sep 17 00:00:00 2001
From: coodos
Date: Mon, 23 Feb 2026 00:46:19 +0530
Subject: [PATCH 02/10] feat: add new binding document ontology
---
.../ontology/schemas/binding-document.json | 123 ++++++++++++++++++
1 file changed, 123 insertions(+)
create mode 100644 services/ontology/schemas/binding-document.json
diff --git a/services/ontology/schemas/binding-document.json b/services/ontology/schemas/binding-document.json
new file mode 100644
index 000000000..6a84ab0d2
--- /dev/null
+++ b/services/ontology/schemas/binding-document.json
@@ -0,0 +1,123 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "schemaId": "b1d0a8c3-4e5f-6789-0abc-def012345678",
+ "title": "Binding Document",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid",
+ "description": "The unique identifier for this binding document"
+ },
+ "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",
+ "oneOf": [
+ {
+ "$ref": "#/definitions/IdDocumentData"
+ },
+ {
+ "$ref": "#/definitions/PhotographData"
+ },
+ {
+ "$ref": "#/definitions/SocialConnectionData"
+ },
+ {
+ "$ref": "#/definitions/SelfData"
+ }
+ ]
+ },
+ "signatures": {
+ "type": "array",
+ "description": "Array of signatures from the user and counterparties",
+ "items": {
+ "$ref": "#/definitions/Signature"
+ }
+ }
+ },
+ "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": {
+ "name": {
+ "type": "string",
+ "description": "Name of the social connection"
+ }
+ },
+ "required": ["name"],
+ "additionalProperties": false
+ },
+ "SelfData": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Self-declared name"
+ }
+ },
+ "required": ["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": ["id", "subject", "type", "data", "signatures"],
+ "additionalProperties": false
+}
From 0c68311c217693a38afa6a8faaf1eae32bde7bf4 Mon Sep 17 00:00:00 2001
From: coodos
Date: Mon, 23 Feb 2026 00:47:14 +0530
Subject: [PATCH 03/10] feat: binding document service
---
.../src/services/BindingDocumentService.ts | 119 ++++++++++++++++++
1 file changed, 119 insertions(+)
create mode 100644 infrastructure/evault-core/src/services/BindingDocumentService.ts
diff --git a/infrastructure/evault-core/src/services/BindingDocumentService.ts b/infrastructure/evault-core/src/services/BindingDocumentService.ts
new file mode 100644
index 000000000..a47fa99c4
--- /dev/null
+++ b/infrastructure/evault-core/src/services/BindingDocumentService.ts
@@ -0,0 +1,119 @@
+import { W3IDBuilder } from "w3id";
+import type { DbService } from "../core/db/db.service";
+import type {
+ BindingDocument,
+ BindingDocumentData,
+ BindingDocumentSignature,
+ BindingDocumentType,
+} from "../core/types/binding-document";
+
+const BINDING_DOCUMENT_ONTOLOGY = "b1d0a8c3-4e5f-6789-0abc-def012345678";
+
+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 w3id = await new W3IDBuilder().build();
+ const normalizedSubject = this.normalizeSubject(input.subject);
+
+ const bindingDocument: BindingDocument = {
+ id: w3id.id,
+ subject: normalizedSubject,
+ type: input.type,
+ data: input.data,
+ 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;
+
+ 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;
+ }
+}
From 2ac32ff00baa0006848a272105af65996d1a2a27 Mon Sep 17 00:00:00 2001
From: coodos
Date: Mon, 23 Feb 2026 01:03:28 +0530
Subject: [PATCH 04/10] feat: graphql binding documents
---
.../evault-core/GRAPHQL_TEST_POCS.md | 303 ----------
.../src/core/protocol/graphql-server.ts | 547 ++++++++++++++----
.../evault-core/src/core/protocol/typedefs.ts | 90 +++
.../src/services/BindingDocumentService.ts | 40 ++
4 files changed, 560 insertions(+), 420 deletions(-)
delete mode 100644 infrastructure/evault-core/GRAPHQL_TEST_POCS.md
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/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts
index 608c99fe6..99cbb5cd2 100644
--- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts
+++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts
@@ -1,21 +1,23 @@
-import { createSchema, createYoga, YogaInitialContext } from "graphql-yoga";
-import { createServer, Server } from "http";
-import { typeDefs } from "./typedefs";
+import { type Server, createServer } from "http";
+import axios from "axios";
+import type { GraphQLSchema } from "graphql";
import { renderVoyagerPage } from "graphql-voyager/middleware";
+import { YogaInitialContext, createSchema, createYoga } from "graphql-yoga";
import { getJWTHeader } from "w3id";
-import { DbService } from "../db/db.service";
+import { BindingDocumentService } 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 +27,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 +58,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 +77,7 @@ export class GraphQLServer {
*/
private async deliverWebhooks(
requestingPlatform: string | null,
- webhookPayload: any
+ webhookPayload: any,
): Promise {
try {
const activePlatforms = await this.getActivePlatforms();
@@ -76,16 +88,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 +115,7 @@ export class GraphQLServer {
try {
const webhookUrl = new URL(
"/api/webhook",
- platformUrl
+ platformUrl,
).toString();
await axios.post(webhookUrl, webhookPayload, {
headers: {
@@ -106,15 +124,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 +173,51 @@ 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");
+ }
+ return this.bindingDocumentService.findBindingDocuments(
+ context.eName,
+ {
+ type: args.type as any,
+ first: args.first,
+ after: args.after,
+ last: args.last,
+ before: args.before,
+ },
+ );
+ },
),
// Retrieve MetaEnvelopes with pagination and filtering
@@ -177,19 +239,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 +267,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 +296,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 +328,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 +350,7 @@ export class GraphQLServer {
acl: input.acl,
},
input.acl,
- context.eName
+ context.eName,
);
// Build the full metaEnvelope response
@@ -283,7 +362,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 +374,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 +399,10 @@ export class GraphQLServer {
ontology: input.ontology,
})
.catch((err) =>
- console.error("appendEnvelopeOperationLog (create) failed:", err)
+ console.error(
+ "appendEnvelopeOperationLog (create) failed:",
+ err,
+ ),
);
return {
@@ -323,18 +410,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 +445,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 +468,7 @@ export class GraphQLServer {
acl: input.acl,
},
input.acl,
- context.eName
+ context.eName,
);
// Build the full metaEnvelope response
@@ -382,7 +480,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 +491,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 +515,10 @@ export class GraphQLServer {
ontology: input.ontology,
})
.catch((err) =>
- console.error("appendEnvelopeOperationLog (update) failed:", err)
+ console.error(
+ "appendEnvelopeOperationLog (update) failed:",
+ err,
+ ),
);
return {
@@ -420,46 +526,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 +602,10 @@ export class GraphQLServer {
ontology: meta.ontology,
})
.catch((err) =>
- console.error("appendEnvelopeOperationLog (delete) failed:", err)
+ console.error(
+ "appendEnvelopeOperationLog (delete) failed:",
+ err,
+ ),
);
return {
@@ -480,19 +614,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 +651,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 +706,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 +717,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 +747,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 +775,146 @@ 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",
+ },
+ ],
+ };
+ }
+
+ try {
+ const result =
+ await this.bindingDocumentService.createBindingDocument(
+ {
+ subject: input.subject,
+ type: input.type as any,
+ data: input.data,
+ ownerSignature: input.ownerSignature,
+ },
+ context.eName,
+ );
+
+ return {
+ bindingDocument: result.bindingDocument,
+ metaEnvelopeId: result.id,
+ 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,
+ );
+
+ 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 +933,7 @@ export class GraphQLServer {
acl: string[];
};
},
- context: VaultContext
+ context: VaultContext,
) => {
if (!context.eName) {
throw new Error("X-ENAME header is required");
@@ -639,7 +945,7 @@ export class GraphQLServer {
acl: input.acl,
},
input.acl,
- context.eName
+ context.eName,
);
// Add parsed field to metaEnvelope for GraphQL response
@@ -672,13 +978,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 +1011,7 @@ export class GraphQLServer {
...result,
metaEnvelope: metaEnvelopeWithParsed,
};
- }
+ },
),
updateMetaEnvelopeById: this.accessGuard.middleware(
async (
@@ -722,7 +1027,7 @@ export class GraphQLServer {
acl: string[];
};
},
- context: VaultContext
+ context: VaultContext,
) => {
if (!context.eName) {
throw new Error("X-ENAME header is required");
@@ -736,7 +1041,7 @@ export class GraphQLServer {
acl: input.acl,
},
input.acl,
- context.eName
+ context.eName,
);
// Deliver webhooks for update operation
@@ -753,7 +1058,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 +1090,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 +1130,7 @@ export class GraphQLServer {
),
);
return true;
- }
+ },
),
updateEnvelopeValue: this.accessGuard.middleware(
async (
@@ -829,7 +1139,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 +1180,7 @@ export class GraphQLServer {
);
}
return true;
- }
+ },
),
},
};
@@ -889,7 +1199,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..cce3374bc 100644
--- a/infrastructure/evault-core/src/core/protocol/typedefs.ts
+++ b/infrastructure/evault-core/src/core/protocol/typedefs.ts
@@ -137,6 +137,42 @@ 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 {
+ id: String!
+ 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 +182,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 +239,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 +292,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/services/BindingDocumentService.ts b/infrastructure/evault-core/src/services/BindingDocumentService.ts
index a47fa99c4..23a5aa991 100644
--- a/infrastructure/evault-core/src/services/BindingDocumentService.ts
+++ b/infrastructure/evault-core/src/services/BindingDocumentService.ts
@@ -1,5 +1,7 @@
import { W3IDBuilder } from "w3id";
import type { DbService } from "../core/db/db.service";
+import type { FindMetaEnvelopesPaginatedOptions } from "../core/db/types";
+import type { MetaEnvelopeConnection } from "../core/db/types";
import type {
BindingDocument,
BindingDocumentData,
@@ -116,4 +118,42 @@ export class BindingDocumentService {
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,
+ },
+ first,
+ after,
+ last,
+ before,
+ });
+
+ if (!type) {
+ return result as MetaEnvelopeConnection;
+ }
+
+ const filteredEdges = result.edges.filter((edge) => {
+ return edge.node.parsed?.type === type;
+ });
+
+ return {
+ ...result,
+ edges: filteredEdges,
+ totalCount: filteredEdges.length,
+ };
+ }
}
From c85f14a681355aefbbf323247b40f8ad503d6fb7 Mon Sep 17 00:00:00 2001
From: coodos
Date: Mon, 23 Feb 2026 01:16:17 +0530
Subject: [PATCH 05/10] tests: binding document tests
---
.../evault-core/src/core/db/schema.ts | 21 +-
.../src/core/protocol/graphql-server.ts | 7 +-
.../evault-core/src/core/protocol/typedefs.ts | 1 -
.../src/core/types/binding-document.ts | 1 -
.../services/BindingDocumentService.spec.ts | 361 ++++++++++++++++++
.../src/services/BindingDocumentService.ts | 3 -
.../ontology/schemas/binding-document.json | 7 +-
7 files changed, 385 insertions(+), 16 deletions(-)
create mode 100644 infrastructure/evault-core/src/services/BindingDocumentService.spec.ts
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 99cbb5cd2..a9fa38789 100644
--- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts
+++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts
@@ -1,8 +1,7 @@
-import { type Server, createServer } from "http";
+import { Server } from "http";
import axios from "axios";
import type { GraphQLSchema } from "graphql";
-import { renderVoyagerPage } from "graphql-voyager/middleware";
-import { YogaInitialContext, createSchema, createYoga } from "graphql-yoga";
+import { createSchema, createYoga } from "graphql-yoga";
import { getJWTHeader } from "w3id";
import { BindingDocumentService } from "../../services/BindingDocumentService";
import type { DbService } from "../db/db.service";
@@ -671,7 +670,7 @@ export class GraphQLServer {
const isEmoverMigration =
skipWebhooks &&
context.tokenPayload?.platform ===
- process.env.EMOVER_API_URL;
+ process.env.EMOVER_API_URL;
// Only allow webhook skipping for authorized migration platforms
const shouldSkipWebhooks = isEmoverMigration;
diff --git a/infrastructure/evault-core/src/core/protocol/typedefs.ts b/infrastructure/evault-core/src/core/protocol/typedefs.ts
index cce3374bc..99f8b6d78 100644
--- a/infrastructure/evault-core/src/core/protocol/typedefs.ts
+++ b/infrastructure/evault-core/src/core/protocol/typedefs.ts
@@ -155,7 +155,6 @@ export const typeDefs = /* GraphQL */ `
}
type BindingDocument {
- id: String!
subject: String!
type: BindingDocumentType!
data: JSON!
diff --git a/infrastructure/evault-core/src/core/types/binding-document.ts b/infrastructure/evault-core/src/core/types/binding-document.ts
index b1dbc0073..c67cc391a 100644
--- a/infrastructure/evault-core/src/core/types/binding-document.ts
+++ b/infrastructure/evault-core/src/core/types/binding-document.ts
@@ -35,7 +35,6 @@ export type BindingDocumentData =
| BindingDocumentSelfData;
export interface BindingDocument {
- id: string;
subject: string;
type: BindingDocumentType;
data: BindingDocumentData;
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..722e09a7a
--- /dev/null
+++ b/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts
@@ -0,0 +1,361 @@
+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 { BindingDocumentService } from "./BindingDocumentService";
+
+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: {
+ 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({
+ 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: {
+ 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({
+ name: "Bob Jones",
+ });
+ });
+
+ it("should normalize subject to include @ prefix", async () => {
+ const result = await bindingDocumentService.createBindingDocument(
+ {
+ subject: "test-user-456",
+ type: "self",
+ data: {
+ 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: {
+ name: "Prefixed User",
+ },
+ ownerSignature: {
+ signer: TEST_ENAME,
+ signature: "sig-prefixed",
+ timestamp: new Date().toISOString(),
+ },
+ },
+ TEST_ENAME,
+ );
+
+ expect(result.bindingDocument.subject).toBe("@already-prefixed");
+ });
+ });
+
+ describe("getBindingDocument", () => {
+ it("should retrieve a binding document by ID", async () => {
+ const created = await bindingDocumentService.createBindingDocument(
+ {
+ subject: "test-user-123",
+ type: "self",
+ data: {
+ 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: {
+ 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 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");
+ });
+ });
+
+ describe("findBindingDocuments", () => {
+ it("should find all binding documents for an eName", async () => {
+ await bindingDocumentService.createBindingDocument(
+ {
+ subject: "test-user-123",
+ type: "self",
+ data: { 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: { 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" },
+ );
+
+ 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
index 23a5aa991..2eacee620 100644
--- a/infrastructure/evault-core/src/services/BindingDocumentService.ts
+++ b/infrastructure/evault-core/src/services/BindingDocumentService.ts
@@ -1,4 +1,3 @@
-import { W3IDBuilder } from "w3id";
import type { DbService } from "../core/db/db.service";
import type { FindMetaEnvelopesPaginatedOptions } from "../core/db/types";
import type { MetaEnvelopeConnection } from "../core/db/types";
@@ -34,11 +33,9 @@ export class BindingDocumentService {
input: CreateBindingDocumentInput,
eName: string,
): Promise<{ id: string; bindingDocument: BindingDocument }> {
- const w3id = await new W3IDBuilder().build();
const normalizedSubject = this.normalizeSubject(input.subject);
const bindingDocument: BindingDocument = {
- id: w3id.id,
subject: normalizedSubject,
type: input.type,
data: input.data,
diff --git a/services/ontology/schemas/binding-document.json b/services/ontology/schemas/binding-document.json
index 6a84ab0d2..41c0f4e0f 100644
--- a/services/ontology/schemas/binding-document.json
+++ b/services/ontology/schemas/binding-document.json
@@ -4,11 +4,6 @@
"title": "Binding Document",
"type": "object",
"properties": {
- "id": {
- "type": "string",
- "format": "uuid",
- "description": "The unique identifier for this binding document"
- },
"subject": {
"type": "string",
"description": "eName of the subject (pre-fixed with @)"
@@ -118,6 +113,6 @@
"additionalProperties": false
}
},
- "required": ["id", "subject", "type", "data", "signatures"],
+ "required": ["subject", "type", "data", "signatures"],
"additionalProperties": false
}
From 61eaad54c2382ae457485b1dd21c77976ab7f145 Mon Sep 17 00:00:00 2001
From: coodos
Date: Mon, 23 Feb 2026 02:04:31 +0530
Subject: [PATCH 06/10] docs: binding documents docs for graphql mutations, and
details
---
docs/docs/Infrastructure/eVault.md | 159 +++++++++++++
docs/docs/W3DS Basics/Binding-Documents.md | 256 +++++++++++++++++++++
docs/docs/W3DS Basics/Links.md | 2 +-
docs/docs/W3DS Basics/W3ID.md | 10 +-
docs/docs/W3DS Basics/eName.md | 73 ++++++
docs/docs/W3DS Basics/glossary.md | 2 +-
6 files changed, 495 insertions(+), 7 deletions(-)
create mode 100644 docs/docs/W3DS Basics/Binding-Documents.md
create mode 100644 docs/docs/W3DS Basics/eName.md
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.
From d1e813d0e82f69798abf3d12fd003e536e73457d Mon Sep 17 00:00:00 2001
From: Julien Connault
Date: Mon, 23 Feb 2026 01:41:28 +0300
Subject: [PATCH 07/10] Feat/ecurrency overdraft toggle (#819)
* fix: correct envDir path in Vite configuration
* feat: add allowNegativeGroupOnly feature to currency management
* feat: implement overdraft validation for group members and enhance negative balance checks
---
.../api/src/controllers/CurrencyController.ts | 10 ++++-
.../api/src/controllers/LedgerController.ts | 1 +
.../api/src/database/entities/Currency.ts | 3 ++
...523000000-add-allow-negative-group-only.ts | 11 +++++
.../api/src/services/CurrencyService.ts | 8 +++-
.../api/src/services/GroupService.ts | 14 ++++++
.../api/src/services/LedgerService.ts | 45 +++++++++++++++----
.../currency/create-currency-modal.tsx | 26 ++++++++++-
.../components/currency/transfer-modal.tsx | 13 ++----
.../client/client/src/pages/dashboard.tsx | 4 +-
platforms/ecurrency/client/vite.config.ts | 2 +-
11 files changed, 111 insertions(+), 26 deletions(-)
create mode 100644 platforms/ecurrency/api/src/database/migrations/1771523000000-add-allow-negative-group-only.ts
diff --git a/platforms/ecurrency/api/src/controllers/CurrencyController.ts b/platforms/ecurrency/api/src/controllers/CurrencyController.ts
index 785e2a75f..1d8ca0462 100644
--- a/platforms/ecurrency/api/src/controllers/CurrencyController.ts
+++ b/platforms/ecurrency/api/src/controllers/CurrencyController.ts
@@ -15,13 +15,14 @@ export class CurrencyController {
return res.status(401).json({ error: "Authentication required" });
}
- const { name, description, groupId, allowNegative, maxNegativeBalance } = req.body;
+ const { name, description, groupId, allowNegative, maxNegativeBalance, allowNegativeGroupOnly } = req.body;
if (!name || !groupId) {
return res.status(400).json({ error: "Name and groupId are required" });
}
const allowNegativeFlag = Boolean(allowNegative);
+ const allowNegativeGroupOnlyFlag = Boolean(allowNegativeGroupOnly);
let normalizedMaxNegative: number | null = null;
if (maxNegativeBalance !== undefined && maxNegativeBalance !== null && maxNegativeBalance !== "") {
@@ -48,7 +49,8 @@ export class CurrencyController {
req.user.id,
allowNegativeFlag,
normalizedMaxNegative,
- description
+ description,
+ allowNegativeGroupOnlyFlag
);
res.status(201).json({
@@ -58,6 +60,7 @@ export class CurrencyController {
ename: currency.ename,
groupId: currency.groupId,
allowNegative: currency.allowNegative,
+ allowNegativeGroupOnly: currency.allowNegativeGroupOnly,
maxNegativeBalance: currency.maxNegativeBalance,
createdBy: currency.createdBy,
createdAt: currency.createdAt,
@@ -81,6 +84,7 @@ export class CurrencyController {
ename: currency.ename,
groupId: currency.groupId,
allowNegative: currency.allowNegative,
+ allowNegativeGroupOnly: currency.allowNegativeGroupOnly,
maxNegativeBalance: currency.maxNegativeBalance,
createdBy: currency.createdBy,
createdAt: currency.createdAt,
@@ -108,6 +112,7 @@ export class CurrencyController {
ename: currency.ename,
groupId: currency.groupId,
allowNegative: currency.allowNegative,
+ allowNegativeGroupOnly: currency.allowNegativeGroupOnly,
maxNegativeBalance: currency.maxNegativeBalance,
createdBy: currency.createdBy,
createdAt: currency.createdAt,
@@ -130,6 +135,7 @@ export class CurrencyController {
ename: currency.ename,
groupId: currency.groupId,
allowNegative: currency.allowNegative,
+ allowNegativeGroupOnly: currency.allowNegativeGroupOnly,
maxNegativeBalance: currency.maxNegativeBalance,
createdBy: currency.createdBy,
createdAt: currency.createdAt,
diff --git a/platforms/ecurrency/api/src/controllers/LedgerController.ts b/platforms/ecurrency/api/src/controllers/LedgerController.ts
index 84bb909a8..82711362e 100644
--- a/platforms/ecurrency/api/src/controllers/LedgerController.ts
+++ b/platforms/ecurrency/api/src/controllers/LedgerController.ts
@@ -51,6 +51,7 @@ export class LedgerController {
name: b.currency.name,
ename: b.currency.ename,
allowNegative: b.currency.allowNegative,
+ allowNegativeGroupOnly: b.currency.allowNegativeGroupOnly,
},
balance: b.balance,
})));
diff --git a/platforms/ecurrency/api/src/database/entities/Currency.ts b/platforms/ecurrency/api/src/database/entities/Currency.ts
index 20e015d16..b7c5cf327 100644
--- a/platforms/ecurrency/api/src/database/entities/Currency.ts
+++ b/platforms/ecurrency/api/src/database/entities/Currency.ts
@@ -39,6 +39,9 @@ export class Currency {
@Column("decimal", { precision: 18, scale: 2, nullable: true })
maxNegativeBalance!: number | null;
+ @Column({ default: false })
+ allowNegativeGroupOnly!: boolean;
+
@Column()
createdBy!: string;
diff --git a/platforms/ecurrency/api/src/database/migrations/1771523000000-add-allow-negative-group-only.ts b/platforms/ecurrency/api/src/database/migrations/1771523000000-add-allow-negative-group-only.ts
new file mode 100644
index 000000000..4cb34d78d
--- /dev/null
+++ b/platforms/ecurrency/api/src/database/migrations/1771523000000-add-allow-negative-group-only.ts
@@ -0,0 +1,11 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class AddAllowNegativeGroupOnly1771523000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "currencies" ADD "allowNegativeGroupOnly" boolean NOT NULL DEFAULT false`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "currencies" DROP COLUMN "allowNegativeGroupOnly"`);
+ }
+}
diff --git a/platforms/ecurrency/api/src/services/CurrencyService.ts b/platforms/ecurrency/api/src/services/CurrencyService.ts
index 053cb40c5..cdea0bb87 100644
--- a/platforms/ecurrency/api/src/services/CurrencyService.ts
+++ b/platforms/ecurrency/api/src/services/CurrencyService.ts
@@ -23,7 +23,8 @@ export class CurrencyService {
createdBy: string,
allowNegative: boolean = false,
maxNegativeBalance: number | null = null,
- description?: string
+ description?: string,
+ allowNegativeGroupOnly: boolean = false
): Promise {
// Verify user is group admin
const isAdmin = await this.groupService.isGroupAdmin(groupId, createdBy);
@@ -46,6 +47,10 @@ export class CurrencyService {
}
}
+ if (allowNegativeGroupOnly && !allowNegative) {
+ throw new Error("Cannot restrict overdraft to group members when negative balances are disabled");
+ }
+
const currency = this.currencyRepository.create({
name,
description,
@@ -54,6 +59,7 @@ export class CurrencyService {
createdBy,
allowNegative,
maxNegativeBalance,
+ allowNegativeGroupOnly,
});
const savedCurrency = await this.currencyRepository.save(currency);
diff --git a/platforms/ecurrency/api/src/services/GroupService.ts b/platforms/ecurrency/api/src/services/GroupService.ts
index df25731dd..f31101dd6 100644
--- a/platforms/ecurrency/api/src/services/GroupService.ts
+++ b/platforms/ecurrency/api/src/services/GroupService.ts
@@ -234,5 +234,19 @@ export class GroupService {
if (!group) return false;
return group.admins.some(admin => admin.id === userId);
}
+
+ async isUserInGroup(groupId: string, userId: string): Promise {
+ const group = await this.groupRepository.findOne({
+ where: { id: groupId },
+ relations: ["members", "participants", "admins"]
+ });
+ if (!group) return false;
+
+ // Check if user is the owner, a member, participant, or admin
+ return group.owner === userId ||
+ group.members.some(m => m.id === userId) ||
+ group.participants.some(p => p.id === userId) ||
+ group.admins.some(a => a.id === userId);
+ }
}
diff --git a/platforms/ecurrency/api/src/services/LedgerService.ts b/platforms/ecurrency/api/src/services/LedgerService.ts
index c6cd693cb..88e8f994b 100644
--- a/platforms/ecurrency/api/src/services/LedgerService.ts
+++ b/platforms/ecurrency/api/src/services/LedgerService.ts
@@ -5,15 +5,18 @@ import { Currency } from "../database/entities/Currency";
import { User } from "../database/entities/User";
import { Group } from "../database/entities/Group";
import { TransactionNotificationService } from "./TransactionNotificationService";
+import { GroupService } from "./GroupService";
import crypto from "crypto";
export class LedgerService {
ledgerRepository: Repository;
currencyRepository: Repository;
+ groupService: GroupService;
constructor() {
this.ledgerRepository = AppDataSource.getRepository(Ledger);
this.currencyRepository = AppDataSource.getRepository(Currency);
+ this.groupService = new GroupService();
}
private computeHash(payload: any): string {
@@ -28,6 +31,23 @@ export class LedgerService {
return prev?.hash ?? null;
}
+ /**
+ * Validates whether a debit of `amount` from `currentBalance` is allowed
+ * given the currency's negative-balance settings.
+ */
+ private validateNegativeAllowance(currency: Currency, currentBalance: number, amount: number): void {
+ if (!currency.allowNegative && currentBalance < amount) {
+ throw new Error("Insufficient balance. This currency does not allow negative balances.");
+ }
+
+ 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}.`);
+ }
+ }
+ }
+
async getAccountBalance(currencyId: string, accountId: string, accountType: AccountType): Promise {
const latestEntry = await this.ledgerRepository.findOne({
where: {
@@ -132,16 +152,23 @@ export class LedgerService {
const currentBalance = await this.getAccountBalance(currencyId, fromAccountId, fromAccountType);
- // Validate debit bounds
- if (!currency.allowNegative && currentBalance < amount) {
- throw new Error("Insufficient balance. This currency does not allow negative balances.");
- }
-
- 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}.`);
+ // Validate debit bounds.
+ // When allowNegativeGroupOnly is true, only group members may overdraft;
+ // non-members are limited to their current balance. Note: the invariant
+ // allowNegativeGroupOnly ⇒ allowNegative is enforced at creation time
+ // in CurrencyService.createCurrency.
+ if (currency.allowNegativeGroupOnly && fromAccountType === AccountType.USER) {
+ const isMember = await this.groupService.isUserInGroup(currency.groupId, fromAccountId);
+
+ if (!isMember) {
+ if (currentBalance < amount) {
+ throw new Error("Insufficient balance. Only group members can have negative balances for this currency.");
+ }
+ } else {
+ this.validateNegativeAllowance(currency, currentBalance, amount);
}
+ } else {
+ this.validateNegativeAllowance(currency, currentBalance, amount);
}
// Create debit entry (from sender's account)
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 5e740da4e..07039ecb9 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
@@ -16,6 +16,7 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
const [description, setDescription] = useState("");
const [groupId, setGroupId] = useState("");
const [allowNegative, setAllowNegative] = useState(false);
+ const [allowNegativeGroupOnly, setAllowNegativeGroupOnly] = useState(false);
const [maxNegativeInput, setMaxNegativeInput] = useState("");
const [error, setError] = useState(null);
const queryClient = useQueryClient();
@@ -28,6 +29,7 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
description?: string;
groupId: string;
allowNegative: boolean;
+ allowNegativeGroupOnly: boolean;
maxNegativeBalance: number | null;
}) => {
const response = await apiClient.post("/api/currencies", data);
@@ -40,6 +42,7 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
setDescription("");
setGroupId("");
setAllowNegative(false);
+ setAllowNegativeGroupOnly(false);
setMaxNegativeInput("");
setError(null);
onOpenChange(false);
@@ -91,6 +94,7 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
description,
groupId,
allowNegative,
+ allowNegativeGroupOnly,
maxNegativeBalance: maxNegativeValue,
});
}}
@@ -142,6 +146,7 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
type="button"
onClick={() => {
setAllowNegative(false);
+ setAllowNegativeGroupOnly(false);
setMaxNegativeInput("");
}}
className={`p-4 border-2 rounded-lg text-left transition-all ${
@@ -173,8 +178,24 @@ export default function CreateCurrencyModal({ open, onOpenChange, groups }: Crea
{allowNegative && (
-
-
Max negative balance (absolute value)
+ <>
+
+
+ setAllowNegativeGroupOnly(e.target.checked)}
+ className="w-4 h-4 rounded border-gray-300"
+ />
+ Restrict overdraft to group members only
+
+
+ When enabled, only users who are members of the currency's group can have negative balances. Non-members must maintain a positive balance.
+
+
+
+
+ Max negative balance (absolute value)
+ >
)}
{error && (
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 0da31da49..b369f708b 100644
--- a/platforms/ecurrency/client/client/src/components/currency/transfer-modal.tsx
+++ b/platforms/ecurrency/client/client/src/components/currency/transfer-modal.tsx
@@ -244,7 +244,9 @@ export default function TransferModal({ open, onOpenChange, fromCurrencyId, acco
)}
{fromCurrencyData && fromCurrencyData.allowNegative && (
- Negative balances are allowed for this currency
+ {fromCurrencyData.allowNegativeGroupOnly
+ ? "Negative balances allowed for group members"
+ : "Negative balances are allowed for this currency"}
)}
@@ -353,15 +355,6 @@ export default function TransferModal({ open, onOpenChange, fromCurrencyId, acco
)}
- {/* Mutation Error */}
- {transferMutation.isError && (
-
- {transferMutation.error instanceof Error
- ? transferMutation.error.message
- : "An error occurred while processing the transfer"}
-
- )}
-
{formatEName(balance.currency.ename)}
{balance.currency.allowNegative !== undefined && (
- {balance.currency.allowNegative ? "Negative Allowed" : "No Negative"}
+ {balance.currency.allowNegative
+ ? (balance.currency.allowNegativeGroupOnly ? "Negative Allowed (Members only)" : "Negative Allowed")
+ : "No Negative"}
)}
diff --git a/platforms/ecurrency/client/vite.config.ts b/platforms/ecurrency/client/vite.config.ts
index 02bcaa749..044f5558f 100644
--- a/platforms/ecurrency/client/vite.config.ts
+++ b/platforms/ecurrency/client/vite.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
-const envDir = path.resolve(import.meta.dirname, "../../");
+const envDir = path.resolve(import.meta.dirname, "../../../");
console.log("🔍 Vite envDir:", envDir);
export default defineConfig({
From 83f341f0721d5201b7cb6c19234888a6c32fa2b9 Mon Sep 17 00:00:00 2001
From: Merul Dhiman <69296233+coodos@users.noreply.github.com>
Date: Mon, 23 Feb 2026 04:13:34 +0530
Subject: [PATCH 08/10] Feat/dev sandbox metaenvelope inspect view (#823)
* feat: evault inspector
* feat: sandbox config
---
.cursor/.gitignore | 1 +
.../dev-sandbox/src/routes/+page.svelte | 1854 +++++++++++------
2 files changed, 1235 insertions(+), 620 deletions(-)
create mode 100644 .cursor/.gitignore
diff --git a/.cursor/.gitignore b/.cursor/.gitignore
new file mode 100644
index 000000000..8bf7cc27a
--- /dev/null
+++ b/.cursor/.gitignore
@@ -0,0 +1 @@
+plans/
diff --git a/infrastructure/dev-sandbox/src/routes/+page.svelte b/infrastructure/dev-sandbox/src/routes/+page.svelte
index e1c946bf6..28d67b177 100644
--- a/infrastructure/dev-sandbox/src/routes/+page.svelte
+++ b/infrastructure/dev-sandbox/src/routes/+page.svelte
@@ -1,532 +1,704 @@
@@ -542,154 +714,340 @@
{config.provisionerUrl}
-
-
- Provision new eVault
-
- {provisionBusy ? "Provisioning…" : "Provision new eVault"}
+
+ (currentTab = "sandbox")}
+ >
+ Wallet Sandbox
- {#if provisionError}
- {provisionError}
- {/if}
- {#if provisionSuccess}
-
+
(currentTab = "inspector")}
+ >
+ eVault Inspector
+
+
(currentTab = "config")}
+ >
+ Sandbox Config
+
+
+
+
+
+ Provision new eVault
+
+ {provisionBusy ? "Provisioning…" : "Provision new eVault"}
+
+ {#if provisionError}
+ {provisionError}
+ {/if}
+ {#if provisionSuccess}
+
+
+ W3ID:
+ {provisionSuccess.w3id}
+
+ copyToClipboard(provisionSuccess!.w3id)}
+ >Copy
+
+
+ eVault URI:
+ {provisionSuccess.uri}
+
+ copyToClipboard(provisionSuccess!.uri)}
+ >Copy
+
+
+ {/if}
+
+
+ {#if identities.length > 0}
+
+ Selected identity
+
+ {#each identities as id, i}
+ {id.w3id}
+ {/each}
+
+
+
+
+ Paste any w3ds URI (auth, sign, voting)
- W3ID:
- {provisionSuccess.w3id}
-
- copyToClipboard(provisionSuccess!.w3id)}
- >Copy
+ Paste a w3ds://auth or
+ w3ds://sign URI (or HTTP URL with session/redirect_uri).
+ You can also upload or paste an image of a QR code to decode
+ the request.
-
- eVault URI:
- {provisionSuccess.uri}
+
+
- copyToClipboard(provisionSuccess!.uri)}
- >Copy qrFileInputEl?.click()}
>
-
-
- {/if}
-
+ {qrDecodeBusy ? "Decoding…" : "Upload QR image"}
+
+
or paste an image (Ctrl+V)
+
+ {#if qrDecodeError}
+
{qrDecodeError}
+ {/if}
+
+
+ {actionBusy ? "Performing…" : "Perform"}
+
+ {#if actionError}
+
{actionError}
+ {/if}
+ {#if actionSuccess}
+
{actionSuccess}
+ {/if}
+
- {#if identities.length > 0}
-
- Selected identity
-
- {#each identities as id, i}
- {id.w3id}
- {/each}
-
-
+
+ Sign payload
+ Sign a string with the selected identity's key.
+
+
+ {signBusy ? "Signing…" : "Sign"}
+
+ {#if signError}
+ {signError}
+ {/if}
+ {#if signResult}
+
+ Signature:
+ {signResult}
+ copyToClipboard(signResult!)}
+ >Copy
+
+ {/if}
+
+ {#if lastDebug}
+
+ Last action debug
+
+ Request:
+ {lastDebug.method}
+ {lastDebug.url}
+
+ Body:
+ {JSON.stringify(lastDebug.body, null, 2)}
+
+ Key used: keyId={lastDebug.keyId},
+ w3id={lastDebug.w3id}{#if lastDebug.publicKeyFingerprint}
+ · fingerprint …{lastDebug.publicKeyFingerprint}{/if}
+
+
+ Response:
+ {lastDebug.responseStatus}
+
+ {typeof lastDebug.responseBody === "object"
+ ? JSON.stringify(
+ lastDebug.responseBody,
+ null,
+ 2,
+ )
+ : String(lastDebug.responseBody)}
+
+ {/if}
+ {/if}
+
+
+
- Paste any w3ds URI (auth, sign, voting)
-
- Paste a w3ds://auth or
- w3ds://sign URI (or HTTP URL with session/redirect_uri).
- You can also upload or paste an image of a QR code to decode
- the request.
-
-
+
MetaEnvelope Explorer
+
+ eName (X-ENAME):
- qrFileInputEl?.click()}
+
+
+ Select ontology:
- {qrDecodeBusy ? "Decoding…" : "Upload QR image"}
-
- or paste an image (Ctrl+V)
+
+ -- pick schema --
+ {#each ontologies as o}
+ {o.title || o.id}
+ {/each}
+
- {#if qrDecodeError}
-
{qrDecodeError}
- {/if}
-
-
- {actionBusy ? "Performing…" : "Perform"}
-
- {#if actionError}
-
{actionError}
- {/if}
- {#if actionSuccess}
-
{actionSuccess}
+ {#if schemasError}
+
{schemasError}
{/if}
+
+
+ {schemasLoading ? "Loading schemas…" : "Refresh schemas"}
+
+
+ {pageLoading ? "Loading…" : "Load MetaEnvelopes"}
+
+
+ {#if selectedOntologyId}
+
-
- Sign payload
- Sign a string with the selected identity's key.
-
-
- {signBusy ? "Signing…" : "Sign"}
-
- {#if signError}
- {signError}
- {/if}
- {#if signResult}
-
- Signature:
- {signResult}
- copyToClipboard(signResult!)}
- >Copy
-
- {/if}
-
+ {#if pageError}
+
{pageError}
+ {/if}
- {#if lastDebug}
-
- Last action debug
-
- Request:
- {lastDebug.method}
- {lastDebug.url}
-
- Body:
- {JSON.stringify(lastDebug.body, null, 2)}
-
- Key used: keyId={lastDebug.keyId},
- w3id={lastDebug.w3id}{#if lastDebug.publicKeyFingerprint}
- · fingerprint …{lastDebug.publicKeyFingerprint}{/if}
-
-
- Response:
- {lastDebug.responseStatus}
-
- {typeof lastDebug.responseBody === "object"
- ? JSON.stringify(
- lastDebug.responseBody,
- null,
- 2,
- )
- : String(lastDebug.responseBody)}
-
+ {#if pageLoading}
+
Loading metaenvelopes…
+ {:else if envelopes.length === 0}
+
No envelopes found for this ontology.
+ {:else}
+ {#each envelopes as env (env.id)}
+ {@const isExpanded = expandedIds.has(env.id)}
+ {@const jsonStr = JSON.stringify(env.parsed, null, 2)}
+
+
+
+
+
{@html highlightJson(jsonStr)}
+ {#if !isExpanded}
+
+ {/if}
+
+
+
+ {/each}
+
+
{/if}
{/if}
+
+
+
+
+ Sandbox Config
+ Custom URLs are saved to localStorage and take precedence over environment defaults.
+
+ Registry URL:
+
+
+
+ Provisioner URL:
+
+
+
+ Ontology URL:
+
+
+
+ Save
+ Reset to defaults
+
+
+
Registry: {registryUrl}
+
Provisioner: {provisionerUrl}
+
Ontology: {ontologyUrl}
+
+
+
@@ -734,6 +1092,38 @@
color: var(--muted, #666);
}
+ /* Tab strip for sandbox/inspector */
+ .tab-bar {
+ display: flex;
+ border-bottom: 2px solid var(--border, #e2e8f0);
+ margin-bottom: 1rem;
+ }
+ .tab-bar button {
+ all: unset;
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ border: 1px solid var(--border, #e2e8f0);
+ border-bottom: 2px solid transparent;
+ border-radius: 0.5rem 0.5rem 0 0;
+ background: var(--bg-card, #fff);
+ margin-right: 0.5rem;
+ }
+ .tab-bar button:hover:not(.active) {
+ background: var(--btn-bg, #334155);
+ color: #fff;
+ border-color: var(--btn-bg, #334155);
+ }
+ .tab-bar button.active {
+ border-bottom-color: var(--btn-hover, #475569);
+ background: var(--bg-page, #f0f2f5);
+ color: var(--fg, #1a1a1a);
+ font-weight: 600;
+ }
+ .tab-bar button.active:hover {
+ background: var(--bg-page, #f0f2f5);
+ color: var(--fg, #1a1a1a);
+ }
+
.sandbox-layout {
display: flex;
height: 100vh;
@@ -1034,4 +1424,228 @@
font-size: 0.7rem;
color: var(--log-muted, #94a3b8);
}
+ .hidden {
+ display: none;
+ }
+ .json-viewer {
+ background: var(--bg-code, #e2e8f0);
+ padding: 1rem;
+ border-radius: 6px;
+ overflow-x: auto;
+ font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
+ font-size: 0.85rem;
+ line-height: 1.4;
+ max-height: 60vh;
+ }
+ .inspector-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 20px;
+ }
+
+ .btn-secondary {
+ background: var(--bg-page, #f0f2f5);
+ color: var(--fg, #334155);
+ border: 1px solid var(--border, #e2e8f0);
+ }
+ .btn-secondary:hover:not(:disabled) {
+ background: var(--border, #e2e8f0);
+ color: var(--fg, #1a1a1a);
+ }
+
+ .field-select-refresh {
+ display: flex;
+ align-items: center;
+ }
+ .field-select-refresh select {
+ flex: 1;
+ }
+ .btn-icon {
+ all: unset;
+ cursor: pointer;
+ font-size: 1.2rem;
+ margin-left: 0.5rem;
+ }
+ .btn-icon:disabled {
+ opacity: 0.5;
+ cursor: default;
+ }
+
+ /* Envelope list */
+ .envelope-list-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.75rem;
+ }
+ .envelope-count {
+ font-size: 0.85rem;
+ color: var(--muted, #64748b);
+ }
+ .pagination-controls {
+ display: flex;
+ gap: 0.4rem;
+ }
+ .envelope-loading,
+ .envelope-empty {
+ padding: 1.5rem;
+ text-align: center;
+ color: var(--muted, #64748b);
+ font-size: 0.9rem;
+ background: var(--bg-card, #fff);
+ border: 1px solid var(--border, #e2e8f0);
+ border-radius: 10px;
+ }
+
+ .envelope-row {
+ background: var(--bg-card, #fff);
+ border: 1px solid var(--border, #e2e8f0);
+ border-radius: 10px;
+ margin-bottom: 0.6rem;
+ overflow: hidden;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+ }
+
+ .envelope-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.65rem 1rem;
+ cursor: pointer;
+ background: var(--bg-card, #fff);
+ border-bottom: 1px solid var(--border, #e2e8f0);
+ gap: 0.75rem;
+ user-select: none;
+ }
+ .envelope-header:hover {
+ background: var(--bg-page, #f0f2f5);
+ }
+
+ .envelope-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ min-width: 0;
+ }
+ .envelope-id {
+ font-family: ui-monospace, "Cascadia Code", "SF Mono", monospace;
+ font-size: 0.78rem;
+ color: var(--fg, #1a1a1a);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 55ch;
+ }
+ .envelope-onto {
+ font-size: 0.72rem;
+ color: var(--muted, #64748b);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 55ch;
+ }
+
+ .btn-expand {
+ all: unset;
+ flex-shrink: 0;
+ cursor: pointer;
+ font-size: 0.75rem;
+ padding: 0.25em 0.65em;
+ border-radius: 5px;
+ border: 1px solid var(--border, #e2e8f0);
+ background: var(--bg-page, #f0f2f5);
+ color: var(--fg, #334155);
+ white-space: nowrap;
+ }
+ .btn-expand:hover {
+ background: var(--btn-bg, #334155);
+ color: var(--btn-fg, #fff);
+ }
+
+ .envelope-body {
+ position: relative;
+ overflow: hidden;
+ max-height: 7.5em;
+ transition: max-height 0.25s ease;
+ }
+ .envelope-body.expanded {
+ max-height: 600px;
+ overflow-y: auto;
+ }
+
+ .json-preview {
+ position: relative;
+ }
+
+ .json-pre {
+ margin: 0;
+ padding: 0.75rem 1rem;
+ font-family: ui-monospace, "Cascadia Code", "SF Mono", monospace;
+ font-size: 0.78rem;
+ line-height: 1.55;
+ background: #1e1e2e;
+ color: #cdd6f4;
+ overflow-x: auto;
+ white-space: pre;
+ }
+
+ .fade-overlay {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ to bottom,
+ transparent 5%,
+ rgba(0, 0, 0, 0.8) 35%,
+ rgba(0, 0, 0, 1) 60%,
+ rgba(0, 0, 0, 1) 100%
+ );
+ pointer-events: none;
+ }
+
+ /* JSON syntax token colors (dark theme matching json-pre bg) */
+ :global(.json-key) { color: #89b4fa; }
+ :global(.json-string) { color: #a6e3a1; }
+ :global(.json-num) { color: #fab387; }
+ :global(.json-bool) { color: #cba6f7; }
+ :global(.json-null) { color: #f38ba8; }
+
+ .pagination-footer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ margin-top: 0.5rem;
+ padding: 0.5rem 0;
+ }
+
+ .config-hint {
+ font-size: 0.82rem;
+ color: var(--muted, #64748b);
+ margin: 0 0 1rem;
+ }
+
+ .config-actions {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 1.25rem;
+ }
+
+ .config-current {
+ margin-top: 1.25rem;
+ padding: 0.75rem 1rem;
+ background: var(--bg-page, #f0f2f5);
+ border-radius: 8px;
+ border: 1px solid var(--border, #e2e8f0);
+ }
+ .config-current p {
+ margin: 0.3rem 0;
+ font-size: 0.82rem;
+ word-break: break-all;
+ }
+ .config-label {
+ font-weight: 600;
+ color: var(--muted, #64748b);
+ margin-right: 0.35rem;
+ }
From 015250d0eca935d368d4d085609b78e8d9859921 Mon Sep 17 00:00:00 2001
From: coodos
Date: Mon, 23 Feb 2026 04:28:19 +0530
Subject: [PATCH 09/10] chore: address code rabbit suggestions
---
.../src/core/protocol/graphql-server.ts | 118 ++++++++++++++++-
.../src/core/types/binding-document.ts | 2 +
.../services/BindingDocumentService.spec.ts | 121 +++++++++++++++++-
.../src/services/BindingDocumentService.ts | 110 ++++++++++++++--
.../ontology/schemas/binding-document.json | 68 +++++++---
5 files changed, 384 insertions(+), 35 deletions(-)
diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts
index a9fa38789..cb2ca2063 100644
--- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts
+++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts
@@ -813,21 +813,91 @@ export class GraphQLServer {
};
}
+ 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 any,
+ 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:
+ "b1d0a8c3-4e5f-6789-0abc-def012345678",
+ payload: result.bindingDocument as unknown as Record,
+ });
+
+ this.db
+ .appendEnvelopeOperationLog({
+ eName: context.eName,
+ metaEnvelopeId,
+ envelopeHash,
+ operation: "create",
+ platform,
+ timestamp: new Date().toISOString(),
+ ontology:
+ "b1d0a8c3-4e5f-6789-0abc-def012345678",
+ })
+ .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:
+ "b1d0a8c3-4e5f-6789-0abc-def012345678",
+ };
+ setTimeout(() => {
+ this.deliverWebhooks(
+ requestingPlatform,
+ webhookPayload,
+ );
+ }, 3_000);
+
return {
bindingDocument: result.bindingDocument,
- metaEnvelopeId: result.id,
+ metaEnvelopeId,
errors: [],
};
} catch (error) {
@@ -891,6 +961,50 @@ export class GraphQLServer {
context.eName,
);
+ const platform =
+ context.tokenPayload?.platform ?? null;
+ const envelopeHash = computeEnvelopeHash({
+ id: input.bindingDocumentId,
+ ontology:
+ "b1d0a8c3-4e5f-6789-0abc-def012345678",
+ payload: result as unknown as Record,
+ });
+
+ this.db
+ .appendEnvelopeOperationLog({
+ eName: context.eName,
+ metaEnvelopeId: input.bindingDocumentId,
+ envelopeHash,
+ operation: "update",
+ platform,
+ timestamp: new Date().toISOString(),
+ ontology:
+ "b1d0a8c3-4e5f-6789-0abc-def012345678",
+ })
+ .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:
+ "b1d0a8c3-4e5f-6789-0abc-def012345678",
+ };
+ setTimeout(() => {
+ this.deliverWebhooks(
+ requestingPlatform,
+ webhookPayload,
+ );
+ }, 3_000);
+
return {
bindingDocument: result,
errors: [],
diff --git a/infrastructure/evault-core/src/core/types/binding-document.ts b/infrastructure/evault-core/src/core/types/binding-document.ts
index c67cc391a..beec5ed58 100644
--- a/infrastructure/evault-core/src/core/types/binding-document.ts
+++ b/infrastructure/evault-core/src/core/types/binding-document.ts
@@ -21,10 +21,12 @@ export interface BindingDocumentPhotographData {
}
export interface BindingDocumentSocialConnectionData {
+ kind: "social_connection";
name: string;
}
export interface BindingDocumentSelfData {
+ kind: "self";
name: string;
}
diff --git a/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts b/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts
index 722e09a7a..735c36f0e 100644
--- a/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts
+++ b/infrastructure/evault-core/src/services/BindingDocumentService.spec.ts
@@ -5,8 +5,11 @@ import {
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;
@@ -97,6 +100,7 @@ describe("BindingDocumentService (integration)", () => {
subject: "test-user-123",
type: "social_connection",
data: {
+ kind: "social_connection",
name: "Alice Smith",
},
ownerSignature: {
@@ -110,6 +114,7 @@ describe("BindingDocumentService (integration)", () => {
expect(result.bindingDocument.type).toBe("social_connection");
expect(result.bindingDocument.data).toEqual({
+ kind: "social_connection",
name: "Alice Smith",
});
});
@@ -120,6 +125,7 @@ describe("BindingDocumentService (integration)", () => {
subject: "test-user-123",
type: "self",
data: {
+ kind: "self",
name: "Bob Jones",
},
ownerSignature: {
@@ -133,6 +139,7 @@ describe("BindingDocumentService (integration)", () => {
expect(result.bindingDocument.type).toBe("self");
expect(result.bindingDocument.data).toEqual({
+ kind: "self",
name: "Bob Jones",
});
});
@@ -143,6 +150,7 @@ describe("BindingDocumentService (integration)", () => {
subject: "test-user-456",
type: "self",
data: {
+ kind: "self",
name: "Test User",
},
ownerSignature: {
@@ -163,6 +171,7 @@ describe("BindingDocumentService (integration)", () => {
subject: "@already-prefixed",
type: "self",
data: {
+ kind: "self",
name: "Prefixed User",
},
ownerSignature: {
@@ -176,6 +185,51 @@ describe("BindingDocumentService (integration)", () => {
expect(result.bindingDocument.subject).toBe("@already-prefixed");
});
+
+ it("should have an audit log entry after creating a binding document", async () => {
+ 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", () => {
@@ -185,6 +239,7 @@ describe("BindingDocumentService (integration)", () => {
subject: "test-user-123",
type: "self",
data: {
+ kind: "self",
name: "Retrieve Test",
},
ownerSignature: {
@@ -242,6 +297,7 @@ describe("BindingDocumentService (integration)", () => {
subject: "test-user-123",
type: "self",
data: {
+ kind: "self",
name: "Signature Test",
},
ownerSignature: {
@@ -289,6 +345,66 @@ describe("BindingDocumentService (integration)", () => {
),
).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", () => {
@@ -297,7 +413,7 @@ describe("BindingDocumentService (integration)", () => {
{
subject: "test-user-123",
type: "self",
- data: { name: "Find Test 1" },
+ data: { kind: "self", name: "Find Test 1" },
ownerSignature: {
signer: TEST_ENAME,
signature: "sig1",
@@ -338,7 +454,7 @@ describe("BindingDocumentService (integration)", () => {
{
subject: "test-user-123",
type: "self",
- data: { name: "Type Filter Test" },
+ data: { kind: "self", name: "Type Filter Test" },
ownerSignature: {
signer: TEST_ENAME,
signature: "sig-type-filter",
@@ -353,6 +469,7 @@ describe("BindingDocumentService (integration)", () => {
{ 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
index 2eacee620..59e30562e 100644
--- a/infrastructure/evault-core/src/services/BindingDocumentService.ts
+++ b/infrastructure/evault-core/src/services/BindingDocumentService.ts
@@ -4,12 +4,75 @@ import type { MetaEnvelopeConnection } from "../core/db/types";
import type {
BindingDocument,
BindingDocumentData,
+ BindingDocumentIdDocumentData,
+ BindingDocumentPhotographData,
+ BindingDocumentSelfData,
BindingDocumentSignature,
+ BindingDocumentSocialConnectionData,
BindingDocumentType,
} from "../core/types/binding-document";
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;
@@ -35,10 +98,12 @@ export class BindingDocumentService {
): 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: input.data,
+ data: validatedData,
signatures: [input.ownerSignature],
};
@@ -77,6 +142,25 @@ export class BindingDocumentService {
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],
@@ -132,6 +216,16 @@ export class BindingDocumentService {
await this.db.findMetaEnvelopesPaginated(eName, {
filter: {
ontologyId: BINDING_DOCUMENT_ONTOLOGY,
+ ...(type
+ ? {
+ search: {
+ term: type,
+ fields: ["type"],
+ mode: "EXACT",
+ caseSensitive: true,
+ },
+ }
+ : {}),
},
first,
after,
@@ -139,18 +233,6 @@ export class BindingDocumentService {
before,
});
- if (!type) {
- return result as MetaEnvelopeConnection;
- }
-
- const filteredEdges = result.edges.filter((edge) => {
- return edge.node.parsed?.type === type;
- });
-
- return {
- ...result,
- edges: filteredEdges,
- totalCount: filteredEdges.length,
- };
+ return result as MetaEnvelopeConnection;
}
}
diff --git a/services/ontology/schemas/binding-document.json b/services/ontology/schemas/binding-document.json
index 41c0f4e0f..919f171da 100644
--- a/services/ontology/schemas/binding-document.json
+++ b/services/ontology/schemas/binding-document.json
@@ -15,21 +15,7 @@
},
"data": {
"type": "object",
- "description": "Format dependent payload for the binding document",
- "oneOf": [
- {
- "$ref": "#/definitions/IdDocumentData"
- },
- {
- "$ref": "#/definitions/PhotographData"
- },
- {
- "$ref": "#/definitions/SocialConnectionData"
- },
- {
- "$ref": "#/definitions/SelfData"
- }
- ]
+ "description": "Format dependent payload for the binding document"
},
"signatures": {
"type": "array",
@@ -39,6 +25,44 @@
}
}
},
+ "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",
@@ -73,23 +97,33 @@
"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": ["name"],
+ "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": ["name"],
+ "required": ["kind", "name"],
"additionalProperties": false
},
"Signature": {
From 2a49fdae6609aceb2d3c14228151f6501192e2b3 Mon Sep 17 00:00:00 2001
From: coodos
Date: Mon, 23 Feb 2026 16:34:12 +0530
Subject: [PATCH 10/10] fix: vulenrabilities
---
.../dev-sandbox/src/routes/+page.svelte | 20 ++++++--
.../src/core/protocol/graphql-server.ts | 34 ++++++++++---
.../services/BindingDocumentService.spec.ts | 51 ++++++++++++++++++-
.../src/services/BindingDocumentService.ts | 5 +-
.../api/src/controllers/CurrencyController.ts | 20 ++++++--
.../api/src/services/CurrencyService.ts | 17 +++++--
.../api/src/services/LedgerService.ts | 11 +---
.../currency/create-currency-modal.tsx | 28 +++++-----
.../components/currency/transfer-modal.tsx | 41 ++++++++++++---
platforms/ecurrency/client/vite.config.ts | 1 -
10 files changed, 172 insertions(+), 56 deletions(-)
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/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()],