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 && ( -
- + <> +
+ +

+ When enabled, only users who are members of the currency's group can have negative balances. Non-members must maintain a positive balance. +

+
+ +
+
+ )} {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"} -
- )} -
{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

- - {#if provisionError} -

{provisionError}

- {/if} - {#if provisionSuccess} -
+ + + + +
+
+

Provision new eVault

+ + {#if provisionError} +

{provisionError}

+ {/if} + {#if provisionSuccess} +
+

+ W3ID: + {provisionSuccess.w3id} + +

+

+ eVault URI: + {provisionSuccess.uri} + +

+
+ {/if} +
+ + {#if identities.length > 0} +
+

Selected identity

+ +
+ +
+

Paste any w3ds URI (auth, sign, voting)

- W3ID: - {provisionSuccess.w3id} - + 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} +

+ qrFileInputEl?.click()} > -

-
- {/if} -
+ {qrDecodeBusy ? "Decoding…" : "Upload QR image"} + + or paste an image (Ctrl+V) +
+ {#if qrDecodeError} +

{qrDecodeError}

+ {/if} + + + {#if actionError} +

{actionError}

+ {/if} + {#if actionSuccess} +

{actionSuccess}

+ {/if} +
- {#if identities.length > 0} -
-

Selected identity

- -
+
+

Sign payload

+

Sign a string with the selected identity's key.

+ + + {#if signError} +

{signError}

+ {/if} + {#if signResult} +

+ Signature: + {signResult} + +

+ {/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

+
+ -
+
+ - {qrDecodeBusy ? "Decoding…" : "Upload QR image"} - - or paste an image (Ctrl+V) +
- {#if qrDecodeError} -

{qrDecodeError}

- {/if} - - - {#if actionError} -

{actionError}

- {/if} - {#if actionSuccess} -

{actionSuccess}

+ {#if schemasError} +

{schemasError}

{/if} +
+ + +
+ {#if selectedOntologyId} +
+ + {#if pageLoading} + Loading… + {:else} + {pageOffset + 1}–{pageOffset + envelopes.length} of {totalCount} + {/if} + +
+ + +
+
-
-

Sign payload

-

Sign a string with the selected identity's key.

- - - {#if signError} -

{signError}

- {/if} - {#if signResult} -

- Signature: - {signResult} - -

- {/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)} +
+
toggleExpand(env.id)} + onkeydown={(e) => e.key === "Enter" && toggleExpand(env.id)} + > +
+ {env.id} + {env.ontology} +
+ +
+
+
+
{@html highlightJson(jsonStr)}
+ {#if !isExpanded} +
+ {/if} +
+
+
+ {/each} + + {/if} {/if} +
+ +
+
+

Sandbox Config

+

Custom URLs are saved to localStorage and take precedence over environment defaults.

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

Registry: {registryUrl}

+

Provisioner: {provisionerUrl}

+

Ontology: {ontologyUrl}

+
+
+