Skip to content

Commit 570a770

Browse files
authored
feat: implement envelope operation logging with cursor pagination (#764)
* feat: logs * fix: coderabbit suggestions * fix testests * docs: add log docs
1 parent 8255a58 commit 570a770

14 files changed

Lines changed: 859 additions & 2 deletions

File tree

docs/docs/Infrastructure/eVault.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,63 @@ X-ENAME: @user-a.w3id
282282
}
283283
```
284284

285+
**Example**:
286+
```bash
287+
curl -X GET http://localhost:4000/whois \
288+
-H "X-ENAME: @user-a.w3id"
289+
```
290+
285291
**Use Case**: Platforms use this endpoint to retrieve public keys for signature verification.
286292

293+
### /logs
294+
295+
Get paginated envelope operation logs for an eName. Each log entry describes a create, update, delete, or update_envelope_value operation (metaEnvelope id, hash, operation type, platform, timestamp).
296+
297+
**Request**:
298+
```http
299+
GET /logs HTTP/1.1
300+
Host: evault.example.com
301+
X-ENAME: @user-a.w3id
302+
```
303+
304+
Optional query parameters:
305+
306+
- `limit` — Page size (default 20, max 100).
307+
- `cursor` — Opaque cursor for the next page (returned as `nextCursor` in the response).
308+
309+
**Response**:
310+
```json
311+
{
312+
"logs": [
313+
{
314+
"id": "log-entry-id",
315+
"eName": "@user-a.w3id",
316+
"metaEnvelopeId": "meta-envelope-id",
317+
"envelopeHash": "sha256-hex",
318+
"operation": "create",
319+
"platform": "https://platform.example.com",
320+
"timestamp": "2025-02-04T12:00:00.000Z",
321+
"ontology": "550e8400-e29b-41d4-a716-446655440001"
322+
}
323+
],
324+
"nextCursor": "2025-02-04T12:00:00.000Z|log-entry-id",
325+
"hasMore": true
326+
}
327+
```
328+
329+
**Example — first page**:
330+
```bash
331+
curl -X GET "http://localhost:4000/logs?limit=20" \
332+
-H "X-ENAME: @user-a.w3id"
333+
```
334+
335+
**Example — next page (using cursor)**:
336+
```bash
337+
curl -X GET "http://localhost:4000/logs?limit=20&cursor=2025-02-04T12:00:00.000Z%7Clog-entry-id" \
338+
-H "X-ENAME: @user-a.w3id"
339+
```
340+
341+
(Use the `nextCursor` value from the previous response; URL-encode the cursor if it contains special characters such as `|`.)
287342

288343
## Access Control
289344

infrastructure/evault-core/src/core/db/db.service.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,4 +562,78 @@ describe("DbService (integration)", () => {
562562
).rejects.toThrow("eName is required");
563563
});
564564
});
565+
566+
describe("Envelope operation logs", () => {
567+
it("should append and retrieve envelope operation logs with pagination", async () => {
568+
const result = await service.storeMetaEnvelope(
569+
{ ontology: "LogTest", payload: { x: 1 }, acl: ["*"] },
570+
["*"],
571+
TEST_ENAME,
572+
);
573+
const metaId = result.metaEnvelope.id;
574+
await service.appendEnvelopeOperationLog({
575+
eName: TEST_ENAME,
576+
metaEnvelopeId: metaId,
577+
envelopeHash: "abc123",
578+
operation: "create",
579+
platform: "test-platform",
580+
timestamp: new Date().toISOString(),
581+
ontology: "LogTest",
582+
});
583+
const page1 = await service.getEnvelopeOperationLogs(TEST_ENAME, {
584+
limit: 10,
585+
});
586+
expect(page1.logs.length).toBeGreaterThanOrEqual(1);
587+
const log = page1.logs.find((l) => l.metaEnvelopeId === metaId);
588+
expect(log).toBeDefined();
589+
expect(log?.operation).toBe("create");
590+
expect(log?.platform).toBe("test-platform");
591+
expect(log?.envelopeHash).toBe("abc123");
592+
expect(log?.ontology).toBe("LogTest");
593+
});
594+
595+
it("should return getMetaEnvelopeIdByEnvelopeId for an envelope", async () => {
596+
const result = await service.storeMetaEnvelope(
597+
{ ontology: "ResolveTest", payload: { key: "v" }, acl: ["*"] },
598+
["*"],
599+
TEST_ENAME,
600+
);
601+
const envelopeId = result.envelopes[0].id;
602+
const metaInfo = await service.getMetaEnvelopeIdByEnvelopeId(
603+
envelopeId,
604+
TEST_ENAME,
605+
);
606+
expect(metaInfo).not.toBeNull();
607+
expect(metaInfo?.metaEnvelopeId).toBe(result.metaEnvelope.id);
608+
expect(metaInfo?.ontology).toBe("ResolveTest");
609+
});
610+
611+
it("should return null from getMetaEnvelopeIdByEnvelopeId for unknown envelope", async () => {
612+
const metaInfo = await service.getMetaEnvelopeIdByEnvelopeId(
613+
"nonexistent-id",
614+
TEST_ENAME,
615+
);
616+
expect(metaInfo).toBeNull();
617+
});
618+
619+
it("should support cursor-based pagination for logs", async () => {
620+
const page1 = await service.getEnvelopeOperationLogs(TEST_ENAME, {
621+
limit: 2,
622+
});
623+
if (page1.logs.length < 2 && !page1.hasMore) return;
624+
if (page1.nextCursor) {
625+
const page2 = await service.getEnvelopeOperationLogs(
626+
TEST_ENAME,
627+
{ limit: 2, cursor: page1.nextCursor },
628+
);
629+
expect(page2.logs.length).toBeLessThanOrEqual(2);
630+
expect(
631+
page2.logs.every(
632+
(l) =>
633+
!page1.logs.some((p) => p.id === l.id),
634+
),
635+
).toBe(true);
636+
}
637+
});
638+
});
565639
});

infrastructure/evault-core/src/core/db/db.service.ts

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import type { Driver } from "neo4j-driver";
1+
import neo4j, { type Driver } from "neo4j-driver";
22
import { W3IDBuilder } from "w3id";
33
import { deserializeValue, serializeValue } from "./schema";
44
import type {
5+
AppendEnvelopeOperationLogParams,
56
Envelope,
7+
EnvelopeOperationLogEntry,
68
GetAllEnvelopesResult,
9+
GetEnvelopeOperationLogsResult,
710
MetaEnvelope,
811
MetaEnvelopeResult,
912
SearchMetaEnvelopesResult,
@@ -888,6 +891,155 @@ export class DbService {
888891
return count;
889892
}
890893

894+
/**
895+
* Gets the metaEnvelope id and ontology for an envelope by envelope id.
896+
* Used when logging updateEnvelopeValue (resolver only has envelopeId).
897+
*/
898+
async getMetaEnvelopeIdByEnvelopeId(
899+
envelopeId: string,
900+
eName: string,
901+
): Promise<{ metaEnvelopeId: string; ontology: string } | null> {
902+
if (!eName) {
903+
throw new Error("eName is required");
904+
}
905+
const result = await this.runQueryInternal(
906+
`
907+
MATCH (m:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(e:Envelope { id: $envelopeId })
908+
RETURN m.id AS metaEnvelopeId, m.ontology AS ontology
909+
`,
910+
{ envelopeId, eName },
911+
);
912+
const record = result.records[0];
913+
if (!record) return null;
914+
return {
915+
metaEnvelopeId: record.get("metaEnvelopeId"),
916+
ontology: record.get("ontology"),
917+
};
918+
}
919+
920+
/**
921+
* Appends an envelope operation log entry (create, update, delete, update_envelope_value).
922+
*/
923+
async appendEnvelopeOperationLog(
924+
params: AppendEnvelopeOperationLogParams,
925+
): Promise<void> {
926+
if (!params.eName) {
927+
throw new Error("eName is required for envelope operation logs");
928+
}
929+
const logId = (await new W3IDBuilder().build()).id;
930+
const platformValue =
931+
params.platform !== null && params.platform !== undefined
932+
? params.platform
933+
: null;
934+
await this.runQueryInternal(
935+
`
936+
CREATE (l:EnvelopeOperationLog {
937+
id: $id,
938+
eName: $eName,
939+
metaEnvelopeId: $metaEnvelopeId,
940+
envelopeHash: $envelopeHash,
941+
operation: $operation,
942+
platform: $platform,
943+
timestamp: $timestamp,
944+
ontology: $ontology
945+
})
946+
`,
947+
{
948+
id: logId,
949+
eName: params.eName,
950+
metaEnvelopeId: params.metaEnvelopeId,
951+
envelopeHash: params.envelopeHash,
952+
operation: params.operation,
953+
platform: platformValue,
954+
timestamp: params.timestamp,
955+
ontology: params.ontology ?? null,
956+
},
957+
);
958+
}
959+
960+
/**
961+
* Returns paginated envelope operation logs for an eName.
962+
* Ordered by timestamp DESC, then id ASC for stable cursor pagination.
963+
*/
964+
async getEnvelopeOperationLogs(
965+
eName: string,
966+
options: { limit: number; cursor?: string | null },
967+
): Promise<GetEnvelopeOperationLogsResult> {
968+
if (!eName) {
969+
throw new Error("eName is required for getting envelope operation logs");
970+
}
971+
const limit = Math.min(Math.max(1, options.limit || 20), 100);
972+
const cursor = options.cursor ?? null;
973+
974+
// Fetch limit+1 to know if there's a next page. Cursor format: "timestamp|id" (| avoids colons in ISO timestamp).
975+
const [cursorTs = "", cursorId = ""] = cursor
976+
? cursor.split("|")
977+
: [];
978+
const result = await this.runQueryInternal(
979+
cursor
980+
? `
981+
MATCH (l:EnvelopeOperationLog { eName: $eName })
982+
WHERE (l.timestamp < $cursorTs) OR (l.timestamp = $cursorTs AND l.id > $cursorId)
983+
WITH l
984+
ORDER BY l.timestamp DESC, l.id ASC
985+
LIMIT $limitPlusOne
986+
RETURN l.id AS id, l.eName AS eName, l.metaEnvelopeId AS metaEnvelopeId,
987+
l.envelopeHash AS envelopeHash, l.operation AS operation,
988+
l.platform AS platform, l.timestamp AS timestamp, l.ontology AS ontology
989+
`
990+
: `
991+
MATCH (l:EnvelopeOperationLog { eName: $eName })
992+
WITH l
993+
ORDER BY l.timestamp DESC, l.id ASC
994+
LIMIT $limitPlusOne
995+
RETURN l.id AS id, l.eName AS eName, l.metaEnvelopeId AS metaEnvelopeId,
996+
l.envelopeHash AS envelopeHash, l.operation AS operation,
997+
l.platform AS platform, l.timestamp AS timestamp, l.ontology AS ontology
998+
`,
999+
cursor
1000+
? {
1001+
eName,
1002+
limitPlusOne: neo4j.int(limit + 1),
1003+
cursorTs,
1004+
cursorId,
1005+
}
1006+
: { eName, limitPlusOne: neo4j.int(limit + 1) },
1007+
);
1008+
1009+
const rows = result.records.map((r) => ({
1010+
id: r.get("id"),
1011+
eName: r.get("eName"),
1012+
metaEnvelopeId: r.get("metaEnvelopeId"),
1013+
envelopeHash: r.get("envelopeHash"),
1014+
operation: r.get("operation"),
1015+
platform: r.get("platform"),
1016+
timestamp: r.get("timestamp"),
1017+
ontology: r.get("ontology"),
1018+
}));
1019+
1020+
const hasMore = rows.length > limit;
1021+
const logs = (hasMore ? rows.slice(0, limit) : rows).map(
1022+
(r): EnvelopeOperationLogEntry => ({
1023+
id: r.id,
1024+
eName: r.eName,
1025+
metaEnvelopeId: r.metaEnvelopeId,
1026+
envelopeHash: r.envelopeHash,
1027+
operation: r.operation,
1028+
platform: r.platform,
1029+
timestamp: r.timestamp,
1030+
...(r.ontology != null && { ontology: r.ontology }),
1031+
}),
1032+
);
1033+
1034+
const last = logs[logs.length - 1];
1035+
const nextCursor =
1036+
hasMore && last
1037+
? `${last.timestamp}|${last.id}`
1038+
: null;
1039+
1040+
return { logs, nextCursor, hasMore };
1041+
}
1042+
8911043
/**
8921044
* Closes the database connection.
8931045
*/
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as crypto from "crypto";
2+
3+
/**
4+
* Computes a deterministic SHA-256 hash of envelope content for logging.
5+
* Used for create/update (id + ontology + payload); for delete, pass only id.
6+
*/
7+
export function computeEnvelopeHash(payload: {
8+
id?: string;
9+
ontology: string;
10+
payload?: Record<string, unknown>;
11+
}): string {
12+
const obj = {
13+
id: payload.id ?? "",
14+
ontology: payload.ontology,
15+
payload: payload.payload ?? {},
16+
};
17+
const canonical = JSON.stringify(obj);
18+
return crypto.createHash("sha256").update(canonical).digest("hex");
19+
}
20+
21+
/**
22+
* Computes hash for delete operation (metaEnvelopeId only).
23+
*/
24+
export function computeEnvelopeHashForDelete(metaEnvelopeId: string): string {
25+
return crypto
26+
.createHash("sha256")
27+
.update(JSON.stringify({ id: metaEnvelopeId }))
28+
.digest("hex");
29+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Neo4j Migration: Add indexes for EnvelopeOperationLog
3+
*
4+
* Creates indexes on eName and timestamp for paginated log queries.
5+
*/
6+
7+
import { Driver } from "neo4j-driver";
8+
9+
export async function createEnvelopeOperationLogIndexes(
10+
driver: Driver,
11+
): Promise<void> {
12+
const session = driver.session();
13+
try {
14+
await session.run(
15+
`CREATE INDEX envelope_operation_log_ename_index IF NOT EXISTS
16+
FOR (l:EnvelopeOperationLog) ON (l.eName)`,
17+
);
18+
console.log("Created eName index on EnvelopeOperationLog nodes");
19+
await session.run(
20+
`CREATE INDEX envelope_operation_log_timestamp_index IF NOT EXISTS
21+
FOR (l:EnvelopeOperationLog) ON (l.timestamp)`,
22+
);
23+
console.log("Created timestamp index on EnvelopeOperationLog nodes");
24+
} catch (error) {
25+
if (error instanceof Error && error.message.includes("already exists")) {
26+
console.log("EnvelopeOperationLog indexes already exist");
27+
} else {
28+
console.error("Error creating EnvelopeOperationLog indexes:", error);
29+
throw error;
30+
}
31+
} finally {
32+
await session.close();
33+
}
34+
}

0 commit comments

Comments
 (0)