diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index 23f00d975..1694cdfc0 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -91,7 +91,7 @@ This graph always holds the current state. History is kept alongside it, never l ```cypher (MetaEnvelopeHistory {metaEnvelopeId, eName, latestVersion}) -(MetaEnvelopeVersion {metaEnvelopeId, eName, version, operation, ontology, acl, aclBlock, payloadJson, requestingPlatform, createdAt}) +(MetaEnvelopeVersion {metaEnvelopeId, eName, version, operation, ontology, acl, aclBlock, payloadJson, fieldsJson, requestingPlatform, author, restoredFromVersion, createdAt}) ``` A removed record is relabelled `PrunedMetaEnvelope`, and its Envelopes `PrunedEnvelope`, so no read returns it while its data is kept. @@ -213,6 +213,8 @@ query { ontology parsed requestingPlatform + author + restoredFromVersion createdAt } } @@ -226,7 +228,9 @@ query { ``` - `operation` is `create`, `update` or `delete`. A `delete` version has `parsed: null`. -- Records written before versioning existed get a baseline `create` version from their state at their first later write. +- `requestingPlatform` is the platform that made the write. `author` is the user it said it acted for, taken from `X-ON-BEHALF-OF` or from a wallet-signed token. It is the caller's assertion, recorded for history, not proof of identity. +- `restoredFromVersion` is set on a version written by `rollbackMetaEnvelope`. +- Records written before versioning existed get a baseline `create` version from their state at their first later write. Its platform and author are unknown. ### Mutations @@ -297,6 +301,30 @@ mutation { } ``` +#### rollbackMetaEnvelope + +Restore a MetaEnvelope to the state it had at an earlier version. Nothing is rewritten: the restore is written as a new version on top of the history, so the version number keeps increasing and the rollback can itself be rolled back. + +- The payload is replaced exactly. Unlike `updateMetaEnvelope`, which patches, fields the earlier version did not have are pruned. +- The record keeps the access policy it has now, so restoring old data never restores old access. +- Rolling back a removed record brings it back. Access to it is decided by the policy it last carried. +- A version that records a removal cannot be restored. Use `removeMetaEnvelope` instead. +- Requires UPDATE permission. + +**Mutation**: +```graphql +mutation { + rollbackMetaEnvelope(id: "global-id-123", version: 2) { + metaEnvelope { id parsed } + version + restoredFromVersion + errors { field message code } + } +} +``` + +Error codes: `VERSION_NOT_FOUND`, `VERSION_IS_DELETE`, `INVALID_VERSION`, `ROLLBACK_FAILED`. + #### removeMetaEnvelope Remove a MetaEnvelope. Removal prunes rather than destroys: the record and its Envelopes disappear from every read, but their data is kept and the record's version history remains available through `metaEnvelopeHistory`. Returns a structured payload confirming the removal. @@ -594,7 +622,7 @@ curl -X GET http://localhost:4000/whois \ ### /logs -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). +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, author, timestamp). `author` is the user the writing platform said it acted for, or `null`.. **Request**: ```http @@ -619,6 +647,7 @@ Optional query parameters: "envelopeHash": "sha256-hex", "operation": "create", "platform": "https://platform.example.com", + "author": "@user-a.w3id", "timestamp": "2025-02-04T12:00:00.000Z", "ontology": "550e8400-e29b-41d4-a716-446655440001" } diff --git a/infrastructure/evault-core/src/core/db/db.service.spec.ts b/infrastructure/evault-core/src/core/db/db.service.spec.ts index 385b30a92..563ce3f5a 100644 --- a/infrastructure/evault-core/src/core/db/db.service.spec.ts +++ b/infrastructure/evault-core/src/core/db/db.service.spec.ts @@ -833,5 +833,225 @@ describe("DbService (integration)", () => { { n: 1 }, ]); }); + + it("records who made each write", async () => { + const stored = await service.storeMetaEnvelope( + { ontology: "AuthoredPost", payload: { text: "a" }, acl: ["*"] }, + ["*"], + VERSION_ENAME, + { + evaultPublicKey: null, + requestingPlatform: "@platform-one", + author: "@user-one", + skipAwareness: true, + }, + ); + await service.updateMetaEnvelopeById( + stored.metaEnvelope.id, + { ontology: "AuthoredPost", payload: { text: "b" }, acl: ["*"] }, + ["*"], + VERSION_ENAME, + { + evaultPublicKey: null, + requestingPlatform: "@platform-two", + skipAwareness: true, + }, + ); + + const history = await service.getMetaEnvelopeVersions( + stored.metaEnvelope.id, + VERSION_ENAME, + ); + expect( + history.edges.map((e) => [ + e.node.requestingPlatform, + e.node.author, + ]), + ).toEqual([ + ["@platform-two", null], + ["@platform-one", "@user-one"], + ]); + }); + }); + + describe("MetaEnvelope rollback", () => { + const ROLLBACK_ENAME = "rollback@example.com"; + const origin = { + evaultPublicKey: null, + requestingPlatform: "@undo-platform", + author: "@undo-user", + skipAwareness: true, + }; + + it("restores an earlier state exactly as a new version", async () => { + const stored = await service.storeMetaEnvelope( + { + ontology: "RollbackPost", + payload: { + title: "original", + publishedAt: new Date("2026-01-02T03:04:05Z"), + }, + acl: ["*"], + }, + ["*"], + ROLLBACK_ENAME, + ); + const id = stored.metaEnvelope.id; + const titleEnvelopeId = stored.envelopes.find( + (e) => e.ontology === "title", + )?.id; + + await service.updateMetaEnvelopeById( + id, + { + ontology: "RollbackPost", + payload: { title: "edited", tags: ["added", "later"] }, + acl: ["*"], + }, + ["*"], + ROLLBACK_ENAME, + ); + + const result = await service.rollbackMetaEnvelope( + id, + ROLLBACK_ENAME, + 1, + origin, + ); + expect(result.version).toBe(3); + expect(result.operation).toBe("update"); + expect(result.restoredFromVersion).toBe(1); + + const live = await service.findMetaEnvelopeById(id, ROLLBACK_ENAME); + // The field added after v1 is gone and the date is a date again + expect(Object.keys(live?.parsed ?? {}).sort()).toEqual([ + "publishedAt", + "title", + ]); + expect(live?.parsed.title).toBe("original"); + expect(live?.parsed.publishedAt).toBeInstanceOf(Date); + expect( + (live?.parsed.publishedAt as Date).toISOString(), + ).toBe("2026-01-02T03:04:05.000Z"); + // Envelope ids stay stable for fields that survive + expect( + live?.envelopes.find((e) => e.ontology === "title")?.id, + ).toBe(titleEnvelopeId); + + const history = await service.getMetaEnvelopeVersions( + id, + ROLLBACK_ENAME, + ); + expect(history.totalCount).toBe(3); + const latest = history.edges[0].node; + expect(latest.operation).toBe("update"); + expect(latest.restoredFromVersion).toBe(1); + expect(latest.author).toBe("@undo-user"); + expect(latest.requestingPlatform).toBe("@undo-platform"); + expect(history.edges[1].node.restoredFromVersion).toBeNull(); + }); + + it("keeps the current access policy instead of the old one", async () => { + const stored = await service.storeMetaEnvelope( + { ontology: "AclPost", payload: { text: "v1" }, acl: ["@old-reader"] }, + ["@old-reader"], + ROLLBACK_ENAME, + ); + const id = stored.metaEnvelope.id; + await service.updateMetaEnvelopeById( + id, + { ontology: "AclPost", payload: { text: "v2" }, acl: ["@new-reader"] }, + ["@new-reader"], + ROLLBACK_ENAME, + ); + + await service.rollbackMetaEnvelope(id, ROLLBACK_ENAME, 1, origin); + + const live = await service.findMetaEnvelopeById(id, ROLLBACK_ENAME); + expect(live?.parsed).toEqual({ text: "v1" }); + expect(live?.acl).toEqual(["@new-reader"]); + }); + + it("brings a removed record back", async () => { + const stored = await service.storeMetaEnvelope( + { ontology: "RevivedPost", payload: { text: "alive" }, acl: ["*"] }, + ["*"], + ROLLBACK_ENAME, + ); + const id = stored.metaEnvelope.id; + await service.deleteMetaEnvelope(id, ROLLBACK_ENAME); + expect(await service.findMetaEnvelopeById(id, ROLLBACK_ENAME)).toBeNull(); + + const result = await service.rollbackMetaEnvelope( + id, + ROLLBACK_ENAME, + 1, + origin, + ); + expect(result.operation).toBe("create"); + expect(result.version).toBe(3); + + const live = await service.findMetaEnvelopeById(id, ROLLBACK_ENAME); + expect(live?.parsed).toEqual({ text: "alive" }); + expect(live?.acl).toEqual(["*"]); + const history = await service.getMetaEnvelopeVersions( + id, + ROLLBACK_ENAME, + ); + expect(history.edges.map((e) => e.node.operation)).toEqual([ + "create", + "delete", + "create", + ]); + }); + + it("rejects a version that does not exist or records a removal", async () => { + const stored = await service.storeMetaEnvelope( + { ontology: "RejectPost", payload: { text: "x" }, acl: ["*"] }, + ["*"], + ROLLBACK_ENAME, + ); + const id = stored.metaEnvelope.id; + + await expect( + service.rollbackMetaEnvelope(id, ROLLBACK_ENAME, 9, origin), + ).rejects.toMatchObject({ code: "VERSION_NOT_FOUND" }); + + await service.deleteMetaEnvelope(id, ROLLBACK_ENAME); + await expect( + service.rollbackMetaEnvelope(id, ROLLBACK_ENAME, 2, origin), + ).rejects.toMatchObject({ code: "VERSION_IS_DELETE" }); + + // A failed rollback records nothing + const history = await service.getMetaEnvelopeVersions( + id, + ROLLBACK_ENAME, + ); + expect(history.totalCount).toBe(2); + }); + + it("restores versions recorded before typed fields existed", async () => { + const stored = await service.storeMetaEnvelope( + { ontology: "UntypedPost", payload: { text: "first", n: 1 }, acl: ["*"] }, + ["*"], + ROLLBACK_ENAME, + ); + const id = stored.metaEnvelope.id; + await service.runQuery( + `MATCH (v:MetaEnvelopeVersion { metaEnvelopeId: $id }) REMOVE v.fieldsJson`, + { id }, + ); + await service.updateMetaEnvelopeById( + id, + { ontology: "UntypedPost", payload: { text: "second", n: 2 }, acl: ["*"] }, + ["*"], + ROLLBACK_ENAME, + ); + + await service.rollbackMetaEnvelope(id, ROLLBACK_ENAME, 1, origin); + + const live = await service.findMetaEnvelopeById(id, ROLLBACK_ENAME); + expect(live?.parsed).toEqual({ text: "first", n: 1 }); + }); }); }); diff --git a/infrastructure/evault-core/src/core/db/db.service.ts b/infrastructure/evault-core/src/core/db/db.service.ts index 1c88a5d38..84ea81fc6 100644 --- a/infrastructure/evault-core/src/core/db/db.service.ts +++ b/infrastructure/evault-core/src/core/db/db.service.ts @@ -27,9 +27,24 @@ import type { export interface AwarenessWriteContext { evaultPublicKey: string | null; requestingPlatform?: string | null; + /** The party the write is recorded against, when the caller named one. */ + author?: string | null; skipAwareness?: boolean; } +/** Who made a write, as recorded in the record's history. */ +interface WriteOrigin { + requestingPlatform: string | null; + author: string | null; +} + +function writeOrigin(awareness?: AwarenessWriteContext): WriteOrigin { + return { + requestingPlatform: awareness?.requestingPlatform ?? null, + author: awareness?.author ?? null, + }; +} + function awarenessOutboxParams( packetId: string, schemaId: string, @@ -87,12 +102,27 @@ interface MetaEnvelopeSnapshot { payload: Record | null; } +/** + * The payload with each field's stored value and type, so a rollback can put + * back exactly what was there (a date stays a date) rather than its JSON form. + */ +function typedFieldsJson(payload: Record | null): string | null { + if (payload === null) return null; + const fields: Record = {}; + for (const [key, value] of Object.entries(payload)) { + const { value: stored, type } = serializeValue(value); + fields[key] = { value: stored ?? null, valueType: type }; + } + return JSON.stringify(fields); +} + function metaEnvelopeVersionParams( metaEnvelopeId: string, eName: string, operation: MetaEnvelopeVersionOperation, snapshot: MetaEnvelopeSnapshot, - requestingPlatform: string | null, + origin: WriteOrigin, + restoredFromVersion: number | null = null, ): Record { return { versionMetaEnvelopeId: metaEnvelopeId, @@ -103,7 +133,11 @@ function metaEnvelopeVersionParams( versionAclBlock: snapshot.aclBlock ?? null, versionPayloadJson: snapshot.payload === null ? null : JSON.stringify(snapshot.payload), - versionRequestingPlatform: requestingPlatform, + versionFieldsJson: typedFieldsJson(snapshot.payload), + versionRequestingPlatform: origin.requestingPlatform, + versionAuthor: origin.author, + versionRestoredFromVersion: + restoredFromVersion === null ? null : neo4j.int(restoredFromVersion), versionCreatedAt: new Date().toISOString(), versionNow: Date.now(), }; @@ -126,11 +160,64 @@ const APPEND_METAENVELOPE_VERSION = ` acl: $versionAcl, aclBlock: $versionAclBlock, payloadJson: $versionPayloadJson, + fieldsJson: $versionFieldsJson, requestingPlatform: $versionRequestingPlatform, + author: $versionAuthor, + restoredFromVersion: $versionRestoredFromVersion, createdAt: $versionCreatedAt }) `; +export type MetaEnvelopeRollbackErrorCode = + | "INVALID_VERSION" + | "VERSION_NOT_FOUND" + | "VERSION_IS_DELETE"; + +export class MetaEnvelopeRollbackError extends Error { + constructor( + readonly code: MetaEnvelopeRollbackErrorCode, + message: string, + ) { + super(message); + this.name = "MetaEnvelopeRollbackError"; + } +} + +export interface MetaEnvelopeRollbackResult { + metaEnvelope: MetaEnvelopeResult; + /** "create" when the rollback brought a pruned record back. */ + operation: "create" | "update"; + /** The version the rollback was recorded as. */ + version: number; + restoredFromVersion: number; +} + +/** + * The stored value and type of each field of a recorded version. Versions + * written before typed fields were recorded only have their JSON payload, so + * their types are re-derived from it (a date comes back as its ISO string). + */ +function restorableFields( + fieldsJson: string | null, + payloadJson: string | null, +): Record { + if (fieldsJson) return JSON.parse(fieldsJson); + const payload: Record = payloadJson + ? JSON.parse(payloadJson) + : {}; + const fields: Record = {}; + for (const [key, value] of Object.entries(payload)) { + const { value: stored, type } = serializeValue(value); + fields[key] = { value: stored ?? null, valueType: type }; + } + return fields; +} + +function toNumberOrNull(value: any): number | null { + if (value === null || value === undefined) return null; + return typeof value.toNumber === "function" ? value.toNumber() : Number(value); +} + function payloadFromEnvelopeNodes(nodes: any[]): Record { const payload: Record = {}; for (const node of nodes) { @@ -246,7 +333,8 @@ export class DbService { eName: string, operation: MetaEnvelopeVersionOperation, snapshot: MetaEnvelopeSnapshot, - requestingPlatform: string | null, + origin: WriteOrigin, + restoredFromVersion: number | null = null, ): Promise { await tx.run( APPEND_METAENVELOPE_VERSION, @@ -255,7 +343,8 @@ export class DbService { eName, operation, snapshot, - requestingPlatform, + origin, + restoredFromVersion, ), ); } @@ -269,19 +358,15 @@ export class DbService { tx: ManagedTransaction, metaEnvelopeId: string, eName: string, - requestingPlatform: string | null, ): Promise { const latestVersion = await this.lockHistory(tx, metaEnvelopeId, eName); const live = await this.readLiveSnapshot(tx, metaEnvelopeId, eName); if (latestVersion === 0 && live) { - await this.appendVersion( - tx, - metaEnvelopeId, - eName, - "create", - live, - requestingPlatform, - ); + // Who wrote the pre-existing state is unknown; it is not this caller. + await this.appendVersion(tx, metaEnvelopeId, eName, "create", live, { + requestingPlatform: null, + author: null, + }); } return live; } @@ -372,7 +457,7 @@ export class DbService { aclBlock: envelopeParams.aclBlock ?? null, payload: meta.payload, }, - awareness?.requestingPlatform ?? null, + writeOrigin(awareness), ), ); @@ -493,7 +578,7 @@ export class DbService { ); } - const requestingPlatform = awareness?.requestingPlatform ?? null; + const origin = writeOrigin(awareness); const session = this.driver.session(); try { await session.executeWrite(async (tx) => { @@ -508,12 +593,7 @@ export class DbService { const historyEName: string = existing.records[0]?.get("eName") ?? eName; if (existed) { - await this.prepareVersionedWrite( - tx, - metaId, - historyEName, - requestingPlatform, - ); + await this.prepareVersionedWrite(tx, metaId, historyEName); } await tx.run(cypher.join("\n"), envelopeParams); @@ -530,7 +610,7 @@ export class DbService { aclBlock: envelopeParams.aclBlock ?? null, payload: meta.payload, }, - requestingPlatform, + origin, ); return; } @@ -546,7 +626,7 @@ export class DbService { historyEName, "update", live, - requestingPlatform, + origin, ); } }); @@ -882,7 +962,7 @@ export class DbService { throw new Error("eName is required for deleting meta-envelopes"); } - const requestingPlatform = awareness?.requestingPlatform ?? null; + const origin = writeOrigin(awareness); const params: Record = { id, eName, @@ -899,12 +979,7 @@ export class DbService { const session = this.driver.session(); try { await session.executeWrite(async (tx) => { - const live = await this.prepareVersionedWrite( - tx, - id, - eName, - requestingPlatform, - ); + const live = await this.prepareVersionedWrite(tx, id, eName); if (!live) return; await tx.run( @@ -948,7 +1023,7 @@ export class DbService { eName, "delete", { ...live, payload: null }, - requestingPlatform, + origin, ); }); } finally { @@ -988,13 +1063,8 @@ export class DbService { const metaEnvelopeId: string | undefined = owner.records[0]?.get("id"); if (!metaEnvelopeId) return; - const requestingPlatform = awareness?.requestingPlatform ?? null; - await this.prepareVersionedWrite( - tx, - metaEnvelopeId, - eName, - requestingPlatform, - ); + const origin = writeOrigin(awareness); + await this.prepareVersionedWrite(tx, metaEnvelopeId, eName); const result = await tx.run( ` @@ -1028,7 +1098,7 @@ export class DbService { aclBlock: record.get("aclBlock") ?? null, payload, }, - requestingPlatform, + origin, ); if (!awareness || awareness.skipAwareness) return; await tx.run( @@ -1080,15 +1150,10 @@ export class DbService { // B's "delete stale envelopes" step could clobber fields that // request A just wrote, and versions could be recorded out of order. const session = this.driver.session(); - const requestingPlatform = awareness?.requestingPlatform ?? null; + const origin = writeOrigin(awareness); try { return await session.executeWrite(async (tx) => { - const previous = await this.prepareVersionedWrite( - tx, - id, - eName, - requestingPlatform, - ); + const previous = await this.prepareVersionedWrite(tx, id, eName); const findResult = await tx.run( ` @@ -1241,7 +1306,7 @@ export class DbService { findResult.records[0]?.get("aclBlock") ?? null, payload: mergedPayload, }, - requestingPlatform, + origin, ); if (awareness && !awareness.skipAwareness) { @@ -1279,6 +1344,244 @@ export class DbService { }); } + /** + * Rolls a MetaEnvelope back to an earlier version. Nothing is rewritten: + * the earlier state is written as a new version on top of the history, so + * the version number keeps increasing and the rollback can itself be + * rolled back. + * + * Unlike an update this replaces the payload exactly — fields the earlier + * version did not have are pruned. The record keeps the access policy it + * has now, so restoring old data never restores old access. Rolling back a + * pruned record brings it back. + * @param id - The ID of the meta-envelope + * @param eName - The eName identifier for multi-tenant isolation + * @param toVersion - The version whose state to restore + */ + async rollbackMetaEnvelope( + id: string, + eName: string, + toVersion: number, + awareness?: AwarenessWriteContext, + ): Promise { + if (!eName) { + throw new Error("eName is required for rolling back meta-envelopes"); + } + if (!Number.isInteger(toVersion) || toVersion < 1) { + throw new MetaEnvelopeRollbackError( + "INVALID_VERSION", + "version must be a positive integer", + ); + } + + const origin = writeOrigin(awareness); + const session = this.driver.session(); + try { + return await session.executeWrite(async (tx) => { + const live = await this.prepareVersionedWrite(tx, id, eName); + + const targetResult = await tx.run( + ` + MATCH (v:MetaEnvelopeVersion { metaEnvelopeId: $id, eName: $eName, version: $version }) + RETURN v.operation AS operation, v.ontology AS ontology, + v.payloadJson AS payloadJson, v.fieldsJson AS fieldsJson + `, + { id, eName, version: neo4j.int(toVersion) }, + ); + const target = targetResult.records[0]; + if (!target) { + throw new MetaEnvelopeRollbackError( + "VERSION_NOT_FOUND", + `MetaEnvelope ${id} has no version ${toVersion}`, + ); + } + if (target.get("operation") === "delete") { + throw new MetaEnvelopeRollbackError( + "VERSION_IS_DELETE", + `Version ${toVersion} records a removal, not a state to restore`, + ); + } + + const fields = restorableFields( + target.get("fieldsJson"), + target.get("payloadJson"), + ); + const ontology: string = target.get("ontology"); + + // Keep the access policy in force now (or, for a pruned record, + // the last one it carried) rather than the target version's. + let access = live + ? { acl: live.acl, aclBlock: live.aclBlock } + : null; + if (!access) { + const lastAcl = await tx.run( + ` + MATCH (v:MetaEnvelopeVersion { metaEnvelopeId: $id, eName: $eName }) + RETURN v.acl AS acl, v.aclBlock AS aclBlock + ORDER BY v.version DESC LIMIT 1 + `, + { id, eName }, + ); + access = { + acl: lastAcl.records[0]?.get("acl") ?? [], + aclBlock: lastAcl.records[0]?.get("aclBlock") ?? null, + }; + } + + const current = await tx.run( + ` + MERGE (m:MetaEnvelope { id: $id, eName: $eName }) + ON CREATE SET m.ontology = $ontology, m.acl = $acl, m.aclBlock = $aclBlock + ON MATCH SET m.ontology = $ontology + WITH m + OPTIONAL MATCH (m)-[:LINKS_TO]->(e:Envelope) + RETURN collect(e) AS envelopes + `, + { + id, + eName, + ontology, + acl: access.acl, + aclBlock: access.aclBlock, + }, + ); + + // Reuse one envelope per restored field so envelope ids stay + // stable; everything else on the record is pruned. + const reusable = new Map(); + const toPrune: string[] = []; + for (const node of current.records[0]?.get("envelopes") ?? []) { + if (!node) continue; + const key = node.properties.ontology; + if (key in fields && !reusable.has(key)) { + reusable.set(key, node.properties.id); + } else { + toPrune.push(node.properties.id); + } + } + + if (toPrune.length > 0) { + await tx.run( + ` + MATCH (:MetaEnvelope { id: $id, eName: $eName })-[:LINKS_TO]->(e:Envelope) + WHERE e.id IN $ids + REMOVE e:Envelope + SET e:PrunedEnvelope, e.prunedAt = $now + `, + { id, eName, ids: toPrune, now: Date.now() }, + ); + } + + for (const [key, field] of Object.entries(fields)) { + const envelopeId = reusable.get(key); + if (envelopeId) { + await tx.run( + ` + MATCH (:MetaEnvelope { id: $id, eName: $eName })-[:LINKS_TO]->(e:Envelope { id: $envelopeId }) + SET e.value = $value, e.valueType = $valueType + `, + { + id, + eName, + envelopeId, + value: field.value, + valueType: field.valueType, + }, + ); + } else { + await tx.run( + ` + MATCH (m:MetaEnvelope { id: $id, eName: $eName }) + CREATE (m)-[:LINKS_TO]->(:Envelope { + id: $envelopeId, ontology: $key, value: $value, valueType: $valueType + }) + `, + { + id, + eName, + envelopeId: (await new W3IDBuilder().build()).id, + key, + value: field.value, + valueType: field.valueType, + }, + ); + } + } + + const restored = await this.readLiveSnapshot(tx, id, eName); + if (!restored) { + throw new Error(`MetaEnvelope ${id} vanished during rollback`); + } + const operation = live ? "update" : "create"; + await this.appendVersion( + tx, + id, + eName, + operation, + restored, + origin, + toVersion, + ); + + if (awareness && !awareness.skipAwareness) { + await tx.run( + `MATCH (m:MetaEnvelope { id: $awarenessPacketId, eName: $awarenessW3id }) + ${CREATE_AWARENESS_OUTBOX}`, + awarenessOutboxParams( + id, + ontology, + eName, + restored.payload, + operation, + awareness, + ), + ); + } + + const versionResult = await tx.run( + `MATCH (h:MetaEnvelopeHistory { metaEnvelopeId: $id, eName: $eName }) + RETURN h.latestVersion AS latestVersion`, + { id, eName }, + ); + const envelopesResult = await tx.run( + `MATCH (:MetaEnvelope { id: $id, eName: $eName })-[:LINKS_TO]->(e:Envelope) + RETURN collect(e) AS envelopes`, + { id, eName }, + ); + const envelopes: Envelope[] = ( + envelopesResult.records[0]?.get("envelopes") ?? [] + ).map((node: any) => ({ + id: node.properties.id, + ontology: node.properties.ontology, + value: deserializeValue( + node.properties.value, + node.properties.valueType, + ), + valueType: node.properties.valueType, + })); + + return { + metaEnvelope: { + id, + ontology, + acl: restored.acl, + _acl: parseStoredAclBlock(restored.aclBlock), + envelopes, + parsed: restored.payload ?? {}, + }, + operation, + version: + toNumberOrNull( + versionResult.records[0]?.get("latestVersion"), + ) ?? 0, + restoredFromVersion: toVersion, + }; + }); + } finally { + await session.close(); + } + } + /** * Finds all meta-envelopes for a specific eName, regardless of ontology. * @param eName - The eName identifier for multi-tenant isolation @@ -1669,6 +1972,7 @@ export class DbService { envelopeHash: $envelopeHash, operation: $operation, platform: $platform, + author: $author, timestamp: $timestamp, ontology: $ontology }) @@ -1680,6 +1984,7 @@ export class DbService { envelopeHash: params.envelopeHash, operation: params.operation, platform: platformValue, + author: params.author ?? null, timestamp: params.timestamp, ontology: params.ontology ?? null, }, @@ -1714,7 +2019,7 @@ export class DbService { LIMIT $limitPlusOne RETURN l.id AS id, l.eName AS eName, l.metaEnvelopeId AS metaEnvelopeId, l.envelopeHash AS envelopeHash, l.operation AS operation, - l.platform AS platform, l.timestamp AS timestamp, l.ontology AS ontology + l.platform AS platform, l.author AS author, l.timestamp AS timestamp, l.ontology AS ontology ` : ` MATCH (l:EnvelopeOperationLog { eName: $eName }) @@ -1723,7 +2028,7 @@ export class DbService { LIMIT $limitPlusOne RETURN l.id AS id, l.eName AS eName, l.metaEnvelopeId AS metaEnvelopeId, l.envelopeHash AS envelopeHash, l.operation AS operation, - l.platform AS platform, l.timestamp AS timestamp, l.ontology AS ontology + l.platform AS platform, l.author AS author, l.timestamp AS timestamp, l.ontology AS ontology `, cursor ? { @@ -1742,6 +2047,7 @@ export class DbService { envelopeHash: r.get("envelopeHash"), operation: r.get("operation"), platform: r.get("platform"), + author: r.get("author") ?? null, timestamp: r.get("timestamp"), ontology: r.get("ontology"), })); @@ -1755,6 +2061,7 @@ export class DbService { envelopeHash: r.envelopeHash, operation: r.operation, platform: r.platform, + author: r.author, timestamp: r.timestamp, ...(r.ontology != null && { ontology: r.ontology }), }), @@ -1818,6 +2125,7 @@ export class DbService { LIMIT $limitPlusOne RETURN v.version AS version, v.operation AS operation, v.ontology AS ontology, v.payloadJson AS payloadJson, v.requestingPlatform AS requestingPlatform, + v.author AS author, v.restoredFromVersion AS restoredFromVersion, v.createdAt AS createdAt `, { @@ -1853,6 +2161,8 @@ export class DbService { ontology: record.get("ontology"), parsed: payloadJson == null ? null : JSON.parse(payloadJson), requestingPlatform: record.get("requestingPlatform") ?? null, + author: record.get("author") ?? null, + restoredFromVersion: toNumberOrNull(record.get("restoredFromVersion")), createdAt: record.get("createdAt"), }; return { diff --git a/infrastructure/evault-core/src/core/db/types.ts b/infrastructure/evault-core/src/core/db/types.ts index 1ee9cb1c0..29af0af28 100644 --- a/infrastructure/evault-core/src/core/db/types.ts +++ b/infrastructure/evault-core/src/core/db/types.ts @@ -97,6 +97,8 @@ export type EnvelopeOperationLogEntry = { envelopeHash: string; operation: EnvelopeOperationType; platform: string | null; + /** The user the writing platform said it acted for, when it named one. */ + author?: string | null; timestamp: string; ontology?: string; }; @@ -110,6 +112,7 @@ export type AppendEnvelopeOperationLogParams = { envelopeHash: string; operation: EnvelopeOperationType; platform: string | null; + author?: string | null; timestamp: string; ontology?: string; }; @@ -189,6 +192,10 @@ export type MetaEnvelopeVersion = Record { + if (!context.eName) { + return { + metaEnvelope: null, + errors: [ + { + message: "X-ENAME header is required", + code: "MISSING_ENAME", + }, + ], + }; + } + + try { + const result = await this.db.rollbackMetaEnvelope( + id, + context.eName, + version, + this.awarenessContext(context), + ); + + const envelopeHash = computeEnvelopeHash({ + id, + ontology: result.metaEnvelope.ontology, + payload: result.metaEnvelope.parsed, + }); + this.db + .appendEnvelopeOperationLog({ + author: this.authorOf(context), + eName: context.eName, + metaEnvelopeId: id, + envelopeHash, + operation: result.operation, + platform: + context.tokenPayload?.platform ?? null, + timestamp: new Date().toISOString(), + ontology: result.metaEnvelope.ontology, + }) + .catch((err) => + console.error( + "appendEnvelopeOperationLog (rollback) failed:", + err, + ), + ); + + return { + metaEnvelope: result.metaEnvelope, + version: result.version, + restoredFromVersion: result.restoredFromVersion, + errors: [], + }; + } catch (error) { + if (error instanceof MetaEnvelopeRollbackError) { + return { + metaEnvelope: null, + errors: [ + { + field: "version", + message: error.message, + code: error.code, + }, + ], + }; + } + console.error( + "Error in rollbackMetaEnvelope:", + error, + ); + return { + metaEnvelope: null, + errors: [ + { + message: + error instanceof Error + ? error.message + : "Failed to roll back MetaEnvelope", + code: "ROLLBACK_FAILED", + }, + ], + }; + } + }, + Permission.UPDATE, + { includePruned: true }, + ), + // Delete a MetaEnvelope with structured result removeMetaEnvelope: this.accessGuard.middleware( async ( @@ -621,6 +736,7 @@ export class GraphQLServer { computeEnvelopeHashForDelete(id); this.db .appendEnvelopeOperationLog({ + author: this.authorOf(context), eName: context.eName, metaEnvelopeId: id, envelopeHash, @@ -749,6 +865,7 @@ export class GraphQLServer { }); this.db .appendEnvelopeOperationLog({ + author: this.authorOf(context), eName: context.eName, metaEnvelopeId: result.metaEnvelope.id, envelopeHash, @@ -885,6 +1002,7 @@ export class GraphQLServer { this.db .appendEnvelopeOperationLog({ + author: this.authorOf(context), eName: context.eName, metaEnvelopeId, envelopeHash, @@ -983,6 +1101,7 @@ export class GraphQLServer { this.db .appendEnvelopeOperationLog({ + author: this.authorOf(context), eName: context.eName, metaEnvelopeId: input.bindingDocumentId, envelopeHash, @@ -1206,6 +1325,7 @@ export class GraphQLServer { }); this.db .appendEnvelopeOperationLog({ + author: this.authorOf(context), eName: context.eName, metaEnvelopeId, envelopeHash, @@ -1362,6 +1482,7 @@ export class GraphQLServer { }); this.db .appendEnvelopeOperationLog({ + author: this.authorOf(context), eName: context.eName, metaEnvelopeId: result.metaEnvelope.id, envelopeHash, @@ -1460,6 +1581,7 @@ export class GraphQLServer { }); this.db .appendEnvelopeOperationLog({ + author: this.authorOf(context), eName: context.eName, metaEnvelopeId: id, envelopeHash, @@ -1509,6 +1631,7 @@ export class GraphQLServer { const envelopeHash = computeEnvelopeHashForDelete(id); this.db .appendEnvelopeOperationLog({ + author: this.authorOf(context), eName: context.eName, metaEnvelopeId: id, envelopeHash, @@ -1560,6 +1683,7 @@ export class GraphQLServer { }); this.db .appendEnvelopeOperationLog({ + author: this.authorOf(context), eName: context.eName, metaEnvelopeId: metaInfo.metaEnvelopeId, envelopeHash, 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 index 81f66e90d..9b598cd0e 100644 --- a/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts +++ b/infrastructure/evault-core/src/core/protocol/idiomatic-graphql-api.spec.ts @@ -876,6 +876,149 @@ describe("Idiomatic GraphQL API", () => { }); }); + describe("rollbackMetaEnvelope mutation", () => { + const ROLLBACK = ` + mutation Rollback($id: ID!, $version: Int!) { + rollbackMetaEnvelope(id: $id, version: $version) { + metaEnvelope { id parsed } + version + restoredFromVersion + errors { field message code } + } + } + `; + const USER = "@undo-e2e-user"; + + const create = async (payload: Record) => { + const result = await makeGraphQLRequest( + server, + `mutation Create($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { metaEnvelope { id } } + }`, + { input: { ontology: "RollbackE2EOntology", payload, acl: ["*"] } }, + getAuthHeaders(), + ); + return result.createMetaEnvelope.metaEnvelope.id as string; + }; + + it("restores an earlier version, recording who asked for it", async () => { + const id = await create({ title: "first" }); + await makeGraphQLRequest( + server, + `mutation Update($id: ID!, $input: MetaEnvelopeInput!) { + updateMetaEnvelope(id: $id, input: $input) { errors { message } } + }`, + { + id, + input: { + ontology: "RollbackE2EOntology", + payload: { title: "second", extra: true }, + acl: ["*"], + }, + }, + getAuthHeaders(), + ); + + const result = await makeGraphQLRequest( + server, + ROLLBACK, + { id, version: 1 }, + { ...getAuthHeaders(), "X-ON-BEHALF-OF": USER }, + ); + expect(result.rollbackMetaEnvelope.errors).toEqual([]); + expect(result.rollbackMetaEnvelope.version).toBe(3); + expect(result.rollbackMetaEnvelope.restoredFromVersion).toBe(1); + expect(result.rollbackMetaEnvelope.metaEnvelope.parsed).toEqual({ + title: "first", + }); + + const history = await makeGraphQLRequest( + server, + `query History($id: ID!) { + metaEnvelopeHistory(id: $id) { + edges { node { version operation author restoredFromVersion parsed } } + } + }`, + { id }, + getAuthHeaders(), + ); + expect(history.metaEnvelopeHistory.edges[0].node).toEqual({ + version: 3, + operation: "update", + author: USER, + restoredFromVersion: 1, + parsed: { title: "first" }, + }); + + // The operation log carries the author too. It is written + // best-effort after the response, so give it a moment. + let entry: any; + for (let attempt = 0; attempt < 20 && !entry; attempt++) { + const logs = await fetch( + `http://localhost:${server.fastifyPort}/logs?limit=100`, + { headers: { "X-ENAME": evault.w3id } }, + ).then((r) => r.json()); + entry = logs.logs.find( + (l: any) => l.metaEnvelopeId === id && l.author === USER, + ); + if (!entry) await new Promise((r) => setTimeout(r, 100)); + } + expect(entry?.operation).toBe("update"); + }); + + it("brings back a removed record", async () => { + const id = await create({ title: "keep" }); + await makeGraphQLRequest( + server, + `mutation Remove($id: ID!) { removeMetaEnvelope(id: $id) { success } }`, + { id }, + getAuthHeaders(), + ); + + const result = await makeGraphQLRequest( + server, + ROLLBACK, + { id, version: 1 }, + getAuthHeaders(), + ); + expect(result.rollbackMetaEnvelope.errors).toEqual([]); + expect(result.rollbackMetaEnvelope.version).toBe(3); + + const live = await makeGraphQLRequest( + server, + `query Live($id: ID!) { metaEnvelope(id: $id) { parsed } }`, + { id }, + getAuthHeaders(), + ); + expect(live.metaEnvelope.parsed).toEqual({ title: "keep" }); + }); + + it("returns a structured error for an unknown version", async () => { + const id = await create({ title: "only" }); + const result = await makeGraphQLRequest( + server, + ROLLBACK, + { id, version: 42 }, + getAuthHeaders(), + ); + expect(result.rollbackMetaEnvelope.metaEnvelope).toBeNull(); + expect(result.rollbackMetaEnvelope.errors[0].code).toBe( + "VERSION_NOT_FOUND", + ); + }); + + it("requires authentication", async () => { + await expect( + makeGraphQLRequest( + server, + ROLLBACK, + { id: "any-id", version: 1 }, + { "X-ENAME": evault.w3id }, + ), + ).rejects.toThrow(); + }); + }); + describe("Envelope.fieldKey resolver", () => { it("should return fieldKey as alias for ontology", async () => { const mutation = ` diff --git a/infrastructure/evault-core/src/core/protocol/typedefs.ts b/infrastructure/evault-core/src/core/protocol/typedefs.ts index 3cfb7fab5..b9d86f64d 100644 --- a/infrastructure/evault-core/src/core/protocol/typedefs.ts +++ b/infrastructure/evault-core/src/core/protocol/typedefs.ts @@ -108,6 +108,10 @@ export const typeDefs = /* GraphQL */ ` parsed: JSON "The platform that made the write, when known" requestingPlatform: String + "The user the writing platform said it acted for (X-ON-BEHALF-OF or a wallet token), when known" + author: String + "Set when this version was written by rolling back to an earlier version" + restoredFromVersion: Int "ISO-8601 time the version was recorded" createdAt: String! } @@ -182,6 +186,17 @@ export const typeDefs = /* GraphQL */ ` errors: [UserError!] } + type RollbackMetaEnvelopePayload { + "The MetaEnvelope as it stands after the rollback, null if errors occurred" + metaEnvelope: MetaEnvelope + "The new version the rollback was recorded as" + version: Int + "The version whose state was restored" + restoredFromVersion: Int + "List of errors that occurred during the mutation" + errors: [UserError!] + } + type DeleteMetaEnvelopePayload { "The ID of the deleted MetaEnvelope" deletedId: ID! @@ -484,6 +499,14 @@ export const typeDefs = /* GraphQL */ ` "Update an existing MetaEnvelope by ID" updateMetaEnvelope(id: ID!, input: MetaEnvelopeInput!): UpdateMetaEnvelopePayload! + """ + Restore a MetaEnvelope to the state it had at an earlier version. The + restore is written as a new version, so history only ever grows. The + payload is replaced exactly (fields that version lacked are pruned), + the current access policy is kept, and a removed record is brought back. + """ + rollbackMetaEnvelope(id: ID!, version: Int!): RollbackMetaEnvelopePayload! + "Delete a MetaEnvelope by ID" removeMetaEnvelope(id: ID!): DeleteMetaEnvelopePayload! 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 e7d70b09b..476775b42 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts @@ -30,7 +30,7 @@ export type VaultContext = YogaInitialContext & { * authorize against — notably a JWT `kid`, which for a Registry platform token * is a signing-key id rather than a party. */ -function isEName(value: unknown): value is string { +export function isEName(value: unknown): value is string { return typeof value === "string" && value.startsWith("@") && value.length > 1; } diff --git a/skills/w3ds/reference/evault.md b/skills/w3ds/reference/evault.md index d7b57a97c..5a0cbbd9a 100644 --- a/skills/w3ds/reference/evault.md +++ b/skills/w3ds/reference/evault.md @@ -120,13 +120,30 @@ Updates never overwrite a recorded state. Every create, update and remove append ```graphql query { metaEnvelopeHistory(id: "global-id-123", first: 20) { - edges { node { version operation ontology parsed requestingPlatform createdAt } } + edges { node { version operation ontology parsed requestingPlatform author restoredFromVersion createdAt } } pageInfo { hasNextPage endCursor } totalCount } } ``` +`author` is the user the writing platform said it acted for (`X-ON-BEHALF-OF`, or a wallet-signed token). Send `X-ON-BEHALF-OF: @` on writes made for a user so history can say who. + +### Rollback + +Restores an earlier version as a new version. History only grows, and the rollback can itself be rolled back. The payload is replaced exactly (fields that version lacked are pruned), the current ACL is kept, and a removed record is brought back. Requires UPDATE. A version that records a removal cannot be restored (`VERSION_IS_DELETE`). + +```graphql +mutation { + rollbackMetaEnvelope(id: "global-id-123", version: 2) { + metaEnvelope { id parsed } + version + restoredFromVersion + errors { field message code } + } +} +``` + ### Bulk create For migrations and initial seeds only. Requires `Authorization: Bearer ` in addition to `X-ENAME`. Supports optional `id` per input (preserves IDs across migrations).