diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index 2de2d7447..36b6542b4 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -73,9 +73,10 @@ A **MetaEnvelope** is the top-level container for an entity (post, user, message Each field in a MetaEnvelope becomes a separate **Envelope** node in Neo4j: - **id**: Unique identifier -- **ontology**: The field name from the ontology schema (e.g., "content", "authorId", "createdAt") - this identifies which field in the schema this envelope represents +- **fieldKey**: The field name from the payload (e.g., "content", "authorId", "createdAt") - this identifies which field in the payload this envelope represents +- **ontology**: Alias for fieldKey (kept for backward compatibility) - **value**: The actual field value (string, number, object, array) -- **valueType**: Type of the value ("string", "number", "object", "array") - cached from the ontology schema for optimization purposes +- **valueType**: Type of the value ("string", "number", "object", "array") ### Storage Structure @@ -97,24 +98,29 @@ This flat graph structure allows: ## GraphQL API -eVault exposes a GraphQL API at `/graphql` for all data operations. +eVault exposes a GraphQL API at `/graphql` for all data operations. All operations require the `X-ENAME` header to identify the eVault owner. + +**Required Header for all operations:** +```http +X-ENAME: @user-a.w3id +``` ### Queries -#### getMetaEnvelopeById +#### metaEnvelope -Retrieve a specific MetaEnvelope by its global ID. +Retrieve a single MetaEnvelope by its ID. **Query**: ```graphql query { - getMetaEnvelopeById(id: "global-id-123") { + metaEnvelope(id: "global-id-123") { id ontology parsed envelopes { id - ontology + fieldKey value valueType } @@ -122,50 +128,65 @@ query { } ``` -**Headers Required**: -- `X-ENAME`: The W3ID of the eVault owner -- `Authorization: Bearer `: Platform authentication token +#### metaEnvelopes -#### findMetaEnvelopesByOntology - -Find all MetaEnvelopes of a specific ontology type. +Retrieve MetaEnvelopes with cursor-based pagination and optional filtering. **Query**: ```graphql query { - findMetaEnvelopesByOntology(ontology: "550e8400-e29b-41d4-a716-446655440001") { - id - ontology - parsed + metaEnvelopes( + filter: { + ontologyId: "550e8400-e29b-41d4-a716-446655440001" + search: { + term: "hello" + caseSensitive: false + mode: CONTAINS + } + } + first: 10 + after: "cursor-string" + ) { + edges { + cursor + node { + id + ontology + parsed + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount } } ``` -#### searchMetaEnvelopes +**Filter Options**: +- `ontologyId`: Filter by ontology schema ID +- `search.term`: Search term to match against envelope values +- `search.caseSensitive`: Whether search is case-sensitive (default: false) +- `search.fields`: Specific field names to search within (optional) +- `search.mode`: `CONTAINS`, `STARTS_WITH`, or `EXACT` (default: CONTAINS) -Search MetaEnvelopes by content within a specific ontology. The `term` parameter performs case-sensitive text matching against the string values in any of the envelopes within MetaEnvelopes of the specified ontology. The search looks for the term within envelope values (not field names). - -**Query**: -```graphql -query { - searchMetaEnvelopes(ontology: "550e8400-e29b-41d4-a716-446655440001", term: "hello") { - id - ontology - parsed - } -} -``` +**Pagination**: +- `first` / `after`: Forward pagination +- `last` / `before`: Backward pagination ### Mutations -#### storeMetaEnvelope +#### createMetaEnvelope -Store a new MetaEnvelope in the eVault. +Create a new MetaEnvelope. Returns a structured payload with the created entity or errors. **Mutation**: ```graphql mutation { - storeMetaEnvelope(input: { + createMetaEnvelope(input: { ontology: "550e8400-e29b-41d4-a716-446655440001" payload: { content: "Hello, world!" @@ -179,56 +200,29 @@ mutation { id ontology parsed + envelopes { + id + fieldKey + value + } } - envelopes { - id - value - valueType + errors { + field + message + code } } } ``` -**Headers Required**: -- `X-ENAME`: The W3ID of the eVault owner (required) -- `Authorization: Bearer `: Optional, but recommended for webhook delivery - -**Response Structure**: +#### updateMetaEnvelope -The `envelopes` array in the response contains one envelope per field in the payload, where each envelope's `ontology` field contains the field name from the schema. For example, storing a post with `content`, `authorId`, and `createdAt` fields produces: - -```json -{ - "envelopes": [ - { - "ontology": "content", - "value": "Hello, world!" - }, - { - "ontology": "authorId", - "value": "..." - }, - { - "ontology": "createdAt", - "value": "2025-01-24T10:00:00Z" - }, - { - "ontology": "mediaUrls", - "value": [] - } - ], - "id": "9a84e965-2604-52bf-97a7-5c4f4151fea2" -} -``` - -#### updateMetaEnvelopeById - -Update an existing MetaEnvelope. +Update an existing MetaEnvelope. Returns a structured payload with the updated entity or errors. **Mutation**: ```graphql mutation { - updateMetaEnvelopeById( + updateMetaEnvelope( id: "global-id-123" input: { ontology: "550e8400-e29b-41d4-a716-446655440001" @@ -244,21 +238,50 @@ mutation { ontology parsed } + errors { + message + code + } } } ``` -#### deleteMetaEnvelope +#### removeMetaEnvelope -Delete a MetaEnvelope and all its Envelopes. +Delete a MetaEnvelope and all its Envelopes. Returns a structured payload confirming deletion. **Mutation**: ```graphql mutation { - deleteMetaEnvelope(id: "global-id-123") + removeMetaEnvelope(id: "global-id-123") { + deletedId + success + errors { + message + code + } + } } ``` +### Legacy API + +The following queries and mutations are preserved for backward compatibility but are considered legacy. New integrations should use the idiomatic API above. + +#### Legacy Queries + +- `getMetaEnvelopeById(id: String!)` - Use `metaEnvelope(id: ID!)` instead +- `findMetaEnvelopesByOntology(ontology: String!)` - Use `metaEnvelopes(filter: {ontologyId: ...})` instead +- `searchMetaEnvelopes(ontology: String!, term: String!)` - Use `metaEnvelopes(filter: {search: ...})` instead +- `getAllEnvelopes` - Returns all envelopes (no pagination) + +#### Legacy Mutations + +- `storeMetaEnvelope(input: MetaEnvelopeInput!)` - Use `createMetaEnvelope` instead +- `updateMetaEnvelopeById(id: String!, input: MetaEnvelopeInput!)` - Use `updateMetaEnvelope` instead +- `deleteMetaEnvelope(id: String!)` - Use `removeMetaEnvelope` instead (returns `Boolean!`) +- `updateEnvelopeValue(envelopeId: String!, newValue: JSON!)` - Update individual envelope value + ## HTTP API ### /whois @@ -451,18 +474,18 @@ The provisioning layer supports shared tenancy (multiple W3IDs can be provisione ## API Examples -### Storing a Post +### Creating a Post ```bash curl -X POST http://localhost:4000/graphql \ -H "Content-Type: application/json" \ -H "X-ENAME: @user-a.w3id" \ -d '{ - "query": "mutation { storeMetaEnvelope(input: { ontology: \"550e8400-e29b-41d4-a716-446655440001\", payload: { content: \"Hello!\", authorId: \"...\", createdAt: \"2025-01-24T10:00:00Z\" }, acl: [\"*\"] }) { metaEnvelope { id ontology } } }" + "query": "mutation { createMetaEnvelope(input: { ontology: \"550e8400-e29b-41d4-a716-446655440001\", payload: { content: \"Hello!\", authorId: \"...\", createdAt: \"2025-01-24T10:00:00Z\" }, acl: [\"*\"] }) { metaEnvelope { id ontology } errors { message } } }" }' ``` -### Querying Posts +### Querying Posts with Pagination ```bash curl -X POST http://localhost:4000/graphql \ @@ -470,7 +493,29 @@ curl -X POST http://localhost:4000/graphql \ -H "X-ENAME: @user-a.w3id" \ -H "Authorization: Bearer " \ -d '{ - "query": "{ findMetaEnvelopesByOntology(ontology: \"550e8400-e29b-41d4-a716-446655440001\") { id parsed } }" + "query": "{ metaEnvelopes(filter: { ontologyId: \"550e8400-e29b-41d4-a716-446655440001\" }, first: 10) { edges { node { id parsed } } pageInfo { hasNextPage endCursor } } }" + }' +``` + +### Searching Posts + +```bash +curl -X POST http://localhost:4000/graphql \ + -H "Content-Type: application/json" \ + -H "X-ENAME: @user-a.w3id" \ + -d '{ + "query": "{ metaEnvelopes(filter: { ontologyId: \"550e8400-e29b-41d4-a716-446655440001\", search: { term: \"hello\", mode: CONTAINS } }, first: 10) { edges { node { id parsed } } totalCount } }" + }' +``` + +### Deleting a Post + +```bash +curl -X POST http://localhost:4000/graphql \ + -H "Content-Type: application/json" \ + -H "X-ENAME: @user-a.w3id" \ + -d '{ + "query": "mutation { removeMetaEnvelope(id: \"global-id-123\") { deletedId success errors { message } } }" }' ``` diff --git a/docs/docs/W3DS Basics/getting-started.md b/docs/docs/W3DS Basics/getting-started.md index 63ff16335..e4330a29d 100644 --- a/docs/docs/W3DS Basics/getting-started.md +++ b/docs/docs/W3DS Basics/getting-started.md @@ -106,13 +106,17 @@ The adapter makes an HTTP POST request to the eVault's GraphQL endpoint. The req **GraphQL Mutation**: ```graphql -mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { - storeMetaEnvelope(input: $input) { +mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { metaEnvelope { id ontology parsed } + errors { + message + code + } } } ``` @@ -133,7 +137,7 @@ mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { } ``` -**Response**: The eVault returns the created MetaEnvelope with a global ID that should be stored for future reference. +**Response**: The eVault returns a payload containing the created MetaEnvelope with a global ID (or errors if the operation failed). The ID should be stored for future reference. **Implementation Notes**: - Use any HTTP client library in your language (requests in Python, http in Go, fetch in JavaScript, etc.) diff --git a/infrastructure/evault-core/src/core/db/db.service.ts b/infrastructure/evault-core/src/core/db/db.service.ts index 9c2c78140..90cb48f84 100644 --- a/infrastructure/evault-core/src/core/db/db.service.ts +++ b/infrastructure/evault-core/src/core/db/db.service.ts @@ -5,10 +5,16 @@ import type { AppendEnvelopeOperationLogParams, Envelope, EnvelopeOperationLogEntry, + FindMetaEnvelopesPaginatedOptions, GetAllEnvelopesResult, GetEnvelopeOperationLogsResult, MetaEnvelope, + MetaEnvelopeConnection, + MetaEnvelopeEdge, + MetaEnvelopeFilterInput, MetaEnvelopeResult, + MetaEnvelopeSearchInput, + PageInfo, SearchMetaEnvelopesResult, StoreMetaEnvelopeResult, } from "./types"; @@ -1040,6 +1046,228 @@ export class DbService { return { logs, nextCursor, hasMore }; } + /** + * Finds meta-envelopes with Relay-style cursor pagination and optional filtering. + * Supports filtering by ontology and searching within envelope values. + * @param eName - The eName identifier for multi-tenant isolation + * @param options - Pagination and filter options + * @returns A connection object with edges, pageInfo, and totalCount + */ + async findMetaEnvelopesPaginated< + T extends Record = Record, + >( + eName: string, + options: FindMetaEnvelopesPaginatedOptions = {}, + ): Promise> { + if (!eName) { + throw new Error("eName is required for finding meta-envelopes"); + } + + const { filter, first, after, last, before } = options; + + // Validate pagination parameters + if (first !== undefined && last !== undefined) { + throw new Error("Cannot specify both 'first' and 'last'"); + } + if (after !== undefined && before !== undefined) { + throw new Error("Cannot specify both 'after' and 'before'"); + } + // Reject mixed-direction cursor usage + if (first !== undefined && before !== undefined) { + throw new Error("Cannot use 'first' with 'before' - use 'first' with 'after' for forward pagination"); + } + if (last !== undefined && after !== undefined) { + throw new Error("Cannot use 'last' with 'after' - use 'last' with 'before' for backward pagination"); + } + + // Default limit + const limit = Math.min( + Math.max(1, first ?? last ?? 20), + 100, + ); + const isBackward = last !== undefined; + + // Build WHERE conditions + const conditions: string[] = ["m.eName = $eName"]; + const params: Record = { eName }; + + // Filter by ontology + if (filter?.ontologyId) { + conditions.push("m.ontology = $ontologyId"); + params.ontologyId = filter.ontologyId; + } + + // Build search condition if provided + let searchCondition = ""; + if (filter?.search?.term) { + const search = filter.search; + const caseSensitive = search.caseSensitive ?? false; + const mode = search.mode ?? "CONTAINS"; + const fields = search.fields; + + // Build the value match expression based on mode + let matchExpr: string; + if (caseSensitive) { + switch (mode) { + case "EXACT": + matchExpr = "e.value = $searchTerm"; + break; + case "STARTS_WITH": + matchExpr = "e.value STARTS WITH $searchTerm"; + break; + default: + // CONTAINS is the default mode + matchExpr = "e.value CONTAINS $searchTerm"; + break; + } + } else { + switch (mode) { + case "EXACT": + matchExpr = "toLower(toString(e.value)) = toLower($searchTerm)"; + break; + case "STARTS_WITH": + matchExpr = "toLower(toString(e.value)) STARTS WITH toLower($searchTerm)"; + break; + default: + // CONTAINS is the default mode + matchExpr = "toLower(toString(e.value)) CONTAINS toLower($searchTerm)"; + break; + } + } + + params.searchTerm = search.term; + + // Add field restriction if specified + if (fields && fields.length > 0) { + params.searchFields = fields; + searchCondition = ` + AND EXISTS { + MATCH (m)-[:LINKS_TO]->(e:Envelope) + WHERE e.ontology IN $searchFields AND ${matchExpr} + } + `; + } else { + searchCondition = ` + AND EXISTS { + MATCH (m)-[:LINKS_TO]->(e:Envelope) + WHERE ${matchExpr} + } + `; + } + } + + // Handle cursor pagination + let cursorCondition = ""; + if (after) { + // Decode cursor (format: base64 encoded "id") + const cursorId = Buffer.from(after, "base64").toString("utf-8"); + params.cursorId = cursorId; + cursorCondition = isBackward + ? "AND m.id < $cursorId" + : "AND m.id > $cursorId"; + } else if (before) { + const cursorId = Buffer.from(before, "base64").toString("utf-8"); + params.cursorId = cursorId; + cursorCondition = isBackward + ? "AND m.id > $cursorId" + : "AND m.id < $cursorId"; + } + + // Get total count (without pagination) + const countQuery = ` + MATCH (m:MetaEnvelope) + WHERE ${conditions.join(" AND ")} + ${searchCondition} + RETURN count(m) AS total + `; + const countResult = await this.runQueryInternal(countQuery, params); + const totalCount = countResult.records[0]?.get("total")?.toNumber?.() ?? + countResult.records[0]?.get("total") ?? 0; + + // Build main query with pagination + const orderDirection = isBackward ? "DESC" : "ASC"; + const mainQuery = ` + MATCH (m:MetaEnvelope) + WHERE ${conditions.join(" AND ")} + ${searchCondition} + ${cursorCondition} + WITH m + ORDER BY m.id ${orderDirection} + LIMIT $limitPlusOne + MATCH (m)-[:LINKS_TO]->(e:Envelope) + RETURN m.id AS id, m.ontology AS ontology, m.acl AS acl, collect(e) AS envelopes + `; + + params.limitPlusOne = neo4j.int(limit + 1); + const result = await this.runQueryInternal(mainQuery, params); + + // Process results + let records = result.records; + const hasExtraRecord = records.length > limit; + if (hasExtraRecord) { + records = records.slice(0, limit); + } + + // Reverse if backward pagination to maintain correct order + if (isBackward) { + records = records.reverse(); + } + + // Build edges + const edges: MetaEnvelopeEdge[] = records.map((record) => { + const envelopes = record + .get("envelopes") + .map((node: any): Envelope => { + const properties = node.properties; + return { + id: properties.id, + ontology: properties.ontology, + value: deserializeValue( + properties.value, + properties.valueType, + ) as T[keyof T], + valueType: properties.valueType, + }; + }); + + const parsed = envelopes.reduce( + (acc: T, envelope: Envelope) => { + (acc as any)[envelope.ontology] = envelope.value; + return acc; + }, + {} as T, + ); + + const id = record.get("id"); + const node: MetaEnvelopeResult = { + id, + ontology: record.get("ontology"), + acl: record.get("acl"), + envelopes, + parsed, + }; + + return { + cursor: Buffer.from(id).toString("base64"), + node, + }; + }); + + // Build pageInfo + const pageInfo: PageInfo = { + hasNextPage: isBackward ? (before !== undefined) : hasExtraRecord, + hasPreviousPage: isBackward ? hasExtraRecord : (after !== undefined), + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, + }; + + return { + edges, + pageInfo, + totalCount, + }; + } + /** * Closes the database connection. */ diff --git a/infrastructure/evault-core/src/core/db/types.ts b/infrastructure/evault-core/src/core/db/types.ts index e92396145..e7f3b2a9d 100644 --- a/infrastructure/evault-core/src/core/db/types.ts +++ b/infrastructure/evault-core/src/core/db/types.ts @@ -105,3 +105,68 @@ export type GetEnvelopeOperationLogsResult = { nextCursor: string | null; hasMore: boolean; }; + +// ============================================================================ +// Pagination Types for Idiomatic GraphQL API +// ============================================================================ + +/** + * Search mode for MetaEnvelope queries. + */ +export type SearchMode = "CONTAINS" | "STARTS_WITH" | "EXACT"; + +/** + * Search input for MetaEnvelope queries. + */ +export type MetaEnvelopeSearchInput = { + term?: string; + caseSensitive?: boolean; + fields?: string[]; + mode?: SearchMode; +}; + +/** + * Filter input for MetaEnvelope queries. + */ +export type MetaEnvelopeFilterInput = { + ontologyId?: string; + search?: MetaEnvelopeSearchInput; +}; + +/** + * Pagination info for Relay-style connections. + */ +export type PageInfo = { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + endCursor: string | null; +}; + +/** + * Edge type for MetaEnvelope connections. + */ +export type MetaEnvelopeEdge = Record> = { + cursor: string; + node: MetaEnvelopeResult; +}; + +/** + * Connection type for paginated MetaEnvelope queries. + */ +export type MetaEnvelopeConnection = Record> = { + edges: MetaEnvelopeEdge[]; + pageInfo: PageInfo; + totalCount: number; +}; + +/** + * Options for paginated MetaEnvelope queries. + */ +export type FindMetaEnvelopesPaginatedOptions = { + filter?: MetaEnvelopeFilterInput; + first?: number; + after?: string; + last?: number; + before?: string; +}; diff --git a/infrastructure/evault-core/src/core/protocol/examples/examples.ts b/infrastructure/evault-core/src/core/protocol/examples/examples.ts index f36eb1944..51bb2dfdf 100644 --- a/infrastructure/evault-core/src/core/protocol/examples/examples.ts +++ b/infrastructure/evault-core/src/core/protocol/examples/examples.ts @@ -3,17 +3,19 @@ export const exampleQueries = ` # This GraphiQL is pre-loaded with real examples you can try instantly. # # Each object is stored as a MetaEnvelope, which is a flat graph of Envelopes. -# You can issue credentials, store data, update specific fields, or search by -# content. +# You can create, query, update, and delete data with pagination and filtering. # -# ๐Ÿ‘‡ Scroll down and uncomment the examples you want to run. Let's go ๐Ÿš€ +# IMPORTANT: All operations require the X-ENAME header to be set. +# Set it in the HTTP Headers panel below (e.g., {"X-ENAME": "@your-w3id"}) +# +# Scroll down and uncomment the examples you want to run! ################################################################################ -# โœ… 1. Store a SocialMediaPost +# 1. Create a MetaEnvelope (e.g., a SocialMediaPost) ################################################################################ # mutation { -# storeMetaEnvelope(input: { +# createMetaEnvelope(input: { # ontology: "SocialMediaPost", # payload: { # text: "gm world!", @@ -21,34 +23,39 @@ export const exampleQueries = ` # dateCreated: "2025-04-10T10:00:00Z", # userLikes: ["@user1", "@user2"] # }, -# acl: ["@d1fa5cb1-6178-534b-a096-59794d485f65"] # Who can access this object +# acl: ["*"] # Who can access this object ("*" = public) # }) { # metaEnvelope { # id # ontology # parsed +# envelopes { +# id +# fieldKey +# value +# valueType +# } # } -# envelopes { -# id -# ontology -# value -# valueType +# errors { +# field +# message +# code # } # } # } ################################################################################ -# ๐Ÿ” 2. Retrieve a MetaEnvelope by ID +# 2. Retrieve a Single MetaEnvelope by ID ################################################################################ # query { -# getMetaEnvelopeById(id: "YOUR_META_ENVELOPE_ID_HERE") { +# metaEnvelope(id: "YOUR_META_ENVELOPE_ID_HERE") { # id # ontology # parsed # envelopes { # id -# ontology +# fieldKey # value # valueType # } @@ -56,89 +63,166 @@ export const exampleQueries = ` # } ################################################################################ -# ๐Ÿ”Ž 3. Search MetaEnvelopes by Ontology + Keyword +# 3. Query MetaEnvelopes with Pagination ################################################################################ # query { -# searchMetaEnvelopes(ontology: "SocialMediaPost", term: "gm") { -# id -# parsed -# envelopes { -# ontology -# value +# metaEnvelopes( +# filter: { +# ontologyId: "SocialMediaPost" # } +# first: 10 +# ) { +# edges { +# cursor +# node { +# id +# ontology +# parsed +# } +# } +# pageInfo { +# hasNextPage +# hasPreviousPage +# startCursor +# endCursor +# } +# totalCount # } # } ################################################################################ -# ๐Ÿ“š 4. Find All MetaEnvelope IDs by Ontology +# 4. Search MetaEnvelopes with Filtering ################################################################################ # query { -# findMetaEnvelopesByOntology(ontology: "SocialMediaPost") -# } - -################################################################################ -# โœ๏ธ 5. Update a Single Envelope's Value -################################################################################ - -# mutation { -# updateEnvelopeValue( -# envelopeId: "YOUR_ENVELOPE_ID_HERE", -# newValue: "Updated value" -# ) -# } - -################################################################################ -# ๐Ÿงผ 6. Delete a MetaEnvelope (and all linked Envelopes) -################################################################################ - -# mutation { -# deleteMetaEnvelope(id: "YOUR_META_ENVELOPE_ID_HERE") +# metaEnvelopes( +# filter: { +# ontologyId: "SocialMediaPost" +# search: { +# term: "gm" +# caseSensitive: false +# mode: CONTAINS +# } +# } +# first: 10 +# ) { +# edges { +# node { +# id +# parsed +# } +# } +# totalCount +# } # } ################################################################################ -# ๐Ÿ”„ 7. Update a MetaEnvelope by ID +# 5. Update a MetaEnvelope ################################################################################ # mutation { -# updateMetaEnvelopeById( +# updateMetaEnvelope( # id: "YOUR_META_ENVELOPE_ID_HERE", # input: { # ontology: "SocialMediaPost", # payload: { -# text: "Updated post content", +# text: "Updated post content!", # image: "https://example.com/new-pic.jpg", # dateCreated: "2025-04-10T10:00:00Z", # userLikes: ["@user1", "@user2", "@user3"] # }, -# acl: ["@d1fa5cb1-6178-534b-a096-59794d485f65"] +# acl: ["*"] # } # ) { # metaEnvelope { # id # ontology # parsed +# envelopes { +# fieldKey +# value +# } # } -# envelopes { -# id -# ontology -# value -# valueType +# errors { +# message +# code +# } +# } +# } + +################################################################################ +# 6. Delete a MetaEnvelope +################################################################################ + +# mutation { +# removeMetaEnvelope(id: "YOUR_META_ENVELOPE_ID_HERE") { +# deletedId +# success +# errors { +# message +# code # } # } # } ################################################################################ -# ๐Ÿ“ฆ 8. List All Envelopes in the System +# 7. Paginate Through Results (using cursor) ################################################################################ # query { -# getAllEnvelopes { -# id -# ontology -# value -# valueType +# metaEnvelopes( +# filter: { ontologyId: "SocialMediaPost" } +# first: 5 +# after: "CURSOR_FROM_PREVIOUS_RESPONSE" +# ) { +# edges { +# cursor +# node { +# id +# parsed +# } +# } +# pageInfo { +# hasNextPage +# endCursor +# } +# } +# } + +################################################################################ +# LEGACY API (for backward compatibility) +# These endpoints still work but prefer the new API above +################################################################################ + +# # Legacy: Store a MetaEnvelope +# mutation { +# storeMetaEnvelope(input: { +# ontology: "SocialMediaPost", +# payload: { text: "Hello!" }, +# acl: ["*"] +# }) { +# metaEnvelope { id } # } # } + +# # Legacy: Get by ID +# query { +# getMetaEnvelopeById(id: "...") { id parsed } +# } + +# # Legacy: Find by ontology (no pagination) +# query { +# findMetaEnvelopesByOntology(ontology: "SocialMediaPost") { id } +# } + +# # Legacy: Search (no pagination) +# query { +# searchMetaEnvelopes(ontology: "SocialMediaPost", term: "hello") { id } +# } + +# # Legacy: Delete (returns boolean) +# mutation { +# deleteMetaEnvelope(id: "...") +# } `; diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index 95638cf7e..456732e8b 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -138,7 +138,64 @@ export class GraphQLServer { const resolvers = { JSON: require("graphql-type-json"), + // Field resolver for Envelope.fieldKey (alias for ontology) + Envelope: { + fieldKey: (parent: any) => parent.ontology, + }, + Query: { + // ============================================================ + // NEW IDIOMATIC API + // ============================================================ + + // Retrieve a single MetaEnvelope by ID + metaEnvelope: this.accessGuard.middleware( + (_: any, { id }: { id: string }, context: VaultContext) => { + if (!context.eName) { + throw new Error("X-ENAME header is required"); + } + return this.db.findMetaEnvelopeById(id, context.eName); + } + ), + + // Retrieve MetaEnvelopes with pagination and filtering + metaEnvelopes: this.accessGuard.middleware( + async ( + _: any, + args: { + filter?: { + ontologyId?: string; + search?: { + term?: string; + caseSensitive?: boolean; + fields?: string[]; + mode?: "CONTAINS" | "STARTS_WITH" | "EXACT"; + }; + }; + first?: number; + after?: string; + last?: number; + before?: string; + }, + 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, + }); + } + ), + + // ============================================================ + // LEGACY API (preserved for backward compatibility) + // ============================================================ + getMetaEnvelopeById: this.accessGuard.middleware( (_: any, { id }: { id: string }, context: VaultContext) => { if (!context.eName) { @@ -180,6 +237,268 @@ export class GraphQLServer { }, Mutation: { + // ============================================================ + // NEW IDIOMATIC API + // ============================================================ + + // Create a new MetaEnvelope with structured payload + createMetaEnvelope: this.accessGuard.middleware( + async ( + _: any, + { + input, + }: { + input: { + ontology: string; + payload: any; + acl: string[]; + }; + }, + context: VaultContext + ) => { + if (!context.eName) { + return { + metaEnvelope: null, + errors: [{ message: "X-ENAME header is required", code: "MISSING_ENAME" }], + }; + } + + try { + const result = await this.db.storeMetaEnvelope( + { + ontology: input.ontology, + payload: input.payload, + acl: input.acl, + }, + input.acl, + context.eName + ); + + // Build the full metaEnvelope response + const metaEnvelope = { + id: result.metaEnvelope.id, + ontology: result.metaEnvelope.ontology, + envelopes: result.envelopes, + parsed: input.payload, + }; + + // Deliver webhooks for create operation + const requestingPlatform = context.tokenPayload?.platform || null; + const webhookPayload = { + id: result.metaEnvelope.id, + w3id: context.eName, + evaultPublicKey: this.evaultPublicKey, + data: input.payload, + schemaId: input.ontology, + }; + + // Delayed webhook delivery to prevent ping-pong + setTimeout(() => { + this.deliverWebhooks(requestingPlatform, webhookPayload); + }, 3_000); + + // Log envelope operation best-effort + const platform = context.tokenPayload?.platform ?? null; + const envelopeHash = computeEnvelopeHash({ + id: result.metaEnvelope.id, + ontology: input.ontology, + payload: input.payload, + }); + this.db + .appendEnvelopeOperationLog({ + eName: context.eName, + metaEnvelopeId: result.metaEnvelope.id, + envelopeHash, + operation: "create", + platform, + timestamp: new Date().toISOString(), + ontology: input.ontology, + }) + .catch((err) => + console.error("appendEnvelopeOperationLog (create) failed:", err) + ); + + return { + metaEnvelope, + errors: [], + }; + } catch (error) { + console.error("Error in createMetaEnvelope:", error); + return { + metaEnvelope: null, + errors: [ + { + message: error instanceof Error ? error.message : "Failed to create MetaEnvelope", + code: "CREATE_FAILED", + }, + ], + }; + } + } + ), + + // Update an existing MetaEnvelope with structured payload + updateMetaEnvelope: this.accessGuard.middleware( + async ( + _: any, + { + id, + input, + }: { + id: string; + input: { + ontology: string; + payload: any; + acl: string[]; + }; + }, + context: VaultContext + ) => { + if (!context.eName) { + return { + metaEnvelope: null, + errors: [{ message: "X-ENAME header is required", code: "MISSING_ENAME" }], + }; + } + + try { + const result = await this.db.updateMetaEnvelopeById( + id, + { + ontology: input.ontology, + payload: input.payload, + acl: input.acl, + }, + input.acl, + context.eName + ); + + // Build the full metaEnvelope response + const metaEnvelope = { + id: result.metaEnvelope.id, + ontology: result.metaEnvelope.ontology, + envelopes: result.envelopes, + parsed: input.payload, + }; + + // Deliver webhooks for update operation + const requestingPlatform = context.tokenPayload?.platform || null; + const webhookPayload = { + id, + w3id: context.eName, + evaultPublicKey: this.evaultPublicKey, + data: input.payload, + schemaId: input.ontology, + }; + + // Fire and forget webhook delivery + this.deliverWebhooks(requestingPlatform, webhookPayload); + + // Log envelope operation best-effort + const platform = context.tokenPayload?.platform ?? null; + const envelopeHash = computeEnvelopeHash({ + id, + ontology: input.ontology, + payload: input.payload, + }); + this.db + .appendEnvelopeOperationLog({ + eName: context.eName, + metaEnvelopeId: id, + envelopeHash, + operation: "update", + platform, + timestamp: new Date().toISOString(), + ontology: input.ontology, + }) + .catch((err) => + console.error("appendEnvelopeOperationLog (update) failed:", err) + ); + + return { + metaEnvelope, + errors: [], + }; + } catch (error) { + console.error("Error in updateMetaEnvelope:", error); + return { + metaEnvelope: null, + errors: [ + { + 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) => { + if (!context.eName) { + return { + deletedId: id, + success: false, + errors: [{ message: "X-ENAME header is required", code: "MISSING_ENAME" }], + }; + } + + try { + const meta = await this.db.findMetaEnvelopeById(id, context.eName); + if (!meta) { + return { + deletedId: id, + success: false, + 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); + this.db + .appendEnvelopeOperationLog({ + eName: context.eName, + metaEnvelopeId: id, + envelopeHash, + operation: "delete", + platform, + timestamp: new Date().toISOString(), + ontology: meta.ontology, + }) + .catch((err) => + console.error("appendEnvelopeOperationLog (delete) failed:", err) + ); + + return { + deletedId: id, + success: true, + errors: [], + }; + } catch (error) { + console.error("Error in removeMetaEnvelope:", error); + return { + deletedId: id, + success: false, + errors: [ + { + message: error instanceof Error ? error.message : "Failed to delete MetaEnvelope", + code: "DELETE_FAILED", + }, + ], + }; + } + } + ), + + // ============================================================ + // LEGACY API (preserved for backward compatibility) + // ============================================================ + storeMetaEnvelope: this.accessGuard.middleware( async ( _: any, diff --git a/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts b/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts new file mode 100644 index 000000000..f3c4bb359 --- /dev/null +++ b/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts @@ -0,0 +1,771 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as jose from "jose"; +import { + setupE2ETestServer, + teardownE2ETestServer, + provisionTestEVault, + makeGraphQLRequest, + type E2ETestServer, + type ProvisionedEVault, +} from "../../test-utils/e2e-setup"; +import { getSharedTestKeyPair } from "../../test-utils/shared-test-keys"; + +describe("Idiomatic GraphQL API", () => { + let server: E2ETestServer; + let evault: ProvisionedEVault; + let authToken: string; + + beforeAll(async () => { + server = await setupE2ETestServer(); + evault = await provisionTestEVault(server); + + // Create auth token for tests that require authentication + const { privateKey } = await getSharedTestKeyPair(); + authToken = await new jose.SignJWT({ platform: "http://localhost:3000" }) + .setProtectedHeader({ alg: "ES256", kid: "entropy-key-1" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(privateKey); + }, 120000); + + afterAll(async () => { + await teardownE2ETestServer(server); + }); + + // Helper to get auth headers + const getAuthHeaders = () => ({ + "X-ENAME": evault.w3id, + "Authorization": `Bearer ${authToken}`, + }); + + describe("createMetaEnvelope mutation", () => { + it("should create a MetaEnvelope and return structured payload", async () => { + const testData = { + content: "Hello from idiomatic API!", + authorId: "@test-author", + createdAt: "2025-02-04T10:00:00Z", + }; + const testOntology = "IdiomaticTestPost"; + + const mutation = ` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { + id + ontology + parsed + envelopes { + id + fieldKey + value + valueType + } + } + errors { + field + message + code + } + } + } + `; + + const result = await makeGraphQLRequest(server, mutation, { + input: { + ontology: testOntology, + payload: testData, + acl: ["*"], + }, + }, { + "X-ENAME": evault.w3id, + }); + + expect(result.createMetaEnvelope).toBeDefined(); + expect(result.createMetaEnvelope.errors).toEqual([]); + expect(result.createMetaEnvelope.metaEnvelope).toBeDefined(); + expect(result.createMetaEnvelope.metaEnvelope.id).toBeDefined(); + expect(result.createMetaEnvelope.metaEnvelope.ontology).toBe(testOntology); + expect(result.createMetaEnvelope.metaEnvelope.parsed).toEqual(testData); + + // Verify envelopes have fieldKey + const envelopes = result.createMetaEnvelope.metaEnvelope.envelopes; + expect(envelopes.length).toBe(3); + + const contentEnvelope = envelopes.find((e: { fieldKey: string; value: string }) => e.fieldKey === "content"); + expect(contentEnvelope).toBeDefined(); + expect(contentEnvelope.value).toBe(testData.content); + }); + + it("should throw GraphQL error when X-ENAME is missing", async () => { + const mutation = ` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { + id + } + errors { + message + code + } + } + } + `; + + // X-ENAME validation happens at middleware level, throws GraphQL error + await expect(makeGraphQLRequest(server, mutation, { + input: { + ontology: "TestOntology", + payload: { test: "data" }, + acl: ["*"], + }, + }, { + // No X-ENAME header + })).rejects.toThrow(); + }); + }); + + describe("metaEnvelope query", () => { + let createdId: string; + + beforeAll(async () => { + // Create a test envelope first + const mutation = ` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { + id + } + } + } + `; + + const result = await makeGraphQLRequest(server, mutation, { + input: { + ontology: "QueryTestOntology", + payload: { title: "Query Test", body: "Test body content" }, + acl: ["*"], + }, + }, { + "X-ENAME": evault.w3id, + }); + + createdId = result.createMetaEnvelope.metaEnvelope.id; + }); + + it("should retrieve a MetaEnvelope by ID", async () => { + const query = ` + query MetaEnvelope($id: ID!) { + metaEnvelope(id: $id) { + id + ontology + parsed + envelopes { + id + fieldKey + value + } + } + } + `; + + const result = await makeGraphQLRequest(server, query, { + id: createdId, + }, getAuthHeaders()); + + expect(result.metaEnvelope).toBeDefined(); + expect(result.metaEnvelope.id).toBe(createdId); + expect(result.metaEnvelope.ontology).toBe("QueryTestOntology"); + expect(result.metaEnvelope.parsed.title).toBe("Query Test"); + expect(result.metaEnvelope.parsed.body).toBe("Test body content"); + }); + + it("should return null for non-existent ID", async () => { + const query = ` + query MetaEnvelope($id: ID!) { + metaEnvelope(id: $id) { + id + } + } + `; + + const result = await makeGraphQLRequest(server, query, { + id: "non-existent-id-12345", + }, getAuthHeaders()); + + expect(result.metaEnvelope).toBeNull(); + }); + }); + + describe("metaEnvelopes query with pagination", () => { + const testOntology = "PaginationTestOntology"; + + beforeAll(async () => { + // Create multiple test envelopes for pagination testing + const mutation = ` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { + id + } + } + } + `; + + for (let i = 0; i < 5; i++) { + await makeGraphQLRequest(server, mutation, { + input: { + ontology: testOntology, + payload: { index: i, content: `Test content ${i}` }, + acl: ["*"], + }, + }, { + "X-ENAME": evault.w3id, + }); + } + }); + + it("should return paginated results with pageInfo", async () => { + const query = ` + query MetaEnvelopes($filter: MetaEnvelopeFilterInput, $first: Int) { + metaEnvelopes(filter: $filter, first: $first) { + edges { + cursor + node { + id + ontology + parsed + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + `; + + const result = await makeGraphQLRequest(server, query, { + filter: { ontologyId: testOntology }, + first: 3, + }, getAuthHeaders()); + + expect(result.metaEnvelopes).toBeDefined(); + expect(result.metaEnvelopes.edges.length).toBe(3); + expect(result.metaEnvelopes.totalCount).toBe(5); + expect(result.metaEnvelopes.pageInfo.hasNextPage).toBe(true); + expect(result.metaEnvelopes.pageInfo.startCursor).toBeDefined(); + expect(result.metaEnvelopes.pageInfo.endCursor).toBeDefined(); + + // Each edge should have a cursor and node + for (const edge of result.metaEnvelopes.edges) { + expect(edge.cursor).toBeDefined(); + expect(edge.node.id).toBeDefined(); + expect(edge.node.ontology).toBe(testOntology); + } + }); + + it("should support cursor-based pagination with after", async () => { + const query = ` + query MetaEnvelopes($filter: MetaEnvelopeFilterInput, $first: Int, $after: String) { + metaEnvelopes(filter: $filter, first: $first, after: $after) { + edges { + cursor + node { + id + parsed + } + } + pageInfo { + hasNextPage + hasPreviousPage + } + } + } + `; + + // First page + const firstPage = await makeGraphQLRequest(server, query, { + filter: { ontologyId: testOntology }, + first: 2, + }, getAuthHeaders()); + + expect(firstPage.metaEnvelopes.edges.length).toBe(2); + const endCursor = firstPage.metaEnvelopes.edges[1].cursor; + + // Second page using cursor + const secondPage = await makeGraphQLRequest(server, query, { + filter: { ontologyId: testOntology }, + first: 2, + after: endCursor, + }, getAuthHeaders()); + + expect(secondPage.metaEnvelopes.edges.length).toBe(2); + expect(secondPage.metaEnvelopes.pageInfo.hasPreviousPage).toBe(true); + + // Verify different items on each page + const firstPageIds = firstPage.metaEnvelopes.edges.map((e: { node: { id: string } }) => e.node.id); + const secondPageIds = secondPage.metaEnvelopes.edges.map((e: { node: { id: string } }) => e.node.id); + + for (const id of secondPageIds) { + expect(firstPageIds).not.toContain(id); + } + }); + + it("should filter by ontologyId", async () => { + const query = ` + query MetaEnvelopes($filter: MetaEnvelopeFilterInput) { + metaEnvelopes(filter: $filter, first: 100) { + edges { + node { + ontology + } + } + totalCount + } + } + `; + + const result = await makeGraphQLRequest(server, query, { + filter: { ontologyId: testOntology }, + }, getAuthHeaders()); + + // All results should have the filtered ontology + for (const edge of result.metaEnvelopes.edges) { + expect(edge.node.ontology).toBe(testOntology); + } + }); + }); + + describe("metaEnvelopes query with search", () => { + const searchOntology = "SearchTestOntology"; + + beforeAll(async () => { + const mutation = ` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { id } + } + } + `; + + // Create test data with searchable content + const testItems = [ + { title: "Hello World", content: "This is a test post" }, + { title: "Goodbye World", content: "Another test content" }, + { title: "Hello Again", content: "More hello content here" }, + { title: "Unrelated", content: "Nothing special" }, + ]; + + for (const item of testItems) { + await makeGraphQLRequest(server, mutation, { + input: { + ontology: searchOntology, + payload: item, + acl: ["*"], + }, + }, { + "X-ENAME": evault.w3id, + }); + } + }); + + it("should search with CONTAINS mode (case-insensitive by default)", async () => { + const query = ` + query MetaEnvelopes($filter: MetaEnvelopeFilterInput) { + metaEnvelopes(filter: $filter, first: 10) { + edges { + node { + parsed + } + } + totalCount + } + } + `; + + const result = await makeGraphQLRequest(server, query, { + filter: { + ontologyId: searchOntology, + search: { + term: "hello", + mode: "CONTAINS", + }, + }, + }, getAuthHeaders()); + + expect(result.metaEnvelopes.totalCount).toBeGreaterThanOrEqual(2); + + // All results should contain "hello" somewhere + for (const edge of result.metaEnvelopes.edges) { + const parsed = edge.node.parsed; + const hasHello = + parsed.title?.toLowerCase().includes("hello") || + parsed.content?.toLowerCase().includes("hello"); + expect(hasHello).toBe(true); + } + }); + + it("should search within specific fields", async () => { + const query = ` + query MetaEnvelopes($filter: MetaEnvelopeFilterInput) { + metaEnvelopes(filter: $filter, first: 10) { + edges { + node { + parsed + } + } + totalCount + } + } + `; + + const result = await makeGraphQLRequest(server, query, { + filter: { + ontologyId: searchOntology, + search: { + term: "hello", + fields: ["title"], + }, + }, + }, getAuthHeaders()); + + // Should find "Hello World" and "Hello Again" but not the one with "hello" only in content + expect(result.metaEnvelopes.totalCount).toBeGreaterThanOrEqual(2); + }); + }); + + describe("updateMetaEnvelope mutation", () => { + let envelopeId: string; + + beforeAll(async () => { + const mutation = ` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { id } + } + } + `; + + const result = await makeGraphQLRequest(server, mutation, { + input: { + ontology: "UpdateTestOntology", + payload: { title: "Original Title", count: 0 }, + acl: ["*"], + }, + }, { + "X-ENAME": evault.w3id, + }); + + envelopeId = result.createMetaEnvelope.metaEnvelope.id; + }); + + it("should update a MetaEnvelope and return structured payload", async () => { + const mutation = ` + mutation UpdateMetaEnvelope($id: ID!, $input: MetaEnvelopeInput!) { + updateMetaEnvelope(id: $id, input: $input) { + metaEnvelope { + id + ontology + parsed + } + errors { + message + code + } + } + } + `; + + const updatedData = { title: "Updated Title", count: 42, newField: "added" }; + + const result = await makeGraphQLRequest(server, mutation, { + id: envelopeId, + input: { + ontology: "UpdateTestOntology", + payload: updatedData, + acl: ["*"], + }, + }, getAuthHeaders()); + + expect(result.updateMetaEnvelope.errors).toEqual([]); + expect(result.updateMetaEnvelope.metaEnvelope.id).toBe(envelopeId); + expect(result.updateMetaEnvelope.metaEnvelope.parsed).toEqual(updatedData); + }); + + it("should throw GraphQL error when authentication is missing", async () => { + const mutation = ` + mutation UpdateMetaEnvelope($id: ID!, $input: MetaEnvelopeInput!) { + updateMetaEnvelope(id: $id, input: $input) { + metaEnvelope { id } + errors { message code } + } + } + `; + + // Authentication validation happens at middleware level + await expect(makeGraphQLRequest(server, mutation, { + id: envelopeId, + input: { + ontology: "TestOntology", + payload: { test: "data" }, + acl: ["*"], + }, + }, { + // No X-ENAME or Authorization header + })).rejects.toThrow(); + }); + }); + + describe("removeMetaEnvelope mutation", () => { + let envelopeId: string; + + beforeAll(async () => { + const mutation = ` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { id } + } + } + `; + + const result = await makeGraphQLRequest(server, mutation, { + input: { + ontology: "DeleteTestOntology", + payload: { toBeDeleted: true }, + acl: ["*"], + }, + }, { + "X-ENAME": evault.w3id, + }); + + envelopeId = result.createMetaEnvelope.metaEnvelope.id; + }); + + it("should delete a MetaEnvelope and return structured result", async () => { + const mutation = ` + mutation RemoveMetaEnvelope($id: ID!) { + removeMetaEnvelope(id: $id) { + deletedId + success + errors { + message + code + } + } + } + `; + + const result = await makeGraphQLRequest(server, mutation, { + id: envelopeId, + }, getAuthHeaders()); + + expect(result.removeMetaEnvelope.deletedId).toBe(envelopeId); + expect(result.removeMetaEnvelope.success).toBe(true); + expect(result.removeMetaEnvelope.errors).toEqual([]); + + // Verify it's actually deleted + const query = ` + query MetaEnvelope($id: ID!) { + metaEnvelope(id: $id) { id } + } + `; + + const queryResult = await makeGraphQLRequest(server, query, { + id: envelopeId, + }, getAuthHeaders()); + + expect(queryResult.metaEnvelope).toBeNull(); + }); + + it("should return error for non-existent ID", async () => { + const mutation = ` + mutation RemoveMetaEnvelope($id: ID!) { + removeMetaEnvelope(id: $id) { + deletedId + success + errors { message code } + } + } + `; + + const result = await makeGraphQLRequest(server, mutation, { + id: "non-existent-id-99999", + }, getAuthHeaders()); + + expect(result.removeMetaEnvelope.success).toBe(false); + expect(result.removeMetaEnvelope.errors[0].code).toBe("NOT_FOUND"); + }); + + it("should throw GraphQL error when authentication is missing", async () => { + const mutation = ` + mutation RemoveMetaEnvelope($id: ID!) { + removeMetaEnvelope(id: $id) { + deletedId + success + errors { message code } + } + } + `; + + // Authentication validation happens at middleware level + await expect(makeGraphQLRequest(server, mutation, { + id: "any-id", + }, { + // No X-ENAME or Authorization header + })).rejects.toThrow(); + }); + }); + + describe("Envelope.fieldKey resolver", () => { + it("should return fieldKey as alias for ontology", async () => { + const mutation = ` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { + envelopes { + ontology + fieldKey + } + } + } + } + `; + + const result = await makeGraphQLRequest(server, mutation, { + input: { + ontology: "FieldKeyTestOntology", + payload: { + testField: "value1", + anotherField: "value2", + }, + acl: ["*"], + }, + }, { + "X-ENAME": evault.w3id, + }); + + const envelopes = result.createMetaEnvelope.metaEnvelope.envelopes; + + // Each envelope should have matching ontology and fieldKey + for (const envelope of envelopes) { + expect(envelope.fieldKey).toBe(envelope.ontology); + } + + // Verify specific field keys exist + const fieldKeys = envelopes.map((e: { fieldKey: string }) => e.fieldKey); + expect(fieldKeys).toContain("testField"); + expect(fieldKeys).toContain("anotherField"); + }); + }); + + describe("backward compatibility", () => { + it("legacy storeMetaEnvelope should still work", async () => { + const mutation = ` + mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { + storeMetaEnvelope(input: $input) { + metaEnvelope { + id + ontology + parsed + } + envelopes { + id + ontology + value + } + } + } + `; + + const result = await makeGraphQLRequest(server, mutation, { + input: { + ontology: "LegacyTestOntology", + payload: { legacyField: "legacy value" }, + acl: ["*"], + }, + }, { + "X-ENAME": evault.w3id, + }); + + expect(result.storeMetaEnvelope.metaEnvelope.id).toBeDefined(); + expect(result.storeMetaEnvelope.metaEnvelope.ontology).toBe("LegacyTestOntology"); + expect(result.storeMetaEnvelope.envelopes.length).toBe(1); + }); + + it("legacy getMetaEnvelopeById should still work", async () => { + // First create an envelope + const createResult = await makeGraphQLRequest(server, ` + mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { + storeMetaEnvelope(input: $input) { + metaEnvelope { id } + } + } + `, { + input: { + ontology: "LegacyQueryTest", + payload: { test: "data" }, + acl: ["*"], + }, + }, { + "X-ENAME": evault.w3id, + }); + + const id = createResult.storeMetaEnvelope.metaEnvelope.id; + + // Query using legacy endpoint - requires auth token + const query = ` + query GetMetaEnvelopeById($id: String!) { + getMetaEnvelopeById(id: $id) { + id + ontology + parsed + } + } + `; + + const result = await makeGraphQLRequest(server, query, { + id, + }, getAuthHeaders()); + + expect(result.getMetaEnvelopeById.id).toBe(id); + expect(result.getMetaEnvelopeById.ontology).toBe("LegacyQueryTest"); + }); + + it("legacy deleteMetaEnvelope should still work", async () => { + // First create an envelope + const createResult = await makeGraphQLRequest(server, ` + mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { + storeMetaEnvelope(input: $input) { + metaEnvelope { id } + } + } + `, { + input: { + ontology: "LegacyDeleteTest", + payload: { toDelete: true }, + acl: ["*"], + }, + }, { + "X-ENAME": evault.w3id, + }); + + const id = createResult.storeMetaEnvelope.metaEnvelope.id; + + // Delete using legacy endpoint - requires auth token + const mutation = ` + mutation DeleteMetaEnvelope($id: String!) { + deleteMetaEnvelope(id: $id) + } + `; + + const result = await makeGraphQLRequest(server, mutation, { + id, + }, getAuthHeaders()); + + expect(result.deleteMetaEnvelope).toBe(true); + }); + }); +}); diff --git a/infrastructure/evault-core/src/core/protocol/typedefs.ts b/infrastructure/evault-core/src/core/protocol/typedefs.ts index a4b406b83..45a6a8bac 100644 --- a/infrastructure/evault-core/src/core/protocol/typedefs.ts +++ b/infrastructure/evault-core/src/core/protocol/typedefs.ts @@ -4,37 +4,174 @@ export const typeDefs = /* GraphQL */ ` type Envelope { id: String! + "The field name from the payload (kept for backward compatibility, prefer fieldKey)" ontology: String! + "The field name from the payload - clearer alias for ontology" + fieldKey: String! value: JSON valueType: String } type MetaEnvelope { id: String! + "The ontology schema ID (W3ID)" ontology: String! envelopes: [Envelope!]! parsed: JSON } + "Result type for legacy storeMetaEnvelope and updateMetaEnvelopeById mutations" type StoreMetaEnvelopeResult { metaEnvelope: MetaEnvelope! envelopes: [Envelope!]! } + # ============================================================================ + # Pagination Types (Relay-style connections) + # ============================================================================ + + type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + + type MetaEnvelopeEdge { + cursor: String! + node: MetaEnvelope! + } + + type MetaEnvelopeConnection { + edges: [MetaEnvelopeEdge!]! + pageInfo: PageInfo! + totalCount: Int + } + + # ============================================================================ + # Search and Filter Types + # ============================================================================ + + enum SearchMode { + "Match if term appears anywhere in the value" + CONTAINS + "Match if value starts with the term" + STARTS_WITH + "Match only exact matches" + EXACT + } + + input MetaEnvelopeSearchInput { + "The search term to look for" + term: String + "Whether the search should be case-sensitive (default: false)" + caseSensitive: Boolean = false + "Specific field names to search within (searches all fields if not provided)" + fields: [String!] + "The matching mode for the search (default: CONTAINS)" + mode: SearchMode = CONTAINS + } + + input MetaEnvelopeFilterInput { + "Filter by ontology schema ID" + ontologyId: ID + "Search within envelope values" + search: MetaEnvelopeSearchInput + } + + # ============================================================================ + # Mutation Payloads (Idiomatic GraphQL) + # ============================================================================ + + "Represents a user-facing error from a mutation" + type UserError { + "The field that caused the error, if applicable" + field: String + "Human-readable error message" + message: String! + "Machine-readable error code" + code: String + } + + type CreateMetaEnvelopePayload { + "The created MetaEnvelope, null if errors occurred" + metaEnvelope: MetaEnvelope + "List of errors that occurred during the mutation" + errors: [UserError!] + } + + type UpdateMetaEnvelopePayload { + "The updated MetaEnvelope, null if errors occurred" + metaEnvelope: MetaEnvelope + "List of errors that occurred during the mutation" + errors: [UserError!] + } + + type DeleteMetaEnvelopePayload { + "The ID of the deleted MetaEnvelope" + deletedId: ID! + "Whether the deletion was successful" + success: Boolean! + "List of errors that occurred during the mutation" + errors: [UserError!] + } + + # ============================================================================ + # Queries + # ============================================================================ + type Query { + # --- NEW IDIOMATIC API --- + "Retrieve a single MetaEnvelope by its ID" + metaEnvelope(id: ID!): MetaEnvelope + + "Retrieve MetaEnvelopes with pagination and optional filtering" + metaEnvelopes( + "Filter criteria for the query" + filter: MetaEnvelopeFilterInput + "Number of items to return (forward pagination)" + first: Int + "Cursor to start after (forward pagination)" + after: String + "Number of items to return (backward pagination)" + last: Int + "Cursor to start before (backward pagination)" + before: String + ): MetaEnvelopeConnection! + + # --- LEGACY API (preserved for backward compatibility) --- getMetaEnvelopeById(id: String!): MetaEnvelope findMetaEnvelopesByOntology(ontology: String!): [MetaEnvelope!]! searchMetaEnvelopes(ontology: String!, term: String!): [MetaEnvelope!]! getAllEnvelopes: [Envelope!]! } + # ============================================================================ + # Inputs + # ============================================================================ + input MetaEnvelopeInput { ontology: String! payload: JSON! acl: [String!]! } + # ============================================================================ + # Mutations + # ============================================================================ + type Mutation { + # --- NEW IDIOMATIC API --- + "Create a new MetaEnvelope" + createMetaEnvelope(input: MetaEnvelopeInput!): CreateMetaEnvelopePayload! + + "Update an existing MetaEnvelope by ID" + updateMetaEnvelope(id: ID!, input: MetaEnvelopeInput!): UpdateMetaEnvelopePayload! + + "Delete a MetaEnvelope by ID" + removeMetaEnvelope(id: ID!): DeleteMetaEnvelopePayload! + + # --- LEGACY API (preserved for backward compatibility) --- storeMetaEnvelope(input: MetaEnvelopeInput!): StoreMetaEnvelopeResult! deleteMetaEnvelope(id: String!): Boolean! updateEnvelopeValue(envelopeId: String!, newValue: JSON!): Boolean! diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts index 664852dcf..daf4cd58a 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts @@ -156,6 +156,10 @@ export class VaultAccessGuard { */ private filterACL(metaEnvelope: any) { if (!metaEnvelope) return null; + // Return primitives (boolean, string, number) as-is - don't try to destructure + if (typeof metaEnvelope !== 'object') { + return metaEnvelope; + } const { acl, ...filtered } = metaEnvelope; return filtered; }