diff --git a/README.md b/README.md index 23145acbc..bd08d7c76 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,17 @@ THIS IS WORK IN PROGRESS - Team (`AgentID` → `Role`) - CoList (`Immutable[]`, addressable positions, insertAfter semantics) - Agent (`{signatoryID, recipientID}[]`) -- MultiStream (independent per-session streams of `Immutable`s) +- CoStream (independent per-session streams of `Immutable`s) - Static (single addressable `Immutable`) ## Implementation Abstractions -- MultiLog +- CoValue - Session Logs - Transactions - Private (encrypted) transactions - Trusting (unencrypted) transactions - Rulesets -- CoValue Types +- CoValue Content Types - LocalNode - Peers - AgentCredentials diff --git a/src/coValue.test.ts b/src/coValue.test.ts index a437a99c8..f1af174cf 100644 --- a/src/coValue.test.ts +++ b/src/coValue.test.ts @@ -1,197 +1,138 @@ -import { test, expect } from "bun:test"; +import { expect, test } from "bun:test"; import { - agentIDfromSessionID, + CoValue, + Transaction, getAgent, getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./multilog"; +} from "./coValue"; import { LocalNode } from "./node"; +import { sign } from "./crypto"; -test("Empty COJSON Map works", () => { +test("Can create coValue with new agent credentials and add transaction to it", () => { const agentCredential = newRandomAgentCredential(); const node = new LocalNode( agentCredential, newRandomSessionID(getAgentID(getAgent(agentCredential))) ); - const multilog = node.createMultiLog({ - type: "comap", + const coValue = node.createCoValue({ + type: "costream", ruleset: { type: "unsafeAllowAll" }, meta: null, }); - const content = multilog.getCurrentContent(); - - if (content.type !== "comap") { - throw new Error("Expected map"); - } - - expect(content.type).toEqual("comap"); - expect([...content.keys()]).toEqual([]); - expect(content.toJSON()).toEqual({}); -}); - -test("Can insert and delete Map entries in edit()", () => { - const agentCredential = newRandomAgentCredential(); - const node = new LocalNode( - agentCredential, - newRandomSessionID(getAgentID(getAgent(agentCredential))) - ); - - const multilog = node.createMultiLog({ - type: "comap", - ruleset: { type: "unsafeAllowAll" }, - meta: null, - }); - - const content = multilog.getCurrentContent(); - - if (content.type !== "comap") { - throw new Error("Expected map"); - } - - expect(content.type).toEqual("comap"); - - content.edit((editable) => { - editable.set("hello", "world", "trusting"); - expect(editable.get("hello")).toEqual("world"); - editable.set("foo", "bar", "trusting"); - expect(editable.get("foo")).toEqual("bar"); - expect([...editable.keys()]).toEqual(["hello", "foo"]); - editable.delete("foo", "trusting"); - expect(editable.get("foo")).toEqual(undefined); - }); -}); - -test("Can get map entry values at different points in time", () => { - const agentCredential = newRandomAgentCredential(); - const node = new LocalNode( - agentCredential, - newRandomSessionID(getAgentID(getAgent(agentCredential))) - ); - - const multilog = node.createMultiLog({ - type: "comap", - ruleset: { type: "unsafeAllowAll" }, - meta: null, - }); - - const content = multilog.getCurrentContent(); - - if (content.type !== "comap") { - throw new Error("Expected map"); - } - - expect(content.type).toEqual("comap"); - - content.edit((editable) => { - const beforeA = Date.now(); - Bun.sleepSync(1); - editable.set("hello", "A", "trusting"); - const beforeB = Date.now(); - Bun.sleepSync(1); - editable.set("hello", "B", "trusting"); - const beforeC = Date.now(); - Bun.sleepSync(1); - editable.set("hello", "C", "trusting"); - expect(editable.get("hello")).toEqual("C"); - expect(editable.getAtTime("hello", Date.now())).toEqual("C"); - expect(editable.getAtTime("hello", beforeA)).toEqual(undefined); - expect(editable.getAtTime("hello", beforeB)).toEqual("A"); - expect(editable.getAtTime("hello", beforeC)).toEqual("B"); - }); -}); - -test("Can get all historic values of key", () => { - const agentCredential = newRandomAgentCredential(); - const node = new LocalNode( - agentCredential, - newRandomSessionID(getAgentID(getAgent(agentCredential))) - ); - - const multilog = node.createMultiLog({ - type: "comap", - ruleset: { type: "unsafeAllowAll" }, - meta: null, - }); - - const content = multilog.getCurrentContent(); - - if (content.type !== "comap") { - throw new Error("Expected map"); - } - - expect(content.type).toEqual("comap"); - - content.edit((editable) => { - editable.set("hello", "A", "trusting"); - const txA = editable.getLastTxID("hello"); - editable.set("hello", "B", "trusting"); - const txB = editable.getLastTxID("hello"); - editable.delete("hello", "trusting"); - const txDel = editable.getLastTxID("hello"); - editable.set("hello", "C", "trusting"); - const txC = editable.getLastTxID("hello"); - expect( - editable.getHistory("hello") - ).toEqual([ + const transaction: Transaction = { + privacy: "trusting", + madeAt: Date.now(), + changes: [ { - txID: txA, - value: "A", - at: txA && multilog.getTx(txA)?.madeAt, + hello: "world", }, - { - txID: txB, - value: "B", - at: txB && multilog.getTx(txB)?.madeAt, - }, - { - txID: txDel, - value: undefined, - at: txDel && multilog.getTx(txDel)?.madeAt, - }, - { - txID: txC, - value: "C", - at: txC && multilog.getTx(txC)?.madeAt, - }, - ]); - }); + ], + }; + + const { expectedNewHash } = coValue.expectedNewHashAfter( + node.ownSessionID, + [transaction] + ); + + expect( + coValue.tryAddTransactions( + node.ownSessionID, + [transaction], + expectedNewHash, + sign(agentCredential.signatorySecret, expectedNewHash) + ) + ).toBe(true); }); -test("Can get last tx ID for a key", () => { +test("transactions with wrong signature are rejected", () => { + const agent = newRandomAgentCredential(); + const wrongAgent = newRandomAgentCredential(); const agentCredential = newRandomAgentCredential(); const node = new LocalNode( agentCredential, newRandomSessionID(getAgentID(getAgent(agentCredential))) ); - const multilog = node.createMultiLog({ - type: "comap", + const coValue = node.createCoValue({ + type: "costream", ruleset: { type: "unsafeAllowAll" }, meta: null, }); - const content = multilog.getCurrentContent(); + const transaction: Transaction = { + privacy: "trusting", + madeAt: Date.now(), + changes: [ + { + hello: "world", + }, + ], + }; - if (content.type !== "comap") { - throw new Error("Expected map"); - } + const { expectedNewHash } = coValue.expectedNewHashAfter( + node.ownSessionID, + [transaction] + ); - expect(content.type).toEqual("comap"); - - content.edit((editable) => { - expect(editable.getLastTxID("hello")).toEqual(undefined); - editable.set("hello", "A", "trusting"); - const sessionID = editable.getLastTxID("hello")?.sessionID; - expect(sessionID && agentIDfromSessionID(sessionID)).toEqual( - getAgentID(getAgent(agentCredential)) - ); - expect(editable.getLastTxID("hello")?.txIndex).toEqual(0); - editable.set("hello", "B", "trusting"); - expect(editable.getLastTxID("hello")?.txIndex).toEqual(1); - editable.set("hello", "C", "trusting"); - expect(editable.getLastTxID("hello")?.txIndex).toEqual(2); - }); + expect( + coValue.tryAddTransactions( + node.ownSessionID, + [transaction], + expectedNewHash, + sign(wrongAgent.signatorySecret, expectedNewHash) + ) + ).toBe(false); +}); + +test("transactions with correctly signed, but wrong hash are rejected", () => { + const agent = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const coValue = node.createCoValue({ + type: "costream", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const transaction: Transaction = { + privacy: "trusting", + madeAt: Date.now(), + changes: [ + { + hello: "world", + }, + ], + }; + + const { expectedNewHash } = coValue.expectedNewHashAfter( + node.ownSessionID, + [ + { + privacy: "trusting", + madeAt: Date.now(), + changes: [ + { + hello: "wrong", + }, + ], + }, + ] + ); + + expect( + coValue.tryAddTransactions( + node.ownSessionID, + [transaction], + expectedNewHash, + sign(agent.signatorySecret, expectedNewHash) + ) + ).toBe(false); }); diff --git a/src/coValue.ts b/src/coValue.ts index 51c6f4007..6bb8e06a4 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -1,239 +1,521 @@ -import { JsonAtom, JsonObject, JsonValue } from "./jsonValue"; -import { MultiLog, MultiLogID, TransactionID } from "./multilog"; +import { randomBytes } from "@noble/hashes/utils"; +import { CoList, CoMap, ContentType, Static, CoStream } from "./contentType"; +import { + Encrypted, + Hash, + KeySecret, + RecipientID, + RecipientSecret, + SignatoryID, + SignatorySecret, + Signature, + StreamingHash, + getRecipientID, + getSignatoryID, + newRandomRecipient, + newRandomSignatory, + openAs, + shortHash, + sign, + verify, + encryptForTransaction, + decryptForTransaction, + KeyID, + unsealKeySecret, +} from "./crypto"; +import { JsonValue } from "./jsonValue"; +import { base58 } from "@scure/base"; +import { + PermissionsDef as RulesetDef, + determineValidTransactions, + expectTeamContent, +} from "./permissions"; +import { LocalNode } from "./node"; +import { CoValueKnownState, NewContentMessage } from "./sync"; -export type CoValueID = MultiLogID & { - readonly __type: T; +export type RawCoValueID = `coval_${string}`; + +export type CoValueHeader = { + type: ContentType["type"]; + ruleset: RulesetDef; + meta: JsonValue; }; -export type CoValue = - | CoMap<{[key: string]: JsonValue}, JsonValue> - | CoList - | MultiStream - | Static; +function coValueIDforHeader(header: CoValueHeader): RawCoValueID { + const hash = shortHash(header); + return `coval_${hash.slice("shortHash_".length)}`; +} -type MapOp = { - txID: TransactionID; +export type SessionID = `session_${string}_${AgentID}`; + +export function agentIDfromSessionID(sessionID: SessionID): AgentID { + return `agent_${sessionID.substring(sessionID.lastIndexOf("_") + 1)}`; +} + +export function newRandomSessionID(agentID: AgentID): SessionID { + return `session_${base58.encode(randomBytes(8))}_${agentID}`; +} + +type SessionLog = { + transactions: Transaction[]; + lastHash?: Hash; + streamingHash: StreamingHash; + lastSignature: Signature; +}; + +export type PrivateTransaction = { + privacy: "private"; madeAt: number; - changeIdx: number; -} & MapOpPayload; + keyUsed: KeyID; + encryptedChanges: Encrypted< + JsonValue[], + { in: RawCoValueID; tx: TransactionID } + >; +}; -// TODO: add after TransactionID[] for conflicts/ordering -export type MapOpPayload = - | { - op: "insert"; - key: K; - value: V; - } - | { - op: "delete"; - key: K; - }; +export type TrustingTransaction = { + privacy: "trusting"; + madeAt: number; + changes: JsonValue[]; +}; -export class CoMap< - M extends {[key: string]: JsonValue}, - Meta extends JsonValue, - K extends string = keyof M & string, - V extends JsonValue = M[K], - MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]} -> { - id: CoValueID>; - multiLog: MultiLog; - type: "comap" = "comap"; - ops: {[KK in K]?: MapOp[]}; +export type Transaction = PrivateTransaction | TrustingTransaction; - constructor(multiLog: MultiLog) { - this.id = multiLog.id as CoValueID>; - this.multiLog = multiLog; - this.ops = {}; +export type DecryptedTransaction = { + txID: TransactionID; + changes: JsonValue[]; + madeAt: number; +}; - this.fillOpsFromMultilog(); +export type TransactionID = { sessionID: SessionID; txIndex: number }; + +export class CoValue { + id: RawCoValueID; + node: LocalNode; + header: CoValueHeader; + sessions: { [key: SessionID]: SessionLog }; + content?: ContentType; + + constructor(header: CoValueHeader, node: LocalNode) { + this.id = coValueIDforHeader(header); + this.header = header; + this.sessions = {}; + this.node = node; } - protected fillOpsFromMultilog() { - this.ops = {}; + testWithDifferentCredentials( + agentCredential: AgentCredential, + ownSessionID: SessionID + ): CoValue { + const newNode = this.node.testWithDifferentCredentials( + agentCredential, + ownSessionID + ); - for (const { txID, changes, madeAt } of this.multiLog.getValidSortedTransactions()) { - for (const [changeIdx, changeUntyped] of ( - changes - ).entries()) { - const change = changeUntyped as MapOpPayload - let entries = this.ops[change.key]; - if (!entries) { - entries = []; - this.ops[change.key] = entries; - } - entries.push({ + return newNode.expectCoValueLoaded(this.id); + } + + knownState(): CoValueKnownState { + return { + coValueID: this.id, + header: true, + sessions: Object.fromEntries( + Object.entries(this.sessions).map(([k, v]) => [ + k, + v.transactions.length, + ]) + ), + }; + } + + get meta(): JsonValue { + return this.header?.meta ?? null; + } + + nextTransactionID(): TransactionID { + const sessionID = this.node.ownSessionID; + return { + sessionID, + txIndex: this.sessions[sessionID]?.transactions.length || 0, + }; + } + + tryAddTransactions( + sessionID: SessionID, + newTransactions: Transaction[], + newHash: Hash, + newSignature: Signature + ): boolean { + const signatoryID = + this.node.knownAgents[agentIDfromSessionID(sessionID)]?.signatoryID; + + if (!signatoryID) { + console.warn("Unknown agent", agentIDfromSessionID(sessionID)); + return false; + } + + const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter( + sessionID, + newTransactions + ); + + if (newHash !== expectedNewHash) { + console.warn("Invalid hash", { newHash, expectedNewHash }); + return false; + } + + if (!verify(newSignature, newHash, signatoryID)) { + console.warn( + "Invalid signature", + newSignature, + newHash, + signatoryID + ); + return false; + } + + const transactions = this.sessions[sessionID]?.transactions ?? []; + + transactions.push(...newTransactions); + + this.sessions[sessionID] = { + transactions, + lastHash: newHash, + streamingHash: newStreamingHash, + lastSignature: newSignature, + }; + + this.content = undefined; + + this.node.syncCoValue(this); + + const _ = this.getCurrentContent(); + + return true; + } + + expectedNewHashAfter( + sessionID: SessionID, + newTransactions: Transaction[] + ): { expectedNewHash: Hash; newStreamingHash: StreamingHash } { + const streamingHash = + this.sessions[sessionID]?.streamingHash.clone() ?? + new StreamingHash(); + for (const transaction of newTransactions) { + streamingHash.update(transaction); + } + + const newStreamingHash = streamingHash.clone(); + + return { + expectedNewHash: streamingHash.digest(), + newStreamingHash, + }; + } + + makeTransaction( + changes: JsonValue[], + privacy: "private" | "trusting" + ): boolean { + const madeAt = Date.now(); + + let transaction: Transaction; + + if (privacy === "private") { + const { secret: keySecret, id: keyID } = this.getCurrentReadKey(); + + transaction = { + privacy: "private", + madeAt, + keyUsed: keyID, + encryptedChanges: encryptForTransaction(changes, keySecret, { + in: this.id, + tx: this.nextTransactionID(), + }), + }; + } else { + transaction = { + privacy: "trusting", + madeAt, + changes, + }; + } + + const sessionID = this.node.ownSessionID; + + const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [ + transaction, + ]); + + const signature = sign( + this.node.agentCredential.signatorySecret, + expectedNewHash + ); + + return this.tryAddTransactions( + sessionID, + [transaction], + expectedNewHash, + signature + ); + } + + getCurrentContent(): ContentType { + if (this.content) { + return this.content; + } + + if (this.header.type === "comap") { + this.content = new CoMap(this); + } else if (this.header.type === "colist") { + this.content = new CoList(this); + } else if (this.header.type === "costream") { + this.content = new CoStream(this); + } else if (this.header.type === "static") { + this.content = new Static(this); + } else { + throw new Error(`Unknown coValue type ${this.header.type}`); + } + + return this.content; + } + + getValidSortedTransactions(): DecryptedTransaction[] { + const validTransactions = determineValidTransactions(this); + + const allTransactions: DecryptedTransaction[] = validTransactions.map( + ({ txID, tx }) => { + return { txID, - madeAt, - changeIdx, - ...(change as any), - }); + madeAt: tx.madeAt, + changes: + tx.privacy === "private" + ? decryptForTransaction( + tx.encryptedChanges, + this.getReadKey(tx.keyUsed), + { + in: this.id, + tx: txID, + } + ) || + (() => { + throw new Error("Couldn't decrypt changes"); + })() + : tx.changes, + }; } - } + ); + allTransactions.sort( + (a, b) => + a.madeAt - b.madeAt || + (a.txID.sessionID < b.txID.sessionID ? -1 : 1) || + a.txID.txIndex - b.txID.txIndex + ); + + return allTransactions; } - keys(): K[] { - return Object.keys(this.ops) as K[]; - } + getCurrentReadKey(): { secret: KeySecret; id: KeyID } { + if (this.header.ruleset.type === "team") { + const content = expectTeamContent(this.getCurrentContent()); - get(key: KK): M[KK] | undefined { - const ops = this.ops[key]; - if (!ops) { - return undefined; - } + const currentKeyId = content.get("readKey")?.keyID; - let lastEntry = ops[ops.length - 1]; + if (!currentKeyId) { + throw new Error("No readKey set"); + } - if (lastEntry.op === "delete") { - return undefined; + const secret = this.getReadKey(currentKeyId); + + return { + secret: secret, + id: currentKeyId, + }; + } else if (this.header.ruleset.type === "ownedByTeam") { + return this.node + .expectCoValueLoaded(this.header.ruleset.team) + .getCurrentReadKey(); } else { - return lastEntry.value; + throw new Error( + "Only teams or values owned by teams have read secrets" + ); } } - getAtTime(key: KK, time: number): M[KK] | undefined { - const ops = this.ops[key]; - if (!ops) { - return undefined; - } + getReadKey(keyID: KeyID): KeySecret { + if (this.header.ruleset.type === "team") { + const content = expectTeamContent(this.getCurrentContent()); - const lastOpBeforeOrAtTime = ops.findLast((op) => op.madeAt <= time); + const readKeyHistory = content.getHistory("readKey"); - if (!lastOpBeforeOrAtTime) { - return undefined; - } + // Try to find direct relevation of key for us - if (lastOpBeforeOrAtTime.op === "delete") { - return undefined; + for (const entry of readKeyHistory) { + if (entry.value?.keyID === keyID) { + const revealer = agentIDfromSessionID(entry.txID.sessionID); + const revealerAgent = this.node.knownAgents[revealer]; + + if (!revealerAgent) { + throw new Error("Unknown revealer"); + } + + const secret = openAs( + entry.value.revelation, + this.node.agentCredential.recipientSecret, + revealerAgent.recipientID, + { + in: this.id, + tx: entry.txID, + } + ); + + if (secret) return secret as KeySecret; + } + } + + // Try to find indirect revelation through previousKeys + + for (const entry of readKeyHistory) { + if (entry.value?.previousKeys?.[keyID]) { + const sealingKeyID = entry.value.keyID; + const sealingKeySecret = this.getReadKey(sealingKeyID); + + if (!sealingKeySecret) { + continue; + } + + const secret = unsealKeySecret( + { + sealed: keyID, + sealing: sealingKeyID, + encrypted: entry.value.previousKeys[keyID], + }, + sealingKeySecret + ); + + if (secret) { + return secret; + } else { + console.error( + `Sealing ${sealingKeyID} key didn't unseal ${keyID}` + ); + } + } + } + + throw new Error( + "readKey " + + keyID + + " not revealed for " + + getAgentID(getAgent(this.node.agentCredential)) + ); + } else if (this.header.ruleset.type === "ownedByTeam") { + return this.node + .expectCoValueLoaded(this.header.ruleset.team) + .getReadKey(keyID); } else { - return lastOpBeforeOrAtTime.value; + throw new Error( + "Only teams or values owned by teams have read secrets" + ); } } - getLastTxID(key: KK): TransactionID | undefined { - const ops = this.ops[key]; - if (!ops) { + getTx(txID: TransactionID): Transaction | undefined { + return this.sessions[txID.sessionID]?.transactions[txID.txIndex]; + } + + newContentSince(knownState: CoValueKnownState | undefined): NewContentMessage | undefined { + const newContent: NewContentMessage = { + action: "newContent", + coValueID: this.id, + header: knownState?.header ? undefined : this.header, + newContent: Object.fromEntries( + Object.entries(this.sessions) + .map(([sessionID, log]) => { + const newTransactions = log.transactions.slice( + knownState?.sessions[sessionID as SessionID] || 0 + ); + + if ( + newTransactions.length === 0 || + !log.lastHash || + !log.lastSignature + ) { + return undefined; + } + + return [ + sessionID, + { + after: + knownState?.sessions[ + sessionID as SessionID + ] || 0, + newTransactions, + lastHash: log.lastHash, + lastSignature: log.lastSignature, + }, + ]; + }) + .filter((x): x is Exclude => !!x) + ), + } + + if (!newContent.header && Object.keys(newContent.newContent).length === 0) { return undefined; } - const lastEntry = ops[ops.length - 1]; - - return lastEntry.txID; - } - - getHistory(key: KK): {at: number, txID: TransactionID, value: M[KK] | undefined}[] { - const ops = this.ops[key]; - if (!ops) { - return []; - } - - const history: {at: number, txID: TransactionID, value: M[KK] | undefined}[] = []; - - for (const op of ops) { - if (op.op === "delete") { - history.push({at: op.madeAt, txID: op.txID, value: undefined}); - } else { - history.push({at: op.madeAt, txID: op.txID, value: op.value}); - } - } - - return history; - } - - toJSON(): JsonObject { - const json: JsonObject = {}; - - for (const key of this.keys()) { - const value = this.get(key); - if (value !== undefined) { - json[key] = value; - } - } - - return json; - } - - edit(changer: (editable: WriteableCoMap) => void): CoMap { - const editable = new WriteableCoMap(this.multiLog); - changer(editable); - return new CoMap(this.multiLog); + return newContent; } } -export class WriteableCoMap< - M extends {[key: string]: JsonValue}, - Meta extends JsonValue, - K extends string = keyof M & string, - V extends JsonValue = M[K], - MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]} -> extends CoMap { - set(key: KK, value: M[KK], privacy: "private" | "trusting" = "private"): void { - this.multiLog.makeTransaction([ - { - op: "insert", - key, - value, - }, - ], privacy); +export type AgentID = `agent_${string}`; - this.fillOpsFromMultilog(); - } +export type Agent = { + signatoryID: SignatoryID; + recipientID: RecipientID; +}; - delete(key: K, privacy: "private" | "trusting" = "private"): void { - this.multiLog.makeTransaction([ - { - op: "delete", - key, - }, - ], privacy); - - this.fillOpsFromMultilog(); - } +export function getAgent(agentCredential: AgentCredential) { + return { + signatoryID: getSignatoryID(agentCredential.signatorySecret), + recipientID: getRecipientID(agentCredential.recipientSecret), + }; } -export class CoList { - id: CoValueID>; - type: "colist" = "colist"; - - constructor(multilog: MultiLog) { - this.id = multilog.id as CoValueID>; - } - - toJSON(): JsonObject { - throw new Error("Method not implemented."); - } +export function getAgentCoValueHeader(agent: Agent): CoValueHeader { + return { + type: "comap", + ruleset: { + type: "agent", + initialSignatoryID: agent.signatoryID, + initialRecipientID: agent.recipientID, + }, + meta: null, + }; } -export class MultiStream { - id: CoValueID>; - type: "multistream" = "multistream"; - - constructor(multilog: MultiLog) { - this.id = multilog.id as CoValueID>; - } - - toJSON(): JsonObject { - throw new Error("Method not implemented."); - } +export function getAgentID(agent: Agent): AgentID { + return `agent_${coValueIDforHeader(getAgentCoValueHeader(agent)).slice( + "coval_".length + )}`; } -export class Static { - id: CoValueID>; - type: "static" = "static"; - - constructor(multilog: MultiLog) { - this.id = multilog.id as CoValueID>; - } - - toJSON(): JsonObject { - throw new Error("Method not implemented."); - } +export function agentIDAsCoValueID(agentID: AgentID): RawCoValueID { + return `coval_${agentID.substring("agent_".length)}`; } -export function expectMap(content: CoValue): CoMap<{ [key: string]: string }, {}> { - if (content.type !== "comap") { - throw new Error("Expected map"); - } +export type AgentCredential = { + signatorySecret: SignatorySecret; + recipientSecret: RecipientSecret; +}; - return content as CoMap<{ [key: string]: string }, {}>; +export function newRandomAgentCredential(): AgentCredential { + const signatorySecret = newRandomSignatory(); + const recipientSecret = newRandomRecipient(); + return { signatorySecret, recipientSecret }; } + +// type Role = "admin" | "writer" | "reader"; + +// type PermissionsDef = CJMap; diff --git a/src/contentType.test.ts b/src/contentType.test.ts new file mode 100644 index 000000000..b0adfabf2 --- /dev/null +++ b/src/contentType.test.ts @@ -0,0 +1,197 @@ +import { test, expect } from "bun:test"; +import { + agentIDfromSessionID, + getAgent, + getAgentID, + newRandomAgentCredential, + newRandomSessionID, +} from "./coValue"; +import { LocalNode } from "./node"; + +test("Empty COJSON Map works", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const coValue = node.createCoValue({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = coValue.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + expect([...content.keys()]).toEqual([]); + expect(content.toJSON()).toEqual({}); +}); + +test("Can insert and delete Map entries in edit()", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const coValue = node.createCoValue({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = coValue.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + + content.edit((editable) => { + editable.set("hello", "world", "trusting"); + expect(editable.get("hello")).toEqual("world"); + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toEqual("bar"); + expect([...editable.keys()]).toEqual(["hello", "foo"]); + editable.delete("foo", "trusting"); + expect(editable.get("foo")).toEqual(undefined); + }); +}); + +test("Can get map entry values at different points in time", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const coValue = node.createCoValue({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = coValue.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + + content.edit((editable) => { + const beforeA = Date.now(); + Bun.sleepSync(1); + editable.set("hello", "A", "trusting"); + const beforeB = Date.now(); + Bun.sleepSync(1); + editable.set("hello", "B", "trusting"); + const beforeC = Date.now(); + Bun.sleepSync(1); + editable.set("hello", "C", "trusting"); + expect(editable.get("hello")).toEqual("C"); + expect(editable.getAtTime("hello", Date.now())).toEqual("C"); + expect(editable.getAtTime("hello", beforeA)).toEqual(undefined); + expect(editable.getAtTime("hello", beforeB)).toEqual("A"); + expect(editable.getAtTime("hello", beforeC)).toEqual("B"); + }); +}); + +test("Can get all historic values of key", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const coValue = node.createCoValue({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = coValue.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + + content.edit((editable) => { + editable.set("hello", "A", "trusting"); + const txA = editable.getLastTxID("hello"); + editable.set("hello", "B", "trusting"); + const txB = editable.getLastTxID("hello"); + editable.delete("hello", "trusting"); + const txDel = editable.getLastTxID("hello"); + editable.set("hello", "C", "trusting"); + const txC = editable.getLastTxID("hello"); + expect( + editable.getHistory("hello") + ).toEqual([ + { + txID: txA, + value: "A", + at: txA && coValue.getTx(txA)?.madeAt, + }, + { + txID: txB, + value: "B", + at: txB && coValue.getTx(txB)?.madeAt, + }, + { + txID: txDel, + value: undefined, + at: txDel && coValue.getTx(txDel)?.madeAt, + }, + { + txID: txC, + value: "C", + at: txC && coValue.getTx(txC)?.madeAt, + }, + ]); + }); +}); + +test("Can get last tx ID for a key", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const coValue = node.createCoValue({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = coValue.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + + content.edit((editable) => { + expect(editable.getLastTxID("hello")).toEqual(undefined); + editable.set("hello", "A", "trusting"); + const sessionID = editable.getLastTxID("hello")?.sessionID; + expect(sessionID && agentIDfromSessionID(sessionID)).toEqual( + getAgentID(getAgent(agentCredential)) + ); + expect(editable.getLastTxID("hello")?.txIndex).toEqual(0); + editable.set("hello", "B", "trusting"); + expect(editable.getLastTxID("hello")?.txIndex).toEqual(1); + editable.set("hello", "C", "trusting"); + expect(editable.getLastTxID("hello")?.txIndex).toEqual(2); + }); +}); diff --git a/src/contentType.ts b/src/contentType.ts new file mode 100644 index 000000000..4331d9975 --- /dev/null +++ b/src/contentType.ts @@ -0,0 +1,239 @@ +import { JsonAtom, JsonObject, JsonValue } from "./jsonValue"; +import { CoValue, RawCoValueID, TransactionID } from "./coValue"; + +export type CoValueID = RawCoValueID & { + readonly __type: T; +}; + +export type ContentType = + | CoMap<{[key: string]: JsonValue}, JsonValue> + | CoList + | CoStream + | Static; + +type MapOp = { + txID: TransactionID; + madeAt: number; + changeIdx: number; +} & MapOpPayload; + +// TODO: add after TransactionID[] for conflicts/ordering +export type MapOpPayload = + | { + op: "insert"; + key: K; + value: V; + } + | { + op: "delete"; + key: K; + }; + +export class CoMap< + M extends {[key: string]: JsonValue}, + Meta extends JsonValue, + K extends string = keyof M & string, + V extends JsonValue = M[K], + MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]} +> { + id: CoValueID>; + coValue: CoValue; + type: "comap" = "comap"; + ops: {[KK in K]?: MapOp[]}; + + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; + this.coValue = coValue; + this.ops = {}; + + this.fillOpsFromCoValue(); + } + + protected fillOpsFromCoValue() { + this.ops = {}; + + for (const { txID, changes, madeAt } of this.coValue.getValidSortedTransactions()) { + for (const [changeIdx, changeUntyped] of ( + changes + ).entries()) { + const change = changeUntyped as MapOpPayload + let entries = this.ops[change.key]; + if (!entries) { + entries = []; + this.ops[change.key] = entries; + } + entries.push({ + txID, + madeAt, + changeIdx, + ...(change as any), + }); + } + } + } + + keys(): K[] { + return Object.keys(this.ops) as K[]; + } + + get(key: KK): M[KK] | undefined { + const ops = this.ops[key]; + if (!ops) { + return undefined; + } + + let lastEntry = ops[ops.length - 1]; + + if (lastEntry.op === "delete") { + return undefined; + } else { + return lastEntry.value; + } + } + + getAtTime(key: KK, time: number): M[KK] | undefined { + const ops = this.ops[key]; + if (!ops) { + return undefined; + } + + const lastOpBeforeOrAtTime = ops.findLast((op) => op.madeAt <= time); + + if (!lastOpBeforeOrAtTime) { + return undefined; + } + + if (lastOpBeforeOrAtTime.op === "delete") { + return undefined; + } else { + return lastOpBeforeOrAtTime.value; + } + } + + getLastTxID(key: KK): TransactionID | undefined { + const ops = this.ops[key]; + if (!ops) { + return undefined; + } + + const lastEntry = ops[ops.length - 1]; + + return lastEntry.txID; + } + + getHistory(key: KK): {at: number, txID: TransactionID, value: M[KK] | undefined}[] { + const ops = this.ops[key]; + if (!ops) { + return []; + } + + const history: {at: number, txID: TransactionID, value: M[KK] | undefined}[] = []; + + for (const op of ops) { + if (op.op === "delete") { + history.push({at: op.madeAt, txID: op.txID, value: undefined}); + } else { + history.push({at: op.madeAt, txID: op.txID, value: op.value}); + } + } + + return history; + } + + toJSON(): JsonObject { + const json: JsonObject = {}; + + for (const key of this.keys()) { + const value = this.get(key); + if (value !== undefined) { + json[key] = value; + } + } + + return json; + } + + edit(changer: (editable: WriteableCoMap) => void): CoMap { + const editable = new WriteableCoMap(this.coValue); + changer(editable); + return new CoMap(this.coValue); + } +} + +export class WriteableCoMap< + M extends {[key: string]: JsonValue}, + Meta extends JsonValue, + K extends string = keyof M & string, + V extends JsonValue = M[K], + MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]} +> extends CoMap { + set(key: KK, value: M[KK], privacy: "private" | "trusting" = "private"): void { + this.coValue.makeTransaction([ + { + op: "insert", + key, + value, + }, + ], privacy); + + this.fillOpsFromCoValue(); + } + + delete(key: K, privacy: "private" | "trusting" = "private"): void { + this.coValue.makeTransaction([ + { + op: "delete", + key, + }, + ], privacy); + + this.fillOpsFromCoValue(); + } +} + +export class CoList { + id: CoValueID>; + type: "colist" = "colist"; + + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; + } + + toJSON(): JsonObject { + throw new Error("Method not implemented."); + } +} + +export class CoStream { + id: CoValueID>; + type: "costream" = "costream"; + + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; + } + + toJSON(): JsonObject { + throw new Error("Method not implemented."); + } +} + +export class Static { + id: CoValueID>; + type: "static" = "static"; + + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; + } + + toJSON(): JsonObject { + throw new Error("Method not implemented."); + } +} + +export function expectMap(content: ContentType): CoMap<{ [key: string]: string }, {}> { + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + return content as CoMap<{ [key: string]: string }, {}>; +} diff --git a/src/crypto.ts b/src/crypto.ts index b5ba53ece..e1c7820b9 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -5,7 +5,7 @@ import { base58, base64url } from "@scure/base"; import stableStringify from "fast-json-stable-stringify"; import { blake3 } from "@noble/hashes/blake3"; import { randomBytes } from "@noble/ciphers/webcrypto/utils"; -import { MultiLogID, SessionID, TransactionID } from "./multilog"; +import { RawCoValueID, SessionID, TransactionID } from "./coValue"; export type SignatorySecret = `signatorySecret_z${string}`; export type SignatoryID = `signatory_z${string}`; @@ -72,7 +72,7 @@ export function seal( message: T, from: RecipientSecret, to: Set, - nOnceMaterial: { in: MultiLogID; tx: TransactionID } + nOnceMaterial: { in: RawCoValueID; tx: TransactionID } ): SealedSet { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) @@ -113,7 +113,7 @@ export function openAs( sealedSet: SealedSet, recipient: RecipientSecret, from: RecipientID, - nOnceMaterial: { in: MultiLogID; tx: TransactionID } + nOnceMaterial: { in: RawCoValueID; tx: TransactionID } ): T | undefined { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) @@ -219,8 +219,8 @@ function encrypt( export function encryptForTransaction( value: T, keySecret: KeySecret, - nOnceMaterial: { in: MultiLogID; tx: TransactionID } -): Encrypted { + nOnceMaterial: { in: RawCoValueID; tx: TransactionID } +): Encrypted { return encrypt(value, keySecret, nOnceMaterial); } @@ -273,9 +273,9 @@ function decrypt( } export function decryptForTransaction( - encrypted: Encrypted, + encrypted: Encrypted, keySecret: KeySecret, - nOnceMaterial: { in: MultiLogID; tx: TransactionID } + nOnceMaterial: { in: RawCoValueID; tx: TransactionID } ): T | undefined { return decrypt(encrypted, keySecret, nOnceMaterial); } diff --git a/src/index.ts b/src/index.ts index 709cc2e15..9ddcd6bca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,14 @@ -import { CoValue } from "./coValue"; +import { ContentType } from "./contentType"; import { JsonValue } from "./jsonValue"; -import { MultiLog } from "./multilog"; +import { CoValue } from "./coValue"; import { LocalNode } from "./node"; -type Value = JsonValue | CoValue; +type Value = JsonValue | ContentType; export { JsonValue, - CoValue, + ContentType, Value, LocalNode, - MultiLog + CoValue } diff --git a/src/jsonValue.ts b/src/jsonValue.ts index fd37d8859..67bc29e0c 100644 --- a/src/jsonValue.ts +++ b/src/jsonValue.ts @@ -1,6 +1,6 @@ -import { CoValueID, CoValue } from "./coValue"; +import { CoValueID, ContentType } from "./contentType"; export type JsonAtom = string | number | boolean | null; -export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID; +export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID; export type JsonArray = JsonValue[]; export type JsonObject = { [key: string]: JsonValue; }; diff --git a/src/multilog.test.ts b/src/multilog.test.ts deleted file mode 100644 index 09fa2aa0f..000000000 --- a/src/multilog.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { expect, test } from "bun:test"; -import { - MultiLog, - Transaction, - getAgent, - getAgentID, - newRandomAgentCredential, - newRandomSessionID, -} from "./multilog"; -import { LocalNode } from "./node"; -import { sign } from "./crypto"; - -test("Can create multilog with new agent credentials and add transaction to it", () => { - const agentCredential = newRandomAgentCredential(); - const node = new LocalNode( - agentCredential, - newRandomSessionID(getAgentID(getAgent(agentCredential))) - ); - - const multilog = node.createMultiLog({ - type: "multistream", - ruleset: { type: "unsafeAllowAll" }, - meta: null, - }); - - const transaction: Transaction = { - privacy: "trusting", - madeAt: Date.now(), - changes: [ - { - hello: "world", - }, - ], - }; - - const { expectedNewHash } = multilog.expectedNewHashAfter( - node.ownSessionID, - [transaction] - ); - - expect( - multilog.tryAddTransactions( - node.ownSessionID, - [transaction], - expectedNewHash, - sign(agentCredential.signatorySecret, expectedNewHash) - ) - ).toBe(true); -}); - -test("transactions with wrong signature are rejected", () => { - const agent = newRandomAgentCredential(); - const wrongAgent = newRandomAgentCredential(); - const agentCredential = newRandomAgentCredential(); - const node = new LocalNode( - agentCredential, - newRandomSessionID(getAgentID(getAgent(agentCredential))) - ); - - const multilog = node.createMultiLog({ - type: "multistream", - ruleset: { type: "unsafeAllowAll" }, - meta: null, - }); - - const transaction: Transaction = { - privacy: "trusting", - madeAt: Date.now(), - changes: [ - { - hello: "world", - }, - ], - }; - - const { expectedNewHash } = multilog.expectedNewHashAfter( - node.ownSessionID, - [transaction] - ); - - expect( - multilog.tryAddTransactions( - node.ownSessionID, - [transaction], - expectedNewHash, - sign(wrongAgent.signatorySecret, expectedNewHash) - ) - ).toBe(false); -}); - -test("transactions with correctly signed, but wrong hash are rejected", () => { - const agent = newRandomAgentCredential(); - const agentCredential = newRandomAgentCredential(); - const node = new LocalNode( - agentCredential, - newRandomSessionID(getAgentID(getAgent(agentCredential))) - ); - - const multilog = node.createMultiLog({ - type: "multistream", - ruleset: { type: "unsafeAllowAll" }, - meta: null, - }); - - const transaction: Transaction = { - privacy: "trusting", - madeAt: Date.now(), - changes: [ - { - hello: "world", - }, - ], - }; - - const { expectedNewHash } = multilog.expectedNewHashAfter( - node.ownSessionID, - [ - { - privacy: "trusting", - madeAt: Date.now(), - changes: [ - { - hello: "wrong", - }, - ], - }, - ] - ); - - expect( - multilog.tryAddTransactions( - node.ownSessionID, - [transaction], - expectedNewHash, - sign(agent.signatorySecret, expectedNewHash) - ) - ).toBe(false); -}); diff --git a/src/multilog.ts b/src/multilog.ts deleted file mode 100644 index a7282aa76..000000000 --- a/src/multilog.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { randomBytes } from "@noble/hashes/utils"; -import { CoList, CoMap, CoValue, Static, MultiStream } from "./coValue"; -import { - Encrypted, - Hash, - KeySecret, - RecipientID, - RecipientSecret, - SignatoryID, - SignatorySecret, - Signature, - StreamingHash, - getRecipientID, - getSignatoryID, - newRandomRecipient, - newRandomSignatory, - openAs, - shortHash, - sign, - verify, - encryptForTransaction, - decryptForTransaction, - KeyID, - unsealKeySecret, -} from "./crypto"; -import { JsonValue } from "./jsonValue"; -import { base58 } from "@scure/base"; -import { - PermissionsDef as RulesetDef, - determineValidTransactions, - expectTeamContent, -} from "./permissions"; -import { LocalNode } from "./node"; -import { MultiLogKnownState, NewContentMessage } from "./sync"; - -export type MultiLogID = `coval_${string}`; - -export type MultiLogHeader = { - type: CoValue["type"]; - ruleset: RulesetDef; - meta: JsonValue; -}; - -function multilogIDforHeader(header: MultiLogHeader): MultiLogID { - const hash = shortHash(header); - return `coval_${hash.slice("shortHash_".length)}`; -} - -export type SessionID = `session_${string}_${AgentID}`; - -export function agentIDfromSessionID(sessionID: SessionID): AgentID { - return `agent_${sessionID.substring(sessionID.lastIndexOf("_") + 1)}`; -} - -export function newRandomSessionID(agentID: AgentID): SessionID { - return `session_${base58.encode(randomBytes(8))}_${agentID}`; -} - -type SessionLog = { - transactions: Transaction[]; - lastHash?: Hash; - streamingHash: StreamingHash; - lastSignature: Signature; -}; - -export type PrivateTransaction = { - privacy: "private"; - madeAt: number; - keyUsed: KeyID; - encryptedChanges: Encrypted< - JsonValue[], - { in: MultiLogID; tx: TransactionID } - >; -}; - -export type TrustingTransaction = { - privacy: "trusting"; - madeAt: number; - changes: JsonValue[]; -}; - -export type Transaction = PrivateTransaction | TrustingTransaction; - -export type DecryptedTransaction = { - txID: TransactionID; - changes: JsonValue[]; - madeAt: number; -}; - -export type TransactionID = { sessionID: SessionID; txIndex: number }; - -export class MultiLog { - id: MultiLogID; - node: LocalNode; - header: MultiLogHeader; - sessions: { [key: SessionID]: SessionLog }; - content?: CoValue; - - constructor(header: MultiLogHeader, node: LocalNode) { - this.id = multilogIDforHeader(header); - this.header = header; - this.sessions = {}; - this.node = node; - } - - testWithDifferentCredentials( - agentCredential: AgentCredential, - ownSessionID: SessionID - ): MultiLog { - const newNode = this.node.testWithDifferentCredentials( - agentCredential, - ownSessionID - ); - - return newNode.expectMultiLogLoaded(this.id); - } - - knownState(): MultiLogKnownState { - return { - multilogID: this.id, - header: true, - sessions: Object.fromEntries( - Object.entries(this.sessions).map(([k, v]) => [ - k, - v.transactions.length, - ]) - ), - }; - } - - get meta(): JsonValue { - return this.header?.meta ?? null; - } - - nextTransactionID(): TransactionID { - const sessionID = this.node.ownSessionID; - return { - sessionID, - txIndex: this.sessions[sessionID]?.transactions.length || 0, - }; - } - - tryAddTransactions( - sessionID: SessionID, - newTransactions: Transaction[], - newHash: Hash, - newSignature: Signature - ): boolean { - const signatoryID = - this.node.knownAgents[agentIDfromSessionID(sessionID)]?.signatoryID; - - if (!signatoryID) { - console.warn("Unknown agent", agentIDfromSessionID(sessionID)); - return false; - } - - const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter( - sessionID, - newTransactions - ); - - if (newHash !== expectedNewHash) { - console.warn("Invalid hash", { newHash, expectedNewHash }); - return false; - } - - if (!verify(newSignature, newHash, signatoryID)) { - console.warn( - "Invalid signature", - newSignature, - newHash, - signatoryID - ); - return false; - } - - const transactions = this.sessions[sessionID]?.transactions ?? []; - - transactions.push(...newTransactions); - - this.sessions[sessionID] = { - transactions, - lastHash: newHash, - streamingHash: newStreamingHash, - lastSignature: newSignature, - }; - - this.content = undefined; - - this.node.syncMultiLog(this); - - const _ = this.getCurrentContent(); - - return true; - } - - expectedNewHashAfter( - sessionID: SessionID, - newTransactions: Transaction[] - ): { expectedNewHash: Hash; newStreamingHash: StreamingHash } { - const streamingHash = - this.sessions[sessionID]?.streamingHash.clone() ?? - new StreamingHash(); - for (const transaction of newTransactions) { - streamingHash.update(transaction); - } - - const newStreamingHash = streamingHash.clone(); - - return { - expectedNewHash: streamingHash.digest(), - newStreamingHash, - }; - } - - makeTransaction( - changes: JsonValue[], - privacy: "private" | "trusting" - ): boolean { - const madeAt = Date.now(); - - let transaction: Transaction; - - if (privacy === "private") { - const { secret: keySecret, id: keyID } = this.getCurrentReadKey(); - - transaction = { - privacy: "private", - madeAt, - keyUsed: keyID, - encryptedChanges: encryptForTransaction(changes, keySecret, { - in: this.id, - tx: this.nextTransactionID(), - }), - }; - } else { - transaction = { - privacy: "trusting", - madeAt, - changes, - }; - } - - const sessionID = this.node.ownSessionID; - - const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [ - transaction, - ]); - - const signature = sign( - this.node.agentCredential.signatorySecret, - expectedNewHash - ); - - return this.tryAddTransactions( - sessionID, - [transaction], - expectedNewHash, - signature - ); - } - - getCurrentContent(): CoValue { - if (this.content) { - return this.content; - } - - if (this.header.type === "comap") { - this.content = new CoMap(this); - } else if (this.header.type === "colist") { - this.content = new CoList(this); - } else if (this.header.type === "multistream") { - this.content = new MultiStream(this); - } else if (this.header.type === "static") { - this.content = new Static(this); - } else { - throw new Error(`Unknown multilog type ${this.header.type}`); - } - - return this.content; - } - - getValidSortedTransactions(): DecryptedTransaction[] { - const validTransactions = determineValidTransactions(this); - - const allTransactions: DecryptedTransaction[] = validTransactions.map( - ({ txID, tx }) => { - return { - txID, - madeAt: tx.madeAt, - changes: - tx.privacy === "private" - ? decryptForTransaction( - tx.encryptedChanges, - this.getReadKey(tx.keyUsed), - { - in: this.id, - tx: txID, - } - ) || - (() => { - throw new Error("Couldn't decrypt changes"); - })() - : tx.changes, - }; - } - ); - allTransactions.sort( - (a, b) => - a.madeAt - b.madeAt || - (a.txID.sessionID < b.txID.sessionID ? -1 : 1) || - a.txID.txIndex - b.txID.txIndex - ); - - return allTransactions; - } - - getCurrentReadKey(): { secret: KeySecret; id: KeyID } { - if (this.header.ruleset.type === "team") { - const content = expectTeamContent(this.getCurrentContent()); - - const currentKeyId = content.get("readKey")?.keyID; - - if (!currentKeyId) { - throw new Error("No readKey set"); - } - - const secret = this.getReadKey(currentKeyId); - - return { - secret: secret, - id: currentKeyId, - }; - } else if (this.header.ruleset.type === "ownedByTeam") { - return this.node - .expectMultiLogLoaded(this.header.ruleset.team) - .getCurrentReadKey(); - } else { - throw new Error( - "Only teams or values owned by teams have read secrets" - ); - } - } - - getReadKey(keyID: KeyID): KeySecret { - if (this.header.ruleset.type === "team") { - const content = expectTeamContent(this.getCurrentContent()); - - const readKeyHistory = content.getHistory("readKey"); - - // Try to find direct relevation of key for us - - for (const entry of readKeyHistory) { - if (entry.value?.keyID === keyID) { - const revealer = agentIDfromSessionID(entry.txID.sessionID); - const revealerAgent = this.node.knownAgents[revealer]; - - if (!revealerAgent) { - throw new Error("Unknown revealer"); - } - - const secret = openAs( - entry.value.revelation, - this.node.agentCredential.recipientSecret, - revealerAgent.recipientID, - { - in: this.id, - tx: entry.txID, - } - ); - - if (secret) return secret as KeySecret; - } - } - - // Try to find indirect revelation through previousKeys - - for (const entry of readKeyHistory) { - if (entry.value?.previousKeys?.[keyID]) { - const sealingKeyID = entry.value.keyID; - const sealingKeySecret = this.getReadKey(sealingKeyID); - - if (!sealingKeySecret) { - continue; - } - - const secret = unsealKeySecret( - { - sealed: keyID, - sealing: sealingKeyID, - encrypted: entry.value.previousKeys[keyID], - }, - sealingKeySecret - ); - - if (secret) { - return secret; - } else { - console.error( - `Sealing ${sealingKeyID} key didn't unseal ${keyID}` - ); - } - } - } - - throw new Error( - "readKey " + - keyID + - " not revealed for " + - getAgentID(getAgent(this.node.agentCredential)) - ); - } else if (this.header.ruleset.type === "ownedByTeam") { - return this.node - .expectMultiLogLoaded(this.header.ruleset.team) - .getReadKey(keyID); - } else { - throw new Error( - "Only teams or values owned by teams have read secrets" - ); - } - } - - getTx(txID: TransactionID): Transaction | undefined { - return this.sessions[txID.sessionID]?.transactions[txID.txIndex]; - } - - newContentSince(knownState: MultiLogKnownState | undefined): NewContentMessage | undefined { - const newContent: NewContentMessage = { - action: "newContent", - multilogID: this.id, - header: knownState?.header ? undefined : this.header, - newContent: Object.fromEntries( - Object.entries(this.sessions) - .map(([sessionID, log]) => { - const newTransactions = log.transactions.slice( - knownState?.sessions[sessionID as SessionID] || 0 - ); - - if ( - newTransactions.length === 0 || - !log.lastHash || - !log.lastSignature - ) { - return undefined; - } - - return [ - sessionID, - { - after: - knownState?.sessions[ - sessionID as SessionID - ] || 0, - newTransactions, - lastHash: log.lastHash, - lastSignature: log.lastSignature, - }, - ]; - }) - .filter((x): x is Exclude => !!x) - ), - } - - if (!newContent.header && Object.keys(newContent.newContent).length === 0) { - return undefined; - } - - return newContent; - } -} - -export type AgentID = `agent_${string}`; - -export type Agent = { - signatoryID: SignatoryID; - recipientID: RecipientID; -}; - -export function getAgent(agentCredential: AgentCredential) { - return { - signatoryID: getSignatoryID(agentCredential.signatorySecret), - recipientID: getRecipientID(agentCredential.recipientSecret), - }; -} - -export function getAgentMultilogHeader(agent: Agent): MultiLogHeader { - return { - type: "comap", - ruleset: { - type: "agent", - initialSignatoryID: agent.signatoryID, - initialRecipientID: agent.recipientID, - }, - meta: null, - }; -} - -export function getAgentID(agent: Agent): AgentID { - return `agent_${multilogIDforHeader(getAgentMultilogHeader(agent)).slice( - "coval_".length - )}`; -} - -export function agentIDasMultiLogID(agentID: AgentID): MultiLogID { - return `coval_${agentID.substring("agent_".length)}`; -} - -export type AgentCredential = { - signatorySecret: SignatorySecret; - recipientSecret: RecipientSecret; -}; - -export function newRandomAgentCredential(): AgentCredential { - const signatorySecret = newRandomSignatory(); - const recipientSecret = newRandomRecipient(); - return { signatorySecret, recipientSecret }; -} - -// type Role = "admin" | "writer" | "reader"; - -// type PermissionsDef = CJMap; diff --git a/src/node.ts b/src/node.ts index c824702c5..7acf2db6c 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,19 +1,19 @@ -import { CoMap } from "./coValue"; +import { CoMap } from "./contentType"; import { newRandomKeySecret, seal } from "./crypto"; import { - MultiLogID, - MultiLog, + RawCoValueID, + CoValue, AgentCredential, AgentID, SessionID, Agent, getAgent, getAgentID, - getAgentMultilogHeader, - MultiLogHeader, + getAgentCoValueHeader, + CoValueHeader, agentIDfromSessionID, - agentIDasMultiLogID, -} from "./multilog"; + agentIDAsCoValueID, +} from "./coValue"; import { Team, expectTeamContent } from "./permissions"; import { NewContentMessage, @@ -31,7 +31,7 @@ import { } from "./sync"; export class LocalNode { - multilogs: { [key: MultiLogID]: MultilogState } = {}; + coValues: { [key: RawCoValueID]: CoValueState } = {}; peers: { [key: PeerID]: PeerState } = {}; agentCredential: AgentCredential; agentID: AgentID; @@ -46,35 +46,35 @@ export class LocalNode { this.knownAgents[agentID] = agent; this.ownSessionID = ownSessionID; - const agentMultilog = new MultiLog(getAgentMultilogHeader(agent), this); - this.multilogs[agentMultilog.id] = { + const agentCoValue = new CoValue(getAgentCoValueHeader(agent), this); + this.coValues[agentCoValue.id] = { state: "loaded", - multilog: agentMultilog, + coValue: agentCoValue, }; } - createMultiLog(header: MultiLogHeader): MultiLog { - const multilog = new MultiLog(header, this); - this.multilogs[multilog.id] = { state: "loaded", multilog }; + createCoValue(header: CoValueHeader): CoValue { + const coValue = new CoValue(header, this); + this.coValues[coValue.id] = { state: "loaded", coValue: coValue }; - this.syncMultiLog(multilog); + this.syncCoValue(coValue); - return multilog; + return coValue; } - loadMultiLog(id: MultiLogID): Promise { - let entry = this.multilogs[id]; + loadCoValue(id: RawCoValueID): Promise { + let entry = this.coValues[id]; if (!entry) { entry = newLoadingState(); - this.multilogs[id] = entry; + this.coValues[id] = entry; for (const peer of Object.values(this.peers)) { peer.outgoing .write({ action: "subscribe", knownState: { - multilogID: id, + coValueID: id, header: false, sessions: {}, }, @@ -85,26 +85,26 @@ export class LocalNode { } } if (entry.state === "loaded") { - return Promise.resolve(entry.multilog); + return Promise.resolve(entry.coValue); } return entry.done; } - expectMultiLogLoaded(id: MultiLogID, expectation?: string): MultiLog { - const entry = this.multilogs[id]; + expectCoValueLoaded(id: RawCoValueID, expectation?: string): CoValue { + const entry = this.coValues[id]; if (!entry) { throw new Error( - `${expectation ? expectation + ": " : ""}Unknown multilog ${id}` + `${expectation ? expectation + ": " : ""}Unknown CoValue ${id}` ); } if (entry.state === "loading") { throw new Error( `${ expectation ? expectation + ": " : "" - }Multilog ${id} not yet loaded` + }CoValue ${id} not yet loaded` ); } - return entry.multilog; + return entry.coValue; } addKnownAgent(agent: Agent) { @@ -113,13 +113,13 @@ export class LocalNode { } createTeam(): Team { - const teamMultilog = this.createMultiLog({ + const teamCoValue = this.createCoValue({ type: "comap", ruleset: { type: "team", initialAdmin: this.agentID }, meta: null, }); - let teamContent = expectTeamContent(teamMultilog.getCurrentContent()); + let teamContent = expectTeamContent(teamCoValue.getCurrentContent()); teamContent = teamContent.edit((editable) => { editable.set(this.agentID, "admin", "trusting"); @@ -130,8 +130,8 @@ export class LocalNode { this.agentCredential.recipientSecret, new Set([getAgent(this.agentCredential).recipientID]), { - in: teamMultilog.id, - tx: teamMultilog.nextTransactionID(), + in: teamCoValue.id, + tx: teamCoValue.nextTransactionID(), } ); @@ -156,7 +156,7 @@ export class LocalNode { this.peers[peer.id] = peerState; if (peer.role === "server") { - for (const entry of Object.values(this.multilogs)) { + for (const entry of Object.values(this.coValues)) { if (entry.state === "loading") { continue; } @@ -164,15 +164,15 @@ export class LocalNode { peerState.outgoing .write({ action: "subscribe", - knownState: entry.multilog.knownState(), + knownState: entry.coValue.knownState(), }) .catch((e) => { // TODO: handle error console.error("Error writing to peer", e); }); - peerState.optimisticKnownStates[entry.multilog.id] = { - multilogID: entry.multilog.id, + peerState.optimisticKnownStates[entry.coValue.id] = { + coValueID: entry.coValue.id, header: false, sessions: {}, }; @@ -217,20 +217,20 @@ export class LocalNode { handleSubscribe( msg: SubscribeMessage, peer: PeerState, - asDependencyOf?: MultiLogID + asDependencyOf?: RawCoValueID ): SyncMessage[] { - const entry = this.multilogs[msg.knownState.multilogID]; + const entry = this.coValues[msg.knownState.coValueID]; if (!entry || entry.state === "loading") { if (!entry) { - this.multilogs[msg.knownState.multilogID] = newLoadingState(); + this.coValues[msg.knownState.coValueID] = newLoadingState(); } return [ { action: "subscribeResponse", knownState: { - multilogID: msg.knownState.multilogID, + coValueID: msg.knownState.coValueID, header: false, sessions: {}, }, @@ -238,39 +238,39 @@ export class LocalNode { ]; } - peer.optimisticKnownStates[entry.multilog.id] = - entry.multilog.knownState(); + peer.optimisticKnownStates[entry.coValue.id] = + entry.coValue.knownState(); - const newContent = entry.multilog.newContentSince(msg.knownState); + const newContent = entry.coValue.newContentSince(msg.knownState); - const dependedOnMultilogs = - entry.multilog.header.ruleset.type === "team" - ? expectTeamContent(entry.multilog.getCurrentContent()) + const dependedOnCoValues = + entry.coValue.header.ruleset.type === "team" + ? expectTeamContent(entry.coValue.getCurrentContent()) .keys() .filter((k): k is AgentID => k.startsWith("agent_")) - .map((agent) => agentIDasMultiLogID(agent)) - : entry.multilog.header.ruleset.type === "ownedByTeam" - ? [entry.multilog.header.ruleset.team] + .map((agent) => agentIDAsCoValueID(agent)) + : entry.coValue.header.ruleset.type === "ownedByTeam" + ? [entry.coValue.header.ruleset.team] : []; return [ - ...dependedOnMultilogs.flatMap((multilogID) => + ...dependedOnCoValues.flatMap((coValueID) => this.handleSubscribe( { action: "subscribe", knownState: { - multilogID, + coValueID, header: false, sessions: {}, }, }, peer, - asDependencyOf || msg.knownState.multilogID + asDependencyOf || msg.knownState.coValueID ) ), { action: "subscribeResponse", - knownState: entry.multilog.knownState(), + knownState: entry.coValue.knownState(), asDependencyOf, }, ...(newContent ? [newContent] : []), @@ -281,70 +281,70 @@ export class LocalNode { msg: SubscribeResponseMessage, peer: PeerState ): SyncMessage[] { - let entry = this.multilogs[msg.knownState.multilogID]; + let entry = this.coValues[msg.knownState.coValueID]; if (!entry) { if (msg.asDependencyOf) { - if (this.multilogs[msg.asDependencyOf]) { + if (this.coValues[msg.asDependencyOf]) { entry = newLoadingState(); - this.multilogs[msg.knownState.multilogID] = entry; + this.coValues[msg.knownState.coValueID] = entry; } } else { throw new Error( - "Expected multilog entry to be created, missing subscribe?" + "Expected coValue entry to be created, missing subscribe?" ); } } if (entry.state === "loading") { - peer.optimisticKnownStates[msg.knownState.multilogID] = + peer.optimisticKnownStates[msg.knownState.coValueID] = msg.knownState; return []; } - const newContent = entry.multilog.newContentSince(msg.knownState); - peer.optimisticKnownStates[msg.knownState.multilogID] = - combinedKnownStates(msg.knownState, entry.multilog.knownState()); + const newContent = entry.coValue.newContentSince(msg.knownState); + peer.optimisticKnownStates[msg.knownState.coValueID] = + combinedKnownStates(msg.knownState, entry.coValue.knownState()); return newContent ? [newContent] : []; } handleNewContent(msg: NewContentMessage): SyncMessage[] { - let entry = this.multilogs[msg.multilogID]; + let entry = this.coValues[msg.coValueID]; if (!entry) { throw new Error( - "Expected multilog entry to be created, missing subscribe?" + "Expected coValue entry to be created, missing subscribe?" ); } - let resolveAfterDone: ((multilog: MultiLog) => void) | undefined; + let resolveAfterDone: ((coValue: CoValue) => void) | undefined; if (entry.state === "loading") { if (!msg.header) { throw new Error("Expected header to be sent in first message"); } - const multilog = new MultiLog(msg.header, this); + const coValue = new CoValue(msg.header, this); resolveAfterDone = entry.resolve; entry = { state: "loaded", - multilog, + coValue: coValue, }; - this.multilogs[msg.multilogID] = entry; + this.coValues[msg.coValueID] = entry; } - const multilog = entry.multilog; + const coValue = entry.coValue; let invalidStateAssumed = false; for (const sessionID of Object.keys(msg.newContent) as SessionID[]) { const ourKnownTxIdx = - multilog.sessions[sessionID]?.transactions.length; + coValue.sessions[sessionID]?.transactions.length; const theirFirstNewTxIdx = msg.newContent[sessionID].after; if ((ourKnownTxIdx || 0) < theirFirstNewTxIdx) { @@ -361,7 +361,7 @@ export class LocalNode { alreadyKnownOffset ); - const success = multilog.tryAddTransactions( + const success = coValue.tryAddTransactions( sessionID, newTransactions, msg.newContent[sessionID].lastHash, @@ -375,14 +375,14 @@ export class LocalNode { } if (resolveAfterDone) { - resolveAfterDone(multilog); + resolveAfterDone(coValue); } return invalidStateAssumed ? [ { action: "wrongAssumedKnownState", - knownState: multilog.knownState(), + knownState: coValue.knownState(), }, ] : []; @@ -392,12 +392,12 @@ export class LocalNode { msg: WrongAssumedKnownStateMessage, peer: PeerState ): SyncMessage[] { - const multilog = this.expectMultiLogLoaded(msg.knownState.multilogID); + const coValue = this.expectCoValueLoaded(msg.knownState.coValueID); - peer.optimisticKnownStates[msg.knownState.multilogID] = - combinedKnownStates(msg.knownState, multilog.knownState()); + peer.optimisticKnownStates[msg.knownState.coValueID] = + combinedKnownStates(msg.knownState, coValue.knownState()); - const newContent = multilog.newContentSince(msg.knownState); + const newContent = coValue.newContentSince(msg.knownState); return newContent ? [newContent] : []; } @@ -406,28 +406,28 @@ export class LocalNode { throw new Error("Method not implemented."); } - async syncMultiLog(multilog: MultiLog) { + async syncCoValue(coValue: CoValue) { for (const peer of Object.values(this.peers)) { const optimisticKnownState = - peer.optimisticKnownStates[multilog.id]; + peer.optimisticKnownStates[coValue.id]; if (optimisticKnownState || peer.role === "server") { const newContent = - multilog.newContentSince(optimisticKnownState); + coValue.newContentSince(optimisticKnownState); - peer.optimisticKnownStates[multilog.id] = peer - .optimisticKnownStates[multilog.id] + peer.optimisticKnownStates[coValue.id] = peer + .optimisticKnownStates[coValue.id] ? combinedKnownStates( - peer.optimisticKnownStates[multilog.id], - multilog.knownState() + peer.optimisticKnownStates[coValue.id], + coValue.knownState() ) - : multilog.knownState(); + : coValue.knownState(); if (!optimisticKnownState && peer.role === "server") { // auto-subscribe await peer.outgoing.write({ action: "subscribe", - knownState: multilog.knownState(), + knownState: coValue.knownState(), }); } @@ -444,21 +444,21 @@ export class LocalNode { ): LocalNode { const newNode = new LocalNode(agentCredential, ownSessionID); - newNode.multilogs = Object.fromEntries( - Object.entries(this.multilogs) + newNode.coValues = Object.fromEntries( + Object.entries(this.coValues) .map(([id, entry]) => { if (entry.state === "loading") { return undefined; } - const newMultilog = new MultiLog( - entry.multilog.header, + const newCoValue = new CoValue( + entry.coValue.header, newNode ); - newMultilog.sessions = entry.multilog.sessions; + newCoValue.sessions = entry.coValue.sessions; - return [id, { state: "loaded", multilog: newMultilog }]; + return [id, { state: "loaded", coValue: newCoValue }]; }) .filter((x): x is Exclude => !!x) ); @@ -472,18 +472,18 @@ export class LocalNode { } } -type MultilogState = +type CoValueState = | { state: "loading"; - done: Promise; - resolve: (multilog: MultiLog) => void; + done: Promise; + resolve: (coValue: CoValue) => void; } - | { state: "loaded"; multilog: MultiLog }; + | { state: "loaded"; coValue: CoValue }; -function newLoadingState(): MultilogState { - let resolve: (multilog: MultiLog) => void; +function newLoadingState(): CoValueState { + let resolve: (coValue: CoValue) => void; - const promise = new Promise((r) => { + const promise = new Promise((r) => { resolve = r; }); diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 041e5a0ac..3d26fb40c 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -4,9 +4,9 @@ import { getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./multilog"; +} from "./coValue"; import { LocalNode } from "./node"; -import { expectMap } from "./coValue"; +import { expectMap } from "./contentType"; import { expectTeamContent } from "./permissions"; import { getRecipientID, @@ -44,7 +44,7 @@ function newTeam() { const node = new LocalNode(admin, newRandomSessionID(adminID)); - const team = node.createMultiLog({ + const team = node.createCoValue({ type: "comap", ruleset: { type: "team", initialAdmin: adminID }, meta: null, @@ -351,7 +351,7 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader test("Admins can write to an object that is owned by their team", () => { const { node, team } = newTeam(); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -393,7 +393,7 @@ test("Writers can write to an object that is owned by their team", () => { expect(editable.get(writerID)).toEqual("writer"); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -431,7 +431,7 @@ test("Writers can write to an object that is owned by their team (high level)", const childObject = team.createMap(); let childObjectAsWriter = expectMap( - childObject.multiLog + childObject.coValue .testWithDifferentCredentials(writer, newRandomSessionID(writerID)) .getCurrentContent() ); @@ -455,7 +455,7 @@ test("Readers can not write to an object that is owned by their team", () => { expect(editable.get(readerID)).toEqual("reader"); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -493,7 +493,7 @@ test("Readers can not write to an object that is owned by their team (high level const childObject = team.createMap(); let childObjectAsReader = expectMap( - childObject.multiLog + childObject.coValue .testWithDifferentCredentials(reader, newRandomSessionID(readerID)) .getCurrentContent() ); @@ -530,7 +530,7 @@ test("Admins can set team read key and then use it to create and read private tr expect(team.getCurrentReadKey().secret).toEqual(readKey); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -588,7 +588,7 @@ test("Admins can set team read key and then writers can use it to create and rea editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -628,7 +628,7 @@ test("Admins can set team read key and then writers can use it to create and rea const childObject = team.createMap(); let childObjectAsWriter = expectMap( - childObject.multiLog + childObject.coValue .testWithDifferentCredentials(writer, newRandomSessionID(writerID)) .getCurrentContent() ); @@ -669,7 +669,7 @@ test("Admins can set team read key and then use it to create private transaction editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -711,7 +711,7 @@ test("Admins can set team read key and then use it to create private transaction expect(editable.get("foo")).toEqual("bar"); }); - const childContentAsReader = expectMap(childObject.multiLog.testWithDifferentCredentials( + const childContentAsReader = expectMap(childObject.coValue.testWithDifferentCredentials( reader, newRandomSessionID(readerID) ).getCurrentContent()); @@ -769,7 +769,7 @@ test("Admins can set team read key and then use it to create private transaction ); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -827,7 +827,7 @@ test("Admins can set team read key and then use it to create private transaction expect(editable.get("foo")).toEqual("bar"); }); - const childContentAsReader1 = expectMap(childObject.multiLog.testWithDifferentCredentials( + const childContentAsReader1 = expectMap(childObject.coValue.testWithDifferentCredentials( reader1, newRandomSessionID(reader1ID) ).getCurrentContent()); @@ -836,7 +836,7 @@ test("Admins can set team read key and then use it to create private transaction team.addMember(reader2ID, "reader"); - const childContentAsReader2 = expectMap(childObject.multiLog.testWithDifferentCredentials( + const childContentAsReader2 = expectMap(childObject.coValue.testWithDifferentCredentials( reader2, newRandomSessionID(reader2ID) ).getCurrentContent()); @@ -869,7 +869,7 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(team.getCurrentReadKey().secret).toEqual(readKey); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -923,7 +923,7 @@ test("Admins can set team read key, make a private transaction in an owned objec let childObject = team.createMap(); - const firstReadKey = childObject.multiLog.getCurrentReadKey(); + const firstReadKey = childObject.coValue.getCurrentReadKey(); childObject = childObject.edit((editable) => { editable.set("foo", "bar", "private"); @@ -934,7 +934,7 @@ test("Admins can set team read key, make a private transaction in an owned objec team.rotateReadKey(); - expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(firstReadKey); + expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey); childObject = childObject.edit((editable) => { editable.set("foo2", "bar2", "private"); @@ -948,7 +948,7 @@ test("Admins can set team read key, make a private transaction in an owned objec test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, add a reader, make another private transaction in the owned object, and both can be read by the reader", () => { const { node, team, admin, adminID } = newTeam(); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -1052,7 +1052,7 @@ test("Admins can set team read key, make a private transaction in an owned objec let childObject = team.createMap(); - const firstReadKey = childObject.multiLog.getCurrentReadKey(); + const firstReadKey = childObject.coValue.getCurrentReadKey(); childObject = childObject.edit((editable) => { editable.set("foo", "bar", "private"); @@ -1063,7 +1063,7 @@ test("Admins can set team read key, make a private transaction in an owned objec team.rotateReadKey(); - expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(firstReadKey); + expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey); const reader = newRandomAgentCredential(); const readerID = getAgentID(getAgent(reader)); @@ -1077,7 +1077,7 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(editable.get("foo2")).toEqual("bar2"); }); - const childContentAsReader = expectMap(childObject.multiLog.testWithDifferentCredentials( + const childContentAsReader = expectMap(childObject.coValue.testWithDifferentCredentials( reader, newRandomSessionID(readerID) ).getCurrentContent()); @@ -1090,7 +1090,7 @@ test("Admins can set team read key, make a private transaction in an owned objec test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions", () => { const { node, team, admin, adminID } = newTeam(); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -1192,7 +1192,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec expect(editable.get("foo2")).toEqual("bar2"); }); - // TODO: make sure these instances of multilogs sync between each other so this isn't necessary? + // TODO: make sure these instances of coValues sync between each other so this isn't necessary? childObjectAsReader = childObject.testWithDifferentCredentials( reader, newRandomSessionID(readerID) @@ -1228,7 +1228,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec team.rotateReadKey(); - const secondReadKey = childObject.multiLog.getCurrentReadKey(); + const secondReadKey = childObject.coValue.getCurrentReadKey(); const reader = newRandomAgentCredential(); const readerID = getAgentID(getAgent(reader)); @@ -1251,14 +1251,14 @@ test("Admins can set team read rey, make a private transaction in an owned objec team.removeMember(readerID); - expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(secondReadKey); + expect(childObject.coValue.getCurrentReadKey()).not.toEqual(secondReadKey); childObject = childObject.edit((editable) => { editable.set("foo3", "bar3", "private"); expect(editable.get("foo3")).toEqual("bar3"); }); - const childContentAsReader2 = expectMap(childObject.multiLog.testWithDifferentCredentials( + const childContentAsReader2 = expectMap(childObject.coValue.testWithDifferentCredentials( reader2, newRandomSessionID(reader2ID) ).getCurrentContent()); @@ -1267,7 +1267,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec expect(childContentAsReader2.get("foo2")).toEqual("bar2"); expect(childContentAsReader2.get("foo3")).toEqual("bar3"); - expect(() => childObject.multiLog.testWithDifferentCredentials( + expect(() => childObject.coValue.testWithDifferentCredentials( reader, newRandomSessionID(readerID) ).getCurrentContent()).toThrow(/readKey (.+?) not revealed for (.+?)/); diff --git a/src/permissions.ts b/src/permissions.ts index 9d1ce83b7..224d568cd 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,4 +1,4 @@ -import { CoMap, CoValue, MapOpPayload } from "./coValue"; +import { CoMap, ContentType, MapOpPayload } from "./contentType"; import { JsonValue } from "./jsonValue"; import { Encrypted, @@ -15,19 +15,19 @@ import { import { AgentCredential, AgentID, - MultiLog, - MultiLogID, + CoValue, + RawCoValueID, SessionID, Transaction, TransactionID, TrustingTransaction, agentIDfromSessionID, -} from "./multilog"; +} from "./coValue"; import { LocalNode } from "."; export type PermissionsDef = - | { type: "team"; initialAdmin: AgentID; parentTeams?: MultiLogID[] } - | { type: "ownedByTeam"; team: MultiLogID } + | { type: "team"; initialAdmin: AgentID; parentTeams?: RawCoValueID[] } + | { type: "ownedByTeam"; team: RawCoValueID } | { type: "agent"; initialSignatoryID: SignatoryID; @@ -38,11 +38,11 @@ export type PermissionsDef = export type Role = "reader" | "writer" | "admin" | "revoked"; export function determineValidTransactions( - multilog: MultiLog + coValue: CoValue ): { txID: TransactionID; tx: Transaction }[] { - if (multilog.header.ruleset.type === "team") { + if (coValue.header.ruleset.type === "team") { const allTrustingTransactionsSorted = Object.entries( - multilog.sessions + coValue.sessions ).flatMap(([sessionID, sessionLog]) => { return sessionLog.transactions .map((tx, txIndex) => ({ sessionID, txIndex, tx })) @@ -64,7 +64,7 @@ export function determineValidTransactions( return a.tx.madeAt - b.tx.madeAt; }); - const initialAdmin = multilog.header.ruleset.initialAdmin; + const initialAdmin = coValue.header.ruleset.initialAdmin; if (!initialAdmin) { throw new Error("Team must have initialAdmin"); @@ -153,10 +153,10 @@ export function determineValidTransactions( } return validTransactions; - } else if (multilog.header.ruleset.type === "ownedByTeam") { + } else if (coValue.header.ruleset.type === "ownedByTeam") { const teamContent = - multilog.node.expectMultiLogLoaded( - multilog.header.ruleset.team, + coValue.node.expectCoValueLoaded( + coValue.header.ruleset.team, "Determining valid transaction in owned object but its team wasn't loaded" ).getCurrentContent(); @@ -164,7 +164,7 @@ export function determineValidTransactions( throw new Error("Team must be a map"); } - return Object.entries(multilog.sessions).flatMap( + return Object.entries(coValue.sessions).flatMap( ([sessionID, sessionLog]) => { const transactor = agentIDfromSessionID(sessionID as SessionID); return sessionLog.transactions @@ -185,8 +185,8 @@ export function determineValidTransactions( })); } ); - } else if (multilog.header.ruleset.type === "unsafeAllowAll") { - return Object.entries(multilog.sessions).flatMap( + } else if (coValue.header.ruleset.type === "unsafeAllowAll") { + return Object.entries(coValue.sessions).flatMap( ([sessionID, sessionLog]) => { return sessionLog.transactions.map((tx, txIndex) => ({ txID: { sessionID: sessionID as SessionID, txIndex }, @@ -194,11 +194,11 @@ export function determineValidTransactions( })); } ); - } else if (multilog.header.ruleset.type === "agent") { + } else if (coValue.header.ruleset.type === "agent") { // TODO return []; } else { - throw new Error("Unknown ruleset type " + (multilog.header.ruleset as any).type); + throw new Error("Unknown ruleset type " + (coValue.header.ruleset as any).type); } } @@ -215,7 +215,7 @@ export type TeamContent = { [key: AgentID]: Role } & { }; }; -export function expectTeamContent(content: CoValue): CoMap { +export function expectTeamContent(content: ContentType): CoMap { if (content.type !== "comap") { throw new Error("Expected map"); } @@ -232,7 +232,7 @@ export class Team { this.node = node; } - get id(): MultiLogID { + get id(): RawCoValueID { return this.teamMap.id; } @@ -249,15 +249,15 @@ export class Team { throw new Error("Failed to set role"); } - const currentReadKey = this.teamMap.multiLog.getCurrentReadKey(); + const currentReadKey = this.teamMap.coValue.getCurrentReadKey(); const revelation = seal( currentReadKey.secret, - this.teamMap.multiLog.node.agentCredential.recipientSecret, + this.teamMap.coValue.node.agentCredential.recipientSecret, new Set([agent.recipientID]), { - in: this.teamMap.multiLog.id, - tx: this.teamMap.multiLog.nextTransactionID(), + in: this.teamMap.coValue.id, + tx: this.teamMap.coValue.nextTransactionID(), } ); @@ -281,21 +281,21 @@ export class Team { } }) as AgentID[]; - const currentReadKey = this.teamMap.multiLog.getCurrentReadKey(); + const currentReadKey = this.teamMap.coValue.getCurrentReadKey(); const newReadKey = newRandomKeySecret(); const newReadKeyRevelation = seal( newReadKey.secret, - this.teamMap.multiLog.node.agentCredential.recipientSecret, + this.teamMap.coValue.node.agentCredential.recipientSecret, new Set( currentlyPermittedReaders.map( (reader) => this.node.knownAgents[reader].recipientID ) ), { - in: this.teamMap.multiLog.id, - tx: this.teamMap.multiLog.nextTransactionID(), + in: this.teamMap.coValue.id, + tx: this.teamMap.coValue.nextTransactionID(), } ); @@ -329,7 +329,7 @@ export class Team { meta?: M ): CoMap { return this.node - .createMultiLog({ + .createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", @@ -346,7 +346,7 @@ export class Team { ): Team { return new Team( expectTeamContent( - this.teamMap.multiLog + this.teamMap.coValue .testWithDifferentCredentials(credential, sessionId) .getCurrentContent() ), diff --git a/src/sync.test.ts b/src/sync.test.ts index 1c7edbea3..2698d1224 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -4,10 +4,10 @@ import { getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./multilog"; +} from "./coValue"; import { LocalNode } from "./node"; import { Peer, SyncMessage } from "./sync"; -import { MapOpPayload, expectMap } from "./coValue"; +import { MapOpPayload, expectMap } from "./contentType"; test( "Node replies with initial tx and header to empty subscribe", @@ -40,7 +40,7 @@ test( await writer.write({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: false, sessions: {}, }, @@ -57,14 +57,14 @@ test( expect(subscribeResponseMsg.value).toEqual({ action: "subscribeResponse", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); const newContentMsg = await reader.read(); expect(newContentMsg.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: { type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, @@ -76,7 +76,7 @@ test( newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[0].madeAt, changes: [ { @@ -88,9 +88,9 @@ test( }, ], lastHash: - map.multiLog.sessions[node.ownSessionID].lastHash!, + map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); @@ -128,7 +128,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( await writer.write({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: true, sessions: { [node.ownSessionID]: 1, @@ -147,14 +147,14 @@ test("Node replies with only new tx to subscribe with some known state", async ( expect(mapSubscribeResponseMsg.value).toEqual({ action: "subscribeResponse", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); const mapNewContentMsg = await reader.read(); expect(mapNewContentMsg.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: undefined, newContent: { [node.ownSessionID]: { @@ -162,7 +162,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[1].madeAt, changes: [ { @@ -173,15 +173,15 @@ test("Node replies with only new tx to subscribe with some known state", async ( ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); }); -test.skip("TODO: node only replies with new tx to subscribe with some known state, even in the depended on multilogs", () => {}); +test.skip("TODO: node only replies with new tx to subscribe with some known state, even in the depended on coValues", () => {}); test("After subscribing, node sends own known state and new txs to peer", async () => { const admin = newRandomAgentCredential(); @@ -208,7 +208,7 @@ test("After subscribing, node sends own known state and new txs to peer", async await writer.write({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: false, sessions: { [node.ownSessionID]: 0, @@ -227,15 +227,15 @@ test("After subscribing, node sends own known state and new txs to peer", async expect(mapSubscribeResponseMsg.value).toEqual({ action: "subscribeResponse", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); const mapNewContentHeaderOnlyMsg = await reader.read(); expect(mapNewContentHeaderOnlyMsg.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, - header: map.multiLog.header, + coValueID: map.coValue.id, + header: map.coValue.header, newContent: {}, } satisfies SyncMessage); @@ -247,14 +247,14 @@ test("After subscribing, node sends own known state and new txs to peer", async expect(mapEditMsg1.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, + coValueID: map.coValue.id, newContent: { [node.ownSessionID]: { after: 0, newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[0].madeAt, changes: [ { @@ -265,9 +265,9 @@ test("After subscribing, node sends own known state and new txs to peer", async ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); @@ -280,14 +280,14 @@ test("After subscribing, node sends own known state and new txs to peer", async expect(mapEditMsg2.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, + coValueID: map.coValue.id, newContent: { [node.ownSessionID]: { after: 1, newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[1].madeAt, changes: [ { @@ -298,9 +298,9 @@ test("After subscribing, node sends own known state and new txs to peer", async ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); @@ -335,7 +335,7 @@ test("Client replies with known new content to subscribeResponse from server", a await writer.write({ action: "subscribeResponse", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: false, sessions: { [node.ownSessionID]: 0, @@ -349,15 +349,15 @@ test("Client replies with known new content to subscribeResponse from server", a expect(msg1.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, - header: map.multiLog.header, + coValueID: map.coValue.id, + header: map.coValue.header, newContent: { [node.ownSessionID]: { after: 0, newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[0].madeAt, changes: [ { @@ -368,9 +368,9 @@ test("Client replies with known new content to subscribeResponse from server", a ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); @@ -401,7 +401,7 @@ test("No matter the optimistic known state, node respects invalid known state me await writer.write({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: false, sessions: { [node.ownSessionID]: 0, @@ -432,7 +432,7 @@ test("No matter the optimistic known state, node respects invalid known state me await writer.write({ action: "wrongAssumedKnownState", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: true, sessions: { [node.ownSessionID]: 1, @@ -444,7 +444,7 @@ test("No matter the optimistic known state, node respects invalid known state me expect(newContentAfterWrongAssumedState.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: undefined, newContent: { [node.ownSessionID]: { @@ -452,7 +452,7 @@ test("No matter the optimistic known state, node respects invalid known state me newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[1].madeAt, changes: [ { @@ -463,15 +463,15 @@ test("No matter the optimistic known state, node respects invalid known state me ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); }); -test("If we add a peer, but it never subscribes to a multilog, it won't get any messages", async () => { +test("If we add a peer, but it never subscribes to a coValue, it won't get any messages", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -500,7 +500,7 @@ test("If we add a peer, but it never subscribes to a multilog, it won't get any await shouldNotResolve(reader.read(), { timeout: 50 }); }); -test("If we add a server peer, all updates to all multilogs are sent to it, even if it doesn't subscribe", async () => { +test("If we add a server peer, all updates to all coValues are sent to it, even if it doesn't subscribe", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -533,7 +533,7 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even expect(subscribeMsg.value).toEqual({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: true, sessions: {}, }, @@ -543,15 +543,15 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even expect(newContentMsg.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, - header: map.multiLog.header, + coValueID: map.coValue.id, + header: map.coValue.header, newContent: { [node.ownSessionID]: { after: 0, newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[0].madeAt, changes: [ { @@ -562,15 +562,15 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); }); -test("If we add a server peer, newly created multilogs are auto-subscribed to", async () => { +test("If we add a server peer, newly created coValues are auto-subscribed to", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -600,22 +600,22 @@ test("If we add a server peer, newly created multilogs are auto-subscribed to", expect(msg1.value).toEqual({ action: "subscribe", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); const msg2 = await reader.read(); expect(msg2.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, - header: map.multiLog.header, + coValueID: map.coValue.id, + header: map.coValue.header, newContent: {}, } satisfies SyncMessage); }); test.skip("TODO: when receiving a subscribe response that is behind our optimistic state (due to already sent content), we ignore it", () => {}); -test("When we connect a new server peer, we try to sync all existing multilogs to it", async () => { +test("When we connect a new server peer, we try to sync all existing coValues to it", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -642,14 +642,14 @@ test("When we connect a new server peer, we try to sync all existing multilogs t expect(teamSubscribeMessage.value).toEqual({ action: "subscribe", - knownState: team.teamMap.multiLog.knownState(), + knownState: team.teamMap.coValue.knownState(), } satisfies SyncMessage); const secondMessage = await reader.read(); expect(secondMessage.value).toEqual({ action: "subscribe", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); }); @@ -678,7 +678,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe await writer.write({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: true, sessions: { [node.ownSessionID]: 1, @@ -696,11 +696,11 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe expect(mapSubscribeResponse.value).toEqual({ action: "subscribeResponse", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); }); -test("When replaying creation and transactions of a multilog as new content, the receiving peer integrates this information", async () => { +test("When replaying creation and transactions of a coValue as new content, the receiving peer integrates this information", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -752,7 +752,7 @@ test("When replaying creation and transactions of a multilog as new content, the await writer2.write(teamSubscribeMsg.value); const teamSubscribeResponseMsg = await reader2.read(); - expect(node2.multilogs[team.teamMap.multiLog.id]?.state).toEqual("loading"); + expect(node2.coValues[team.teamMap.coValue.id]?.state).toEqual("loading"); const writer1 = inTx1.getWriter(); @@ -765,7 +765,7 @@ test("When replaying creation and transactions of a multilog as new content, the const _mapSubscribeResponseMsg = await reader2.read(); await writer2.write(mapNewContentMsg.value); - expect(node2.multilogs[map.multiLog.id]?.state).toEqual("loading"); + expect(node2.coValues[map.coValue.id]?.state).toEqual("loading"); await writer2.write(mapEditMsg.value); @@ -773,12 +773,12 @@ test("When replaying creation and transactions of a multilog as new content, the expect( expectMap( - node2.expectMultiLogLoaded(map.multiLog.id).getCurrentContent() + node2.expectCoValueLoaded(map.coValue.id).getCurrentContent() ).get("hello") ).toEqual("world"); }); -test("When loading a multilog on one node, the server node it is requested from replies with all the necessary depended on multilogs to make it work", async () => { +test("When loading a coValue on one node, the server node it is requested from replies with all the necessary depended on coValues to make it work", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -798,11 +798,11 @@ test("When loading a multilog on one node, the server node it is requested from node1.addPeer(node2asPeer); node2.addPeer(node1asPeer); - await node2.loadMultiLog(map.multiLog.id); + await node2.loadCoValue(map.coValue.id); expect( expectMap( - node2.expectMultiLogLoaded(map.multiLog.id).getCurrentContent() + node2.expectCoValueLoaded(map.coValue.id).getCurrentContent() ).get("hello") ).toEqual("world"); }); diff --git a/src/sync.ts b/src/sync.ts index 276d84bbe..3a8bd02e3 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,8 +1,8 @@ import { Hash, Signature } from "./crypto"; -import { MultiLogHeader, MultiLogID, SessionID, Transaction } from "./multilog"; +import { CoValueHeader, RawCoValueID, SessionID, Transaction } from "./coValue"; -export type MultiLogKnownState = { - multilogID: MultiLogID; +export type CoValueKnownState = { + coValueID: RawCoValueID; header: boolean; sessions: { [sessionID: SessionID]: number }; }; @@ -16,19 +16,19 @@ export type SyncMessage = export type SubscribeMessage = { action: "subscribe"; - knownState: MultiLogKnownState; + knownState: CoValueKnownState; }; export type SubscribeResponseMessage = { action: "subscribeResponse"; - knownState: MultiLogKnownState; - asDependencyOf?: MultiLogID; + knownState: CoValueKnownState; + asDependencyOf?: RawCoValueID; }; export type NewContentMessage = { action: "newContent"; - multilogID: MultiLogID; - header?: MultiLogHeader; + coValueID: RawCoValueID; + header?: CoValueHeader; newContent: { [sessionID: SessionID]: SessionNewContent; }; @@ -44,12 +44,12 @@ export type SessionNewContent = { export type WrongAssumedKnownStateMessage = { action: "wrongAssumedKnownState"; - knownState: MultiLogKnownState; + knownState: CoValueKnownState; }; export type UnsubscribeMessage = { action: "unsubscribe"; - multilogID: MultiLogID; + coValueID: RawCoValueID; }; export type PeerID = string; @@ -63,15 +63,15 @@ export interface Peer { export interface PeerState { id: PeerID; - optimisticKnownStates: { [multilogID: MultiLogID]: MultiLogKnownState }; + optimisticKnownStates: { [coValueID: RawCoValueID]: CoValueKnownState }; incoming: ReadableStream; outgoing: WritableStreamDefaultWriter; role: "peer" | "server" | "client"; } export function weAreStrictlyAhead( - ourKnownState: MultiLogKnownState, - theirKnownState: MultiLogKnownState + ourKnownState: CoValueKnownState, + theirKnownState: CoValueKnownState ): boolean { if (theirKnownState.header && !ourKnownState.header) { return false; @@ -94,8 +94,8 @@ export function weAreStrictlyAhead( return true; } -export function combinedKnownStates(stateA: MultiLogKnownState, stateB: MultiLogKnownState): MultiLogKnownState { - const sessionStates: MultiLogKnownState["sessions"] = {}; +export function combinedKnownStates(stateA: CoValueKnownState, stateB: CoValueKnownState): CoValueKnownState { + const sessionStates: CoValueKnownState["sessions"] = {}; const allSessions = new Set([...Object.keys(stateA.sessions), ...Object.keys(stateB.sessions)] as SessionID[]); @@ -107,7 +107,7 @@ export function combinedKnownStates(stateA: MultiLogKnownState, stateB: MultiLog } return { - multilogID: stateA.multilogID, + coValueID: stateA.coValueID, header: stateA.header || stateB.header, sessions: sessionStates, };