Merge pull request #5 from gardencmp/anselm/gar-86-ensure-private-client-server-client-sync-works

Ensure private client -> server -> client sync works
This commit is contained in:
Anselm Eickhoff
2023-08-07 19:54:23 +01:00
committed by GitHub
16 changed files with 3986 additions and 806 deletions

18
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
project: './tsconfig.json',
},
root: true,
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/no-floating-promises": "error",
},
};

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,19 +1,30 @@
{
"name": "cojson",
"module": "src/index.ts",
"type": "module",
"license": "MIT",
"devDependencies": {
"bun-types": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@noble/ciphers": "^0.1.3",
"@noble/curves": "^1.1.0",
"@noble/hashes": "^1.3.1",
"@scure/base": "^1.1.1",
"fast-json-stable-stringify": "^2.1.0"
}
}
"name": "cojson",
"module": "src/index.ts",
"type": "module",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"eslint": "^8.46.0",
"jest": "^29.6.2",
"ts-jest": "^29.1.1",
"typescript": "5.0.2"
},
"dependencies": {
"@noble/ciphers": "^0.1.3",
"@noble/curves": "^1.1.0",
"@noble/hashes": "^1.3.1",
"@scure/base": "^1.1.1",
"fast-json-stable-stringify": "^2.1.0",
"isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
},
"scripts": {
"test": "jest"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node"
}
}

View File

@@ -1,4 +1,3 @@
import { expect, test } from "bun:test";
import {
CoValue,
Transaction,
@@ -11,7 +10,7 @@ import { LocalNode } from "./node";
import { sign } from "./crypto";
test("Can create coValue with new agent credentials and add transaction to it", () => {
const agentCredential = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
@@ -49,9 +48,8 @@ test("Can create coValue with new agent credentials and add transaction to it",
});
test("transactions with wrong signature are rejected", () => {
const agent = newRandomAgentCredential();
const wrongAgent = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential();
const wrongAgent = newRandomAgentCredential("wrongAgent");
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
@@ -89,8 +87,7 @@ test("transactions with wrong signature are rejected", () => {
});
test("transactions with correctly signed, but wrong hash are rejected", () => {
const agent = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
@@ -132,7 +129,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
node.ownSessionID,
[transaction],
expectedNewHash,
sign(agent.signatorySecret, expectedNewHash)
sign(agentCredential.signatorySecret, expectedNewHash)
)
).toBe(false);
});

View File

@@ -33,27 +33,34 @@ import {
import { LocalNode } from "./node";
import { CoValueKnownState, NewContentMessage } from "./sync";
export type RawCoValueID = `coval_${string}`;
export type RawCoValueID = `co_z${string}` | `co_${string}_z${string}`;
export type CoValueHeader = {
type: ContentType["type"];
ruleset: RulesetDef;
meta: JsonValue;
publicNickname?: string;
};
function coValueIDforHeader(header: CoValueHeader): RawCoValueID {
const hash = shortHash(header);
return `coval_${hash.slice("shortHash_".length)}`;
if (header.publicNickname) {
return `co_${header.publicNickname}_z${hash.slice(
"shortHash_z".length
)}`;
} else {
return `co_z${hash.slice("shortHash_z".length)}`;
}
}
export type SessionID = `session_${string}_${AgentID}`;
export type SessionID = `${AgentID}_session_z${string}`;
export function agentIDfromSessionID(sessionID: SessionID): AgentID {
return `agent_${sessionID.substring(sessionID.lastIndexOf("_") + 1)}`;
return sessionID.split("_session")[0] as AgentID;
}
export function newRandomSessionID(agentID: AgentID): SessionID {
return `session_${base58.encode(randomBytes(8))}_${agentID}`;
return `${agentID}_session_z${base58.encode(randomBytes(8))}`;
}
type SessionLog = {
@@ -146,8 +153,10 @@ export class CoValue {
newHash: Hash,
newSignature: Signature
): boolean {
const signatoryID =
this.node.knownAgents[agentIDfromSessionID(sessionID)]?.signatoryID;
const signatoryID = this.node.expectAgentLoaded(
agentIDfromSessionID(sessionID),
"Expected to know signatory of transaction"
).signatoryID;
if (!signatoryID) {
console.warn("Unknown agent", agentIDfromSessionID(sessionID));
@@ -187,8 +196,6 @@ export class CoValue {
this.content = undefined;
this.node.syncCoValue(this);
const _ = this.getCurrentContent();
return true;
@@ -224,6 +231,10 @@ export class CoValue {
if (privacy === "private") {
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
if (!keySecret) {
throw new Error("Can't make transaction without read key secret");
}
transaction = {
privacy: "private",
madeAt,
@@ -252,12 +263,18 @@ export class CoValue {
expectedNewHash
);
return this.tryAddTransactions(
const success = this.tryAddTransactions(
sessionID,
[transaction],
expectedNewHash,
signature
);
if (success) {
void this.node.sync.syncCoValue(this);
}
return success;
}
getCurrentContent(): ContentType {
@@ -285,26 +302,40 @@ export class CoValue {
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,
};
if (tx.privacy === "trusting") {
return {
txID,
madeAt: tx.madeAt,
changes: tx.changes,
};
} else {
const readKey = this.getReadKey(tx.keyUsed);
if (!readKey) {
return undefined;
} else {
const decrytedChanges = decryptForTransaction(
tx.encryptedChanges,
readKey,
{
in: this.id,
tx: txID,
}
);
if (!decrytedChanges) {
console.error("Failed to decrypt transaction despite having key");
return undefined;
}
return {
txID,
madeAt: tx.madeAt,
changes: decrytedChanges,
};
}
}
}
);
).filter((x): x is Exclude<typeof x, undefined> => !!x);
allTransactions.sort(
(a, b) =>
a.madeAt - b.madeAt ||
@@ -315,7 +346,7 @@ export class CoValue {
return allTransactions;
}
getCurrentReadKey(): { secret: KeySecret; id: KeyID } {
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
@@ -342,7 +373,7 @@ export class CoValue {
}
}
getReadKey(keyID: KeyID): KeySecret {
getReadKey(keyID: KeyID): KeySecret | undefined {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
@@ -353,11 +384,10 @@ export class CoValue {
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 revealerAgent = this.node.expectAgentLoaded(
revealer,
"Expected to know revealer"
);
const secret = openAs(
entry.value.revelation,
@@ -376,7 +406,8 @@ export class CoValue {
// Try to find indirect revelation through previousKeys
for (const entry of readKeyHistory) {
if (entry.value?.previousKeys?.[keyID]) {
const encryptedPreviousKey = entry.value?.previousKeys?.[keyID];
if (entry.value && encryptedPreviousKey) {
const sealingKeyID = entry.value.keyID;
const sealingKeySecret = this.getReadKey(sealingKeyID);
@@ -388,7 +419,7 @@ export class CoValue {
{
sealed: keyID,
sealing: sealingKeyID,
encrypted: entry.value.previousKeys[keyID],
encrypted: encryptedPreviousKey,
},
sealingKeySecret
);
@@ -403,12 +434,7 @@ export class CoValue {
}
}
throw new Error(
"readKey " +
keyID +
" not revealed for " +
getAgentID(getAgent(this.node.agentCredential))
);
return undefined;
} else if (this.header.ruleset.type === "ownedByTeam") {
return this.node
.expectCoValueLoaded(this.header.ruleset.team)
@@ -424,7 +450,9 @@ export class CoValue {
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
}
newContentSince(knownState: CoValueKnownState | undefined): NewContentMessage | undefined {
newContentSince(
knownState: CoValueKnownState | undefined
): NewContentMessage | undefined {
const newContent: NewContentMessage = {
action: "newContent",
coValueID: this.id,
@@ -459,27 +487,42 @@ export class CoValue {
})
.filter((x): x is Exclude<typeof x, undefined> => !!x)
),
}
};
if (!newContent.header && Object.keys(newContent.newContent).length === 0) {
if (
!newContent.header &&
Object.keys(newContent.newContent).length === 0
) {
return undefined;
}
return newContent;
}
getDependedOnCoValues(): RawCoValueID[] {
return this.header.ruleset.type === "team"
? expectTeamContent(this.getCurrentContent())
.keys()
.filter((k): k is AgentID => k.startsWith("co_agent"))
: this.header.ruleset.type === "ownedByTeam"
? [this.header.ruleset.team]
: [];
}
}
export type AgentID = `agent_${string}`;
export type AgentID = `co_agent${string}_z${string}`;
export type Agent = {
signatoryID: SignatoryID;
recipientID: RecipientID;
publicNickname?: string;
};
export function getAgent(agentCredential: AgentCredential) {
return {
signatoryID: getSignatoryID(agentCredential.signatorySecret),
recipientID: getRecipientID(agentCredential.recipientSecret),
publicNickname: agentCredential.publicNickname,
};
}
@@ -492,28 +535,27 @@ export function getAgentCoValueHeader(agent: Agent): CoValueHeader {
initialRecipientID: agent.recipientID,
},
meta: null,
publicNickname:
"agent" + (agent.publicNickname ? `-${agent.publicNickname}` : ""),
};
}
export function getAgentID(agent: Agent): AgentID {
return `agent_${coValueIDforHeader(getAgentCoValueHeader(agent)).slice(
"coval_".length
)}`;
}
export function agentIDAsCoValueID(agentID: AgentID): RawCoValueID {
return `coval_${agentID.substring("agent_".length)}`;
return coValueIDforHeader(getAgentCoValueHeader(agent)) as AgentID;
}
export type AgentCredential = {
signatorySecret: SignatorySecret;
recipientSecret: RecipientSecret;
publicNickname?: string;
};
export function newRandomAgentCredential(): AgentCredential {
export function newRandomAgentCredential(
publicNickname: string
): AgentCredential {
const signatorySecret = newRandomSignatory();
const recipientSecret = newRandomRecipient();
return { signatorySecret, recipientSecret };
return { signatorySecret, recipientSecret, publicNickname };
}
// type Role = "admin" | "writer" | "reader";

View File

@@ -1,4 +1,3 @@
import { test, expect } from "bun:test";
import {
agentIDfromSessionID,
getAgent,
@@ -9,7 +8,7 @@ import {
import { LocalNode } from "./node";
test("Empty COJSON Map works", () => {
const agentCredential = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
@@ -33,7 +32,7 @@ test("Empty COJSON Map works", () => {
});
test("Can insert and delete Map entries in edit()", () => {
const agentCredential = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
@@ -65,7 +64,7 @@ test("Can insert and delete Map entries in edit()", () => {
});
test("Can get map entry values at different points in time", () => {
const agentCredential = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
@@ -87,13 +86,13 @@ test("Can get map entry values at different points in time", () => {
content.edit((editable) => {
const beforeA = Date.now();
Bun.sleepSync(1);
while(Date.now() < beforeA + 10){}
editable.set("hello", "A", "trusting");
const beforeB = Date.now();
Bun.sleepSync(1);
while(Date.now() < beforeB + 10){}
editable.set("hello", "B", "trusting");
const beforeC = Date.now();
Bun.sleepSync(1);
while(Date.now() < beforeC + 10){}
editable.set("hello", "C", "trusting");
expect(editable.get("hello")).toEqual("C");
expect(editable.getAtTime("hello", Date.now())).toEqual("C");
@@ -104,7 +103,7 @@ test("Can get map entry values at different points in time", () => {
});
test("Can get all historic values of key", () => {
const agentCredential = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
@@ -161,7 +160,7 @@ test("Can get all historic values of key", () => {
});
test("Can get last tx ID for a key", () => {
const agentCredential = newRandomAgentCredential();
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))

View File

@@ -82,7 +82,7 @@ export class CoMap<
return undefined;
}
let lastEntry = ops[ops.length - 1];
const lastEntry = ops[ops.length - 1]!;
if (lastEntry.op === "delete") {
return undefined;
@@ -116,7 +116,7 @@ export class CoMap<
return undefined;
}
const lastEntry = ops[ops.length - 1];
const lastEntry = ops[ops.length - 1]!;
return lastEntry.txID;
}

View File

@@ -1,4 +1,3 @@
import { expect, test } from "bun:test";
import {
getRecipientID,
getSignatoryID,
@@ -18,7 +17,7 @@ import {
} from "./crypto";
import { base58, base64url } from "@scure/base";
import { x25519 } from "@noble/curves/ed25519";
import { xsalsa20_poly1305 } from "@noble/ciphers/_slow";
import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
import { blake3 } from "@noble/hashes/blake3";
import stableStringify from "fast-json-stable-stringify";
@@ -50,8 +49,8 @@ test("Sealing round-trips, but invalid receiver can't unseal", () => {
const recipient3 = newRandomRecipient();
const nOnceMaterial = {
in: "coval_zTEST",
tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 },
in: "co_zTEST",
tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 },
} as const;
const sealed = seal(
@@ -84,7 +83,7 @@ test("Sealing round-trips, but invalid receiver can't unseal", () => {
getRecipientID(sender).substring("recipient_z".length)
);
const sealedBytes = base64url.decode(
sealed[getRecipientID(recipient1)].substring("sealed_U".length)
sealed[getRecipientID(recipient1)]!.substring("sealed_U".length)
);
const sharedSecret = x25519.getSharedSecret(recipient3priv, senderPub);
@@ -107,23 +106,23 @@ test("Encryption for transactions round-trips", () => {
const { secret } = newRandomKeySecret();
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
in: "coval_zTEST",
tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 },
in: "co_zTEST",
tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 },
});
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
in: "coval_zTEST",
tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 },
in: "co_zTEST",
tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 },
});
const decrypted1 = decryptForTransaction(encrypted1, secret, {
in: "coval_zTEST",
tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 },
in: "co_zTEST",
tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 },
});
const decrypted2 = decryptForTransaction(encrypted2, secret, {
in: "coval_zTEST",
tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 },
in: "co_zTEST",
tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 },
});
expect([decrypted1, decrypted2]).toEqual([{ a: "hello" }, { b: "world" }]);
@@ -134,23 +133,23 @@ test("Encryption for transactions doesn't decrypt with a wrong key", () => {
const { secret: secret2 } = newRandomKeySecret();
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
in: "coval_zTEST",
tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 },
in: "co_zTEST",
tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 },
});
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
in: "coval_zTEST",
tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 },
in: "co_zTEST",
tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 },
});
const decrypted1 = decryptForTransaction(encrypted1, secret2, {
in: "coval_zTEST",
tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 },
in: "co_zTEST",
tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 },
});
const decrypted2 = decryptForTransaction(encrypted2, secret2, {
in: "coval_zTEST",
tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 },
in: "co_zTEST",
tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 },
});
expect([decrypted1, decrypted2]).toEqual([undefined, undefined]);

View File

@@ -2,7 +2,7 @@ import { ed25519, x25519 } from "@noble/curves/ed25519";
import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa";
import { JsonValue } from "./jsonValue";
import { base58, base64url } from "@scure/base";
import stableStringify from "fast-json-stable-stringify";
import { default as stableStringify } from "fast-json-stable-stringify";
import { blake3 } from "@noble/hashes/blake3";
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
import { RawCoValueID, SessionID, TransactionID } from "./coValue";
@@ -91,10 +91,10 @@ export function seal<T extends JsonValue>(
const sealedSet: SealedSet<T> = {};
for (let i = 0; i < recipientsSorted.length; i++) {
const recipient = recipientsSorted[i];
const recipient = recipientsSorted[i]!;
const sharedSecret = x25519.getSharedSecret(
senderPriv,
recipientPubs[i]
recipientPubs[i]!
);
const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt(

View File

@@ -1,4 +1,3 @@
import { CoMap } from "./contentType";
import { newRandomKeySecret, seal } from "./crypto";
import {
RawCoValueID,
@@ -12,38 +11,23 @@ import {
getAgentCoValueHeader,
CoValueHeader,
agentIDfromSessionID,
agentIDAsCoValueID,
newRandomAgentCredential,
} from "./coValue";
import { Team, expectTeamContent } from "./permissions";
import {
NewContentMessage,
Peer,
PeerID,
PeerState,
SessionNewContent,
SubscribeMessage,
SubscribeResponseMessage,
SyncMessage,
UnsubscribeMessage,
WrongAssumedKnownStateMessage,
combinedKnownStates,
weAreStrictlyAhead,
} from "./sync";
import { SyncManager } from "./sync";
export class LocalNode {
coValues: { [key: RawCoValueID]: CoValueState } = {};
peers: { [key: PeerID]: PeerState } = {};
agentCredential: AgentCredential;
agentID: AgentID;
ownSessionID: SessionID;
knownAgents: { [key: AgentID]: Agent } = {};
sync = new SyncManager(this);
constructor(agentCredential: AgentCredential, ownSessionID: SessionID) {
this.agentCredential = agentCredential;
const agent = getAgent(agentCredential);
const agentID = getAgentID(agent);
this.agentID = agentID;
this.knownAgents[agentID] = agent;
this.ownSessionID = ownSessionID;
const agentCoValue = new CoValue(getAgentCoValueHeader(agent), this);
@@ -57,7 +41,7 @@ export class LocalNode {
const coValue = new CoValue(header, this);
this.coValues[coValue.id] = { state: "loaded", coValue: coValue };
this.syncCoValue(coValue);
void this.sync.syncCoValue(coValue);
return coValue;
}
@@ -69,20 +53,7 @@ export class LocalNode {
this.coValues[id] = entry;
for (const peer of Object.values(this.peers)) {
peer.outgoing
.write({
action: "subscribe",
knownState: {
coValueID: id,
header: false,
sessions: {},
},
})
.catch((e) => {
console.error("Error writing to peer", e);
});
}
this.sync.loadFromPeers(id);
}
if (entry.state === "loaded") {
return Promise.resolve(entry.coValue);
@@ -107,9 +78,33 @@ export class LocalNode {
return entry.coValue;
}
addKnownAgent(agent: Agent) {
const agentID = getAgentID(agent);
this.knownAgents[agentID] = agent;
createAgent(publicNickname: string): AgentCredential {
const agentCredential = newRandomAgentCredential(publicNickname);
this.createCoValue(getAgentCoValueHeader(getAgent(agentCredential)));
return agentCredential;
}
expectAgentLoaded(id: AgentID, expectation?: string): Agent {
const coValue = this.expectCoValueLoaded(
id,
expectation
);
if (coValue.header.type !== "comap" || coValue.header.ruleset.type !== "agent") {
throw new Error(
`${
expectation ? expectation + ": " : ""
}CoValue ${id} is not an agent`
);
}
return {
recipientID: coValue.header.ruleset.initialRecipientID,
signatoryID: coValue.header.ruleset.initialSignatoryID,
publicNickname: coValue.header.publicNickname?.replace("agent-", ""),
}
}
createTeam(): Team {
@@ -117,6 +112,7 @@ export class LocalNode {
type: "comap",
ruleset: { type: "team", initialAdmin: this.agentID },
meta: null,
publicNickname: "team",
});
let teamContent = expectTeamContent(teamCoValue.getCurrentContent());
@@ -145,299 +141,6 @@ export class LocalNode {
return new Team(teamContent, this);
}
addPeer(peer: Peer) {
const peerState: PeerState = {
id: peer.id,
optimisticKnownStates: {},
incoming: peer.incoming,
outgoing: peer.outgoing.getWriter(),
role: peer.role,
};
this.peers[peer.id] = peerState;
if (peer.role === "server") {
for (const entry of Object.values(this.coValues)) {
if (entry.state === "loading") {
continue;
}
peerState.outgoing
.write({
action: "subscribe",
knownState: entry.coValue.knownState(),
})
.catch((e) => {
// TODO: handle error
console.error("Error writing to peer", e);
});
peerState.optimisticKnownStates[entry.coValue.id] = {
coValueID: entry.coValue.id,
header: false,
sessions: {},
};
}
}
const readIncoming = async () => {
for await (const msg of peerState.incoming) {
for (const responseMsg of this.handleSyncMessage(
msg,
peerState
)) {
await peerState.outgoing.write(responseMsg);
}
}
};
readIncoming().catch((e) => {
// TODO: handle error
console.error("Error reading from peer", e);
});
}
handleSyncMessage(msg: SyncMessage, peer: PeerState): SyncMessage[] {
// TODO: validate
switch (msg.action) {
case "subscribe":
return this.handleSubscribe(msg, peer);
case "subscribeResponse":
return this.handleSubscribeResponse(msg, peer);
case "newContent":
return this.handleNewContent(msg);
case "wrongAssumedKnownState":
return this.handleWrongAssumedKnownState(msg, peer);
case "unsubscribe":
return this.handleUnsubscribe(msg);
default:
throw new Error(`Unknown message type ${(msg as any).action}`);
}
}
handleSubscribe(
msg: SubscribeMessage,
peer: PeerState,
asDependencyOf?: RawCoValueID
): SyncMessage[] {
const entry = this.coValues[msg.knownState.coValueID];
if (!entry || entry.state === "loading") {
if (!entry) {
this.coValues[msg.knownState.coValueID] = newLoadingState();
}
return [
{
action: "subscribeResponse",
knownState: {
coValueID: msg.knownState.coValueID,
header: false,
sessions: {},
},
},
];
}
peer.optimisticKnownStates[entry.coValue.id] =
entry.coValue.knownState();
const newContent = entry.coValue.newContentSince(msg.knownState);
const dependedOnCoValues =
entry.coValue.header.ruleset.type === "team"
? expectTeamContent(entry.coValue.getCurrentContent())
.keys()
.filter((k): k is AgentID => k.startsWith("agent_"))
.map((agent) => agentIDAsCoValueID(agent))
: entry.coValue.header.ruleset.type === "ownedByTeam"
? [entry.coValue.header.ruleset.team]
: [];
return [
...dependedOnCoValues.flatMap((coValueID) =>
this.handleSubscribe(
{
action: "subscribe",
knownState: {
coValueID,
header: false,
sessions: {},
},
},
peer,
asDependencyOf || msg.knownState.coValueID
)
),
{
action: "subscribeResponse",
knownState: entry.coValue.knownState(),
asDependencyOf,
},
...(newContent ? [newContent] : []),
];
}
handleSubscribeResponse(
msg: SubscribeResponseMessage,
peer: PeerState
): SyncMessage[] {
let entry = this.coValues[msg.knownState.coValueID];
if (!entry) {
if (msg.asDependencyOf) {
if (this.coValues[msg.asDependencyOf]) {
entry = newLoadingState();
this.coValues[msg.knownState.coValueID] = entry;
}
} else {
throw new Error(
"Expected coValue entry to be created, missing subscribe?"
);
}
}
if (entry.state === "loading") {
peer.optimisticKnownStates[msg.knownState.coValueID] =
msg.knownState;
return [];
}
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.coValues[msg.coValueID];
if (!entry) {
throw new Error(
"Expected coValue entry to be created, missing subscribe?"
);
}
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 coValue = new CoValue(msg.header, this);
resolveAfterDone = entry.resolve;
entry = {
state: "loaded",
coValue: coValue,
};
this.coValues[msg.coValueID] = entry;
}
const coValue = entry.coValue;
let invalidStateAssumed = false;
for (const sessionID of Object.keys(msg.newContent) as SessionID[]) {
const ourKnownTxIdx =
coValue.sessions[sessionID]?.transactions.length;
const theirFirstNewTxIdx = msg.newContent[sessionID].after;
if ((ourKnownTxIdx || 0) < theirFirstNewTxIdx) {
invalidStateAssumed = true;
continue;
}
const alreadyKnownOffset = ourKnownTxIdx
? ourKnownTxIdx - theirFirstNewTxIdx
: 0;
const newTransactions =
msg.newContent[sessionID].newTransactions.slice(
alreadyKnownOffset
);
const success = coValue.tryAddTransactions(
sessionID,
newTransactions,
msg.newContent[sessionID].lastHash,
msg.newContent[sessionID].lastSignature
);
if (!success) {
console.error("Failed to add transactions", newTransactions);
continue;
}
}
if (resolveAfterDone) {
resolveAfterDone(coValue);
}
return invalidStateAssumed
? [
{
action: "wrongAssumedKnownState",
knownState: coValue.knownState(),
},
]
: [];
}
handleWrongAssumedKnownState(
msg: WrongAssumedKnownStateMessage,
peer: PeerState
): SyncMessage[] {
const coValue = this.expectCoValueLoaded(msg.knownState.coValueID);
peer.optimisticKnownStates[msg.knownState.coValueID] =
combinedKnownStates(msg.knownState, coValue.knownState());
const newContent = coValue.newContentSince(msg.knownState);
return newContent ? [newContent] : [];
}
handleUnsubscribe(msg: UnsubscribeMessage): SyncMessage[] {
throw new Error("Method not implemented.");
}
async syncCoValue(coValue: CoValue) {
for (const peer of Object.values(this.peers)) {
const optimisticKnownState =
peer.optimisticKnownStates[coValue.id];
if (optimisticKnownState || peer.role === "server") {
const newContent =
coValue.newContentSince(optimisticKnownState);
peer.optimisticKnownStates[coValue.id] = peer
.optimisticKnownStates[coValue.id]
? combinedKnownStates(
peer.optimisticKnownStates[coValue.id],
coValue.knownState()
)
: coValue.knownState();
if (!optimisticKnownState && peer.role === "server") {
// auto-subscribe
await peer.outgoing.write({
action: "subscribe",
knownState: coValue.knownState(),
});
}
if (newContent) {
await peer.outgoing.write(newContent);
}
}
}
}
testWithDifferentCredentials(
agentCredential: AgentCredential,
ownSessionID: SessionID
@@ -463,11 +166,6 @@ export class LocalNode {
.filter((x): x is Exclude<typeof x, undefined> => !!x)
);
newNode.knownAgents = {
...this.knownAgents,
[agentIDfromSessionID(ownSessionID)]: getAgent(agentCredential),
};
return newNode;
}
}
@@ -480,7 +178,7 @@ type CoValueState =
}
| { state: "loaded"; coValue: CoValue };
function newLoadingState(): CoValueState {
export function newLoadingState(): CoValueState {
let resolve: (coValue: CoValue) => void;
const promise = new Promise<CoValue>((r) => {

View File

@@ -1,4 +1,3 @@
import { test, expect } from "bun:test";
import {
getAgent,
getAgentID,
@@ -16,9 +15,9 @@ import {
} from "./crypto";
function teamWithTwoAdmins() {
const { team, admin, adminID } = newTeam();
const { team, admin, adminID, node } = newTeam();
const otherAdmin = newRandomAgentCredential();
const otherAdmin = node.createAgent("otherAdmin");
const otherAdminID = getAgentID(getAgent(otherAdmin));
let content = expectTeamContent(team.getCurrentContent());
@@ -35,11 +34,11 @@ function teamWithTwoAdmins() {
}
expect(content.get(otherAdminID)).toEqual("admin");
return { team, admin, adminID, otherAdmin, otherAdminID };
return { team, admin, adminID, otherAdmin, otherAdminID, node };
}
function newTeam() {
const admin = newRandomAgentCredential();
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
@@ -48,6 +47,7 @@ function newTeam() {
type: "comap",
ruleset: { type: "team", initialAdmin: adminID },
meta: null,
publicNickname: "team",
});
const teamContent = expectTeamContent(team.getCurrentContent());
@@ -65,7 +65,7 @@ test("Initial admin can add another admin to a team", () => {
});
function newTeamHighLevel() {
const admin = newRandomAgentCredential();
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
@@ -78,11 +78,9 @@ function newTeamHighLevel() {
function teamWithTwoAdminsHighLevel() {
const { admin, adminID, node, team } = newTeamHighLevel();
const otherAdmin = newRandomAgentCredential();
const otherAdmin = node.createAgent("otherAdmin");
const otherAdminID = getAgentID(getAgent(otherAdmin));
node.addKnownAgent(getAgent(otherAdmin));
team.addMember(otherAdminID, "admin");
return { admin, adminID, node, team, otherAdmin, otherAdminID };
@@ -93,7 +91,7 @@ test("Initial admin can add another admin to a team (high level)", () => {
});
test("Added admin can add a third admin to a team", () => {
const { team, otherAdmin, otherAdminID } = teamWithTwoAdmins();
const { team, otherAdmin, otherAdminID, node } = teamWithTwoAdmins();
const teamAsOtherAdmin = team.testWithDifferentCredentials(
otherAdmin,
@@ -104,7 +102,7 @@ test("Added admin can add a third admin to a team", () => {
expect(otherContent.get(otherAdminID)).toEqual("admin");
const thirdAdmin = newRandomAgentCredential();
const thirdAdmin = node.createAgent("thirdAdmin");
const thirdAdminID = getAgentID(getAgent(thirdAdmin));
otherContent.edit((editable) => {
@@ -126,11 +124,9 @@ test("Added adming can add a third admin to a team (high level)", () => {
newRandomSessionID(otherAdminID)
);
const thirdAdmin = newRandomAgentCredential();
const thirdAdmin = node.createAgent("thirdAdmin");
const thirdAdminID = getAgentID(getAgent(thirdAdmin));
node.addKnownAgent(getAgent(thirdAdmin));
teamAsOtherAdmin.addMember(thirdAdminID, "admin");
expect(teamAsOtherAdmin.teamMap.get(thirdAdminID)).toEqual("admin");
@@ -187,8 +183,8 @@ test("Admins can't demote other admins in a team (high level)", () => {
});
test("Admins an add writers to a team, who can't add admins, writers, or readers", () => {
const { team } = newTeam();
const writer = newRandomAgentCredential();
const { team, node } = newTeam();
const writer = node.createAgent("writer");
const writerID = getAgentID(getAgent(writer));
let teamContent = expectTeamContent(team.getCurrentContent());
@@ -212,7 +208,7 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers
expect(teamContentAsWriter.get(writerID)).toEqual("writer");
const otherAgent = newRandomAgentCredential();
const otherAgent = node.createAgent("otherAgent");
const otherAgentID = getAgentID(getAgent(otherAgent));
teamContentAsWriter.edit((editable) => {
@@ -234,11 +230,9 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers
test("Admins an add writers to a team, who can't add admins, writers, or readers (high level)", () => {
const { team, node } = newTeamHighLevel();
const writer = newRandomAgentCredential();
const writer = node.createAgent("writer");
const writerID = getAgentID(getAgent(writer));
node.addKnownAgent(getAgent(writer));
team.addMember(writerID, "writer");
expect(team.teamMap.get(writerID)).toEqual("writer");
@@ -249,11 +243,9 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers
expect(teamAsWriter.teamMap.get(writerID)).toEqual("writer");
const otherAgent = newRandomAgentCredential();
const otherAgent = node.createAgent("otherAgent");
const otherAgentID = getAgentID(getAgent(otherAgent));
node.addKnownAgent(getAgent(otherAgent));
expect(() => teamAsWriter.addMember(otherAgentID, "admin")).toThrow(
"Failed to set role"
);
@@ -268,8 +260,8 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers
});
test("Admins can add readers to a team, who can't add admins, writers, or readers", () => {
const { team } = newTeam();
const reader = newRandomAgentCredential();
const { team, node } = newTeam();
const reader = node.createAgent("reader");
const readerID = getAgentID(getAgent(reader));
let teamContent = expectTeamContent(team.getCurrentContent());
@@ -293,7 +285,7 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader
expect(teamContentAsReader.get(readerID)).toEqual("reader");
const otherAgent = newRandomAgentCredential();
const otherAgent = node.createAgent("otherAgent");
const otherAgentID = getAgentID(getAgent(otherAgent));
teamContentAsReader.edit((editable) => {
@@ -315,11 +307,9 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader
test("Admins can add readers to a team, who can't add admins, writers, or readers (high level)", () => {
const { team, node } = newTeamHighLevel();
const reader = newRandomAgentCredential();
const reader = node.createAgent("reader");
const readerID = getAgentID(getAgent(reader));
node.addKnownAgent(getAgent(reader));
team.addMember(readerID, "reader");
expect(team.teamMap.get(readerID)).toEqual("reader");
@@ -330,11 +320,9 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader
expect(teamAsReader.teamMap.get(readerID)).toEqual("reader");
const otherAgent = newRandomAgentCredential();
const otherAgent = node.createAgent("otherAgent");
const otherAgentID = getAgentID(getAgent(otherAgent));
node.addKnownAgent(getAgent(otherAgent));
expect(() => teamAsReader.addMember(otherAgentID, "admin")).toThrow(
"Failed to set role"
);
@@ -355,6 +343,7 @@ test("Admins can write to an object that is owned by their team", () => {
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
publicNickname: "childObject",
});
let childContent = expectMap(childObject.getCurrentContent());
@@ -385,7 +374,7 @@ test("Admins can write to an object that is owned by their team (high level)", (
test("Writers can write to an object that is owned by their team", () => {
const { node, team } = newTeam();
const writer = newRandomAgentCredential();
const writer = node.createAgent("writer");
const writerID = getAgentID(getAgent(writer));
expectTeamContent(team.getCurrentContent()).edit((editable) => {
@@ -397,6 +386,7 @@ test("Writers can write to an object that is owned by their team", () => {
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
publicNickname: "childObject",
});
const childObjectAsWriter = childObject.testWithDifferentCredentials(
@@ -421,11 +411,9 @@ test("Writers can write to an object that is owned by their team", () => {
test("Writers can write to an object that is owned by their team (high level)", () => {
const { node, team } = newTeamHighLevel();
const writer = newRandomAgentCredential();
const writer = node.createAgent("writer");
const writerID = getAgentID(getAgent(writer));
node.addKnownAgent(getAgent(writer));
team.addMember(writerID, "writer");
const childObject = team.createMap();
@@ -447,7 +435,7 @@ test("Writers can write to an object that is owned by their team (high level)",
test("Readers can not write to an object that is owned by their team", () => {
const { node, team } = newTeam();
const reader = newRandomAgentCredential();
const reader = node.createAgent("reader");
const readerID = getAgentID(getAgent(reader));
expectTeamContent(team.getCurrentContent()).edit((editable) => {
@@ -459,6 +447,7 @@ test("Readers can not write to an object that is owned by their team", () => {
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
publicNickname: "childObject",
});
const childObjectAsReader = childObject.testWithDifferentCredentials(
@@ -483,11 +472,9 @@ test("Readers can not write to an object that is owned by their team", () => {
test("Readers can not write to an object that is owned by their team (high level)", () => {
const { node, team } = newTeamHighLevel();
const reader = newRandomAgentCredential();
const reader = node.createAgent("reader");
const readerID = getAgentID(getAgent(reader));
node.addKnownAgent(getAgent(reader));
team.addMember(readerID, "reader");
const childObject = team.createMap();
@@ -534,6 +521,7 @@ test("Admins can set team read key and then use it to create and read private tr
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
publicNickname: "childObject",
});
let childContent = expectMap(childObject.getCurrentContent());
@@ -563,7 +551,7 @@ test("Admins can set team read key and then use it to create and read private tr
test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects", () => {
const { node, team, admin } = newTeam();
const writer = newRandomAgentCredential();
const writer = node.createAgent("writer");
const writerID = getAgentID(getAgent(writer));
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
@@ -592,6 +580,7 @@ test("Admins can set team read key and then writers can use it to create and rea
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
publicNickname: "childObject",
});
const childObjectAsWriter = childObject.testWithDifferentCredentials(
@@ -618,11 +607,9 @@ test("Admins can set team read key and then writers can use it to create and rea
test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects (high level)", () => {
const { node, team, admin } = newTeamHighLevel();
const writer = newRandomAgentCredential();
const writer = node.createAgent("writer");
const writerID = getAgentID(getAgent(writer));
node.addKnownAgent(getAgent(writer));
team.addMember(writerID, "writer");
const childObject = team.createMap();
@@ -644,7 +631,7 @@ test("Admins can set team read key and then writers can use it to create and rea
test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read", () => {
const { node, team, admin } = newTeam();
const reader = newRandomAgentCredential();
const reader = node.createAgent("reader");
const readerID = getAgentID(getAgent(reader));
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
@@ -673,6 +660,7 @@ test("Admins can set team read key and then use it to create private transaction
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
publicNickname: "childObject",
});
expectMap(childObject.getCurrentContent()).edit((editable) => {
@@ -697,11 +685,9 @@ test("Admins can set team read key and then use it to create private transaction
test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read (high level)", () => {
const { node, team, admin } = newTeamHighLevel();
const reader = newRandomAgentCredential();
const reader = node.createAgent("reader");
const readerID = getAgentID(getAgent(reader));
node.addKnownAgent(getAgent(reader));
team.addMember(readerID, "reader");
let childObject = team.createMap();
@@ -711,21 +697,21 @@ 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.coValue.testWithDifferentCredentials(
reader,
newRandomSessionID(readerID)
).getCurrentContent());
const childContentAsReader = expectMap(
childObject.coValue
.testWithDifferentCredentials(reader, newRandomSessionID(readerID))
.getCurrentContent()
);
expect(childContentAsReader.get("foo")).toEqual("bar");
});
test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key", () => {
const { node, team, admin } = newTeam();
const reader1 = newRandomAgentCredential();
const reader1 = node.createAgent("reader1");
const reader1ID = getAgentID(getAgent(reader1));
const reader2 = newRandomAgentCredential();
const reader2 = node.createAgent("reader2");
const reader2ID = getAgentID(getAgent(reader2));
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
@@ -773,6 +759,7 @@ test("Admins can set team read key and then use it to create private transaction
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
publicNickname: "childObject",
});
expectMap(childObject.getCurrentContent()).edit((editable) => {
@@ -810,14 +797,11 @@ test("Admins can set team read key and then use it to create private transaction
test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key (high level)", () => {
const { node, team, admin } = newTeamHighLevel();
const reader1 = newRandomAgentCredential();
const reader1 = node.createAgent("reader1");
const reader1ID = getAgentID(getAgent(reader1));
const reader2 = newRandomAgentCredential();
const reader2 = node.createAgent("reader2");
const reader2ID = getAgentID(getAgent(reader2));
node.addKnownAgent(getAgent(reader1));
node.addKnownAgent(getAgent(reader2));
team.addMember(reader1ID, "reader");
let childObject = team.createMap();
@@ -827,24 +811,31 @@ 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.coValue.testWithDifferentCredentials(
reader1,
newRandomSessionID(reader1ID)
).getCurrentContent());
const childContentAsReader1 = expectMap(
childObject.coValue
.testWithDifferentCredentials(
reader1,
newRandomSessionID(reader1ID)
)
.getCurrentContent()
);
expect(childContentAsReader1.get("foo")).toEqual("bar");
team.addMember(reader2ID, "reader");
const childContentAsReader2 = expectMap(childObject.coValue.testWithDifferentCredentials(
reader2,
newRandomSessionID(reader2ID)
).getCurrentContent());
const childContentAsReader2 = expectMap(
childObject.coValue
.testWithDifferentCredentials(
reader2,
newRandomSessionID(reader2ID)
)
.getCurrentContent()
);
expect(childContentAsReader2.get("foo")).toEqual("bar");
});
test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, make another private transaction, and both can be read by the admin", () => {
const { node, team, admin, adminID } = newTeam();
@@ -873,6 +864,7 @@ test("Admins can set team read key, make a private transaction in an owned objec
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
publicNickname: "childObject",
});
let childContent = expectMap(childObject.getCurrentContent());
@@ -952,6 +944,7 @@ test("Admins can set team read key, make a private transaction in an owned objec
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
publicNickname: "childObject",
});
const teamContent = expectTeamContent(team.getCurrentContent());
@@ -985,7 +978,7 @@ test("Admins can set team read key, make a private transaction in an owned objec
childContent = expectMap(childObject.getCurrentContent());
expect(childContent.get("foo")).toEqual("bar");
const reader = newRandomAgentCredential();
const reader = node.createAgent("reader");
const readerID = getAgentID(getAgent(reader));
const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret();
@@ -1065,11 +1058,9 @@ test("Admins can set team read key, make a private transaction in an owned objec
expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey);
const reader = newRandomAgentCredential();
const reader = node.createAgent("reader");
const readerID = getAgentID(getAgent(reader));
node.addKnownAgent(getAgent(reader));
team.addMember(readerID, "reader");
childObject = childObject.edit((editable) => {
@@ -1077,15 +1068,15 @@ 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.coValue.testWithDifferentCredentials(
reader,
newRandomSessionID(readerID)
).getCurrentContent());
const childContentAsReader = expectMap(
childObject.coValue
.testWithDifferentCredentials(reader, newRandomSessionID(readerID))
.getCurrentContent()
);
expect(childContentAsReader.get("foo")).toEqual("bar");
expect(childContentAsReader.get("foo2")).toEqual("bar2");
})
});
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();
@@ -1094,13 +1085,14 @@ test("Admins can set team read rey, make a private transaction in an owned objec
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
publicNickname: "childObject",
});
const teamContent = expectTeamContent(team.getCurrentContent());
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
const reader = newRandomAgentCredential();
const reader = node.createAgent("reader");
const readerID = getAgentID(getAgent(reader));
const reader2 = newRandomAgentCredential();
const reader2 = node.createAgent("reader2");
const reader2ID = getAgentID(getAgent(reader));
teamContent.edit((editable) => {
@@ -1202,15 +1194,12 @@ test("Admins can set team read rey, make a private transaction in an owned objec
newRandomSessionID(reader2ID)
);
expect(() => expectMap(childObjectAsReader.getCurrentContent())).toThrow(
/readKey (.+?) not revealed for (.+?)/
);
expect(
expectMap(childObjectAsReader.getCurrentContent()).get("foo2")
).toBeUndefined();
expect(
expectMap(childObjectAsReader2.getCurrentContent()).get("foo2")
).toEqual("bar2");
expect(() => {
childObjectAsReader.getCurrentContent();
}).toThrow();
});
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 (high level)", () => {
@@ -1218,7 +1207,6 @@ test("Admins can set team read rey, make a private transaction in an owned objec
let childObject = team.createMap();
childObject = childObject.edit((editable) => {
editable.set("foo", "bar", "private");
expect(editable.get("foo")).toEqual("bar");
@@ -1230,14 +1218,11 @@ test("Admins can set team read rey, make a private transaction in an owned objec
const secondReadKey = childObject.coValue.getCurrentReadKey();
const reader = newRandomAgentCredential();
const reader = node.createAgent("reader");
const readerID = getAgentID(getAgent(reader));
const reader2 = newRandomAgentCredential();
const reader2 = node.createAgent("reader2");
const reader2ID = getAgentID(getAgent(reader2));
node.addKnownAgent(getAgent(reader));
node.addKnownAgent(getAgent(reader2));
team.addMember(readerID, "reader");
team.addMember(reader2ID, "reader");
@@ -1258,17 +1243,27 @@ test("Admins can set team read rey, make a private transaction in an owned objec
expect(editable.get("foo3")).toEqual("bar3");
});
const childContentAsReader2 = expectMap(childObject.coValue.testWithDifferentCredentials(
reader2,
newRandomSessionID(reader2ID)
).getCurrentContent());
const childContentAsReader2 = expectMap(
childObject.coValue
.testWithDifferentCredentials(
reader2,
newRandomSessionID(reader2ID)
)
.getCurrentContent()
);
expect(childContentAsReader2.get("foo")).toEqual("bar");
expect(childContentAsReader2.get("foo2")).toEqual("bar2");
expect(childContentAsReader2.get("foo3")).toEqual("bar3");
expect(() => childObject.coValue.testWithDifferentCredentials(
reader,
newRandomSessionID(readerID)
).getCurrentContent()).toThrow(/readKey (.+?) not revealed for (.+?)/);
expect(
expectMap(
childObject.coValue
.testWithDifferentCredentials(
reader,
newRandomSessionID(readerID)
)
.getCurrentContent()
).get("foo3")
).toBeUndefined();
});

View File

@@ -238,7 +238,7 @@ export class Team {
addMember(agentID: AgentID, role: Role) {
this.teamMap = this.teamMap.edit((map) => {
const agent = this.node.knownAgents[agentID];
const agent = this.node.expectAgentLoaded(agentID, "Expected to know agent to add them to team");
if (!agent) {
throw new Error("Unknown agent " + agentID);
@@ -251,6 +251,10 @@ export class Team {
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
if (!currentReadKey.secret) {
throw new Error("Can't add member without read key secret");
}
const revelation = seal(
currentReadKey.secret,
this.teamMap.coValue.node.agentCredential.recipientSecret,
@@ -271,7 +275,7 @@ export class Team {
rotateReadKey() {
const currentlyPermittedReaders = this.teamMap.keys().filter((key) => {
if (key.startsWith("agent_")) {
if (key.startsWith("co_agent")) {
const role = this.teamMap.get(key);
return (
role === "admin" || role === "writer" || role === "reader"
@@ -281,7 +285,16 @@ export class Team {
}
}) as AgentID[];
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey();
if (!maybeCurrentReadKey.secret) {
throw new Error("Can't rotate read key secret we don't have access to");
}
const currentReadKey = {
id: maybeCurrentReadKey.id,
secret: maybeCurrentReadKey.secret,
};
const newReadKey = newRandomKeySecret();
@@ -290,7 +303,13 @@ export class Team {
this.teamMap.coValue.node.agentCredential.recipientSecret,
new Set(
currentlyPermittedReaders.map(
(reader) => this.node.knownAgents[reader].recipientID
(reader) => {
const readerAgent = this.node.expectAgentLoaded(reader, "Expected to know currently permitted reader");
if (!readerAgent) {
throw new Error("Unknown agent " + reader);
}
return readerAgent.recipientID
}
)
),
{
@@ -336,6 +355,7 @@ export class Team {
team: this.teamMap.id,
},
meta: meta || null,
publicNickname: "map",
})
.getCurrentContent() as CoMap<M, Meta>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,9 @@
import { Hash, Signature } from "./crypto";
import { CoValueHeader, RawCoValueID, SessionID, Transaction } from "./coValue";
import { CoValue } from "./coValue";
import { LocalNode } from "./node";
import { newLoadingState } from "./node";
import { ReadableStream, WritableStream, WritableStreamDefaultWriter } from "isomorphic-streams";
export type CoValueKnownState = {
coValueID: RawCoValueID;
@@ -7,23 +11,29 @@ export type CoValueKnownState = {
sessions: { [sessionID: SessionID]: number };
};
export function emptyKnownState(coValueID: RawCoValueID): CoValueKnownState {
return {
coValueID,
header: false,
sessions: {},
};
}
export type SyncMessage =
| SubscribeMessage
| SubscribeResponseMessage
| TellKnownStateMessage
| NewContentMessage
| WrongAssumedKnownStateMessage
| UnsubscribeMessage;
export type SubscribeMessage = {
action: "subscribe";
knownState: CoValueKnownState;
};
} & CoValueKnownState;
export type SubscribeResponseMessage = {
action: "subscribeResponse";
knownState: CoValueKnownState;
export type TellKnownStateMessage = {
action: "tellKnownState";
asDependencyOf?: RawCoValueID;
};
} & CoValueKnownState;
export type NewContentMessage = {
action: "newContent";
@@ -44,8 +54,7 @@ export type SessionNewContent = {
export type WrongAssumedKnownStateMessage = {
action: "wrongAssumedKnownState";
knownState: CoValueKnownState;
};
} & CoValueKnownState;
export type UnsubscribeMessage = {
action: "unsubscribe";
@@ -64,6 +73,7 @@ export interface Peer {
export interface PeerState {
id: PeerID;
optimisticKnownStates: { [coValueID: RawCoValueID]: CoValueKnownState };
toldKnownState: Set<RawCoValueID>;
incoming: ReadableStream<SyncMessage>;
outgoing: WritableStreamDefaultWriter<SyncMessage>;
role: "peer" | "server" | "client";
@@ -94,10 +104,16 @@ export function weAreStrictlyAhead(
return true;
}
export function combinedKnownStates(stateA: CoValueKnownState, stateB: CoValueKnownState): CoValueKnownState {
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[]);
const allSessions = new Set([
...Object.keys(stateA.sessions),
...Object.keys(stateB.sessions),
] as SessionID[]);
for (const sessionID of allSessions) {
const stateAValue = stateA.sessions[sessionID];
@@ -111,4 +127,386 @@ export function combinedKnownStates(stateA: CoValueKnownState, stateB: CoValueKn
header: stateA.header || stateB.header,
sessions: sessionStates,
};
}
}
export class SyncManager {
peers: { [key: PeerID]: PeerState } = {};
local: LocalNode;
constructor(local: LocalNode) {
this.local = local;
}
loadFromPeers(id: RawCoValueID) {
for (const peer of Object.values(this.peers)) {
peer.outgoing
.write({
action: "subscribe",
coValueID: id,
header: false,
sessions: {},
})
.catch((e) => {
console.error("Error writing to peer", e);
});
}
}
async handleSyncMessage(msg: SyncMessage, peer: PeerState) {
// TODO: validate
switch (msg.action) {
case "subscribe":
return await this.handleSubscribe(msg, peer);
case "tellKnownState":
return await this.handleTellKnownState(msg, peer);
case "newContent":
return await this.handleNewContent(msg, peer);
case "wrongAssumedKnownState":
return await this.handleWrongAssumedKnownState(msg, peer);
case "unsubscribe":
return await this.handleUnsubscribe(msg);
default:
throw new Error(
`Unknown message type ${
(msg as { action: "string" }).action
}`
);
}
}
async subscribeToIncludingDependencies(
coValueID: RawCoValueID,
peer: PeerState
) {
const coValue = this.local.expectCoValueLoaded(coValueID);
for (const coValueID of coValue.getDependedOnCoValues()) {
await this.subscribeToIncludingDependencies(coValueID, peer);
}
if (!peer.toldKnownState.has(coValueID)) {
peer.toldKnownState.add(coValueID);
await peer.outgoing.write({
action: "subscribe",
...coValue.knownState(),
});
}
}
async tellUntoldKnownStateIncludingDependencies(
coValueID: RawCoValueID,
peer: PeerState,
asDependencyOf?: RawCoValueID
) {
const coValue = this.local.expectCoValueLoaded(coValueID);
for (const dependentCoValueID of coValue.getDependedOnCoValues()) {
await this.tellUntoldKnownStateIncludingDependencies(
dependentCoValueID,
peer,
asDependencyOf || coValueID
);
}
if (!peer.toldKnownState.has(coValueID)) {
await peer.outgoing.write({
action: "tellKnownState",
asDependencyOf,
...coValue.knownState(),
});
peer.toldKnownState.add(coValueID);
}
}
async sendNewContentIncludingDependencies(
coValueID: RawCoValueID,
peer: PeerState
) {
const coValue = this.local.expectCoValueLoaded(coValueID);
for (const coValueID of coValue.getDependedOnCoValues()) {
await this.sendNewContentIncludingDependencies(coValueID, peer);
}
const newContent = coValue.newContentSince(
peer.optimisticKnownStates[coValueID]
);
if (newContent) {
await peer.outgoing.write(newContent);
peer.optimisticKnownStates[coValueID] = combinedKnownStates(
peer.optimisticKnownStates[coValueID] ||
emptyKnownState(coValueID),
coValue.knownState()
);
}
}
addPeer(peer: Peer) {
const peerState: PeerState = {
id: peer.id,
optimisticKnownStates: {},
incoming: peer.incoming,
outgoing: peer.outgoing.getWriter(),
toldKnownState: new Set(),
role: peer.role,
};
this.peers[peer.id] = peerState;
if (peer.role === "server") {
const initialSync = async () => {
for (const entry of Object.values(this.local.coValues)) {
if (entry.state === "loading") {
continue;
}
await this.subscribeToIncludingDependencies(
entry.coValue.id,
peerState
);
peerState.optimisticKnownStates[entry.coValue.id] = {
coValueID: entry.coValue.id,
header: false,
sessions: {},
};
}
};
void initialSync();
}
const readIncoming = async () => {
for await (const msg of peerState.incoming) {
try {
await this.handleSyncMessage(msg, peerState);
} catch (e) {
console.error(
`Error reading from peer ${peer.id}`,
JSON.stringify(msg),
e
);
}
}
};
void readIncoming();
}
async handleSubscribe(msg: SubscribeMessage, peer: PeerState) {
const entry = this.local.coValues[msg.coValueID];
if (!entry || entry.state === "loading") {
if (!entry) {
this.local.coValues[msg.coValueID] = newLoadingState();
}
peer.optimisticKnownStates[msg.coValueID] = knownStateIn(msg);
peer.toldKnownState.add(msg.coValueID);
await peer.outgoing.write({
action: "tellKnownState",
coValueID: msg.coValueID,
header: false,
sessions: {},
});
return;
}
peer.optimisticKnownStates[msg.coValueID] = knownStateIn(msg);
await this.tellUntoldKnownStateIncludingDependencies(
msg.coValueID,
peer
);
await this.sendNewContentIncludingDependencies(msg.coValueID, peer);
}
async handleTellKnownState(msg: TellKnownStateMessage, peer: PeerState) {
let entry = this.local.coValues[msg.coValueID];
peer.optimisticKnownStates[msg.coValueID] = combinedKnownStates(
peer.optimisticKnownStates[msg.coValueID] || emptyKnownState(msg.coValueID),
knownStateIn(msg)
);
if (!entry) {
if (msg.asDependencyOf) {
if (this.local.coValues[msg.asDependencyOf]) {
entry = newLoadingState();
this.local.coValues[msg.coValueID] = entry;
} else {
throw new Error(
"Expected coValue dependency entry to be created, missing subscribe?"
);
}
} else {
throw new Error(
"Expected coValue entry to be created, missing subscribe?"
);
}
}
if (entry.state === "loading") {
return [];
}
await this.tellUntoldKnownStateIncludingDependencies(
msg.coValueID,
peer
);
await this.sendNewContentIncludingDependencies(msg.coValueID, peer);
}
async handleNewContent(msg: NewContentMessage, peer: PeerState) {
let entry = this.local.coValues[msg.coValueID];
if (!entry) {
throw new Error(
"Expected coValue entry to be created, missing subscribe?"
);
}
let resolveAfterDone: ((coValue: CoValue) => void) | undefined;
const peerOptimisticKnownState =
peer.optimisticKnownStates[msg.coValueID];
if (!peerOptimisticKnownState) {
throw new Error(
"Expected optimisticKnownState to be set for coValue we receive new content for"
);
}
if (entry.state === "loading") {
if (!msg.header) {
throw new Error("Expected header to be sent in first message");
}
peerOptimisticKnownState.header = true;
const coValue = new CoValue(msg.header, this.local);
resolveAfterDone = entry.resolve;
entry = {
state: "loaded",
coValue: coValue,
};
this.local.coValues[msg.coValueID] = entry;
}
const coValue = entry.coValue;
let invalidStateAssumed = false;
for (const [sessionID, newContentForSession] of Object.entries(
msg.newContent
) as [SessionID, SessionNewContent][]) {
const ourKnownTxIdx =
coValue.sessions[sessionID]?.transactions.length;
const theirFirstNewTxIdx = newContentForSession.after;
if ((ourKnownTxIdx || 0) < theirFirstNewTxIdx) {
invalidStateAssumed = true;
continue;
}
const alreadyKnownOffset = ourKnownTxIdx
? ourKnownTxIdx - theirFirstNewTxIdx
: 0;
const newTransactions =
newContentForSession.newTransactions.slice(alreadyKnownOffset);
const success = coValue.tryAddTransactions(
sessionID,
newTransactions,
newContentForSession.lastHash,
newContentForSession.lastSignature
);
if (!success) {
console.error("Failed to add transactions", newTransactions);
continue;
}
peerOptimisticKnownState.sessions[sessionID] =
newContentForSession.after +
newContentForSession.newTransactions.length;
}
if (resolveAfterDone) {
resolveAfterDone(coValue);
}
await this.syncCoValue(coValue);
if (invalidStateAssumed) {
await peer.outgoing.write({
action: "wrongAssumedKnownState",
...coValue.knownState(),
});
}
}
async handleWrongAssumedKnownState(
msg: WrongAssumedKnownStateMessage,
peer: PeerState
) {
const coValue = this.local.expectCoValueLoaded(msg.coValueID);
peer.optimisticKnownStates[msg.coValueID] = combinedKnownStates(
msg,
coValue.knownState()
);
const newContent = coValue.newContentSince(msg);
if (newContent) {
await peer.outgoing.write(newContent);
}
}
handleUnsubscribe(_msg: UnsubscribeMessage) {
throw new Error("Method not implemented.");
}
async syncCoValue(coValue: CoValue) {
for (const peer of Object.values(this.peers)) {
const optimisticKnownState = peer.optimisticKnownStates[coValue.id];
const shouldSync =
optimisticKnownState ||
peer.role === "server";
if (shouldSync) {
await this.tellUntoldKnownStateIncludingDependencies(
coValue.id,
peer
);
await this.sendNewContentIncludingDependencies(
coValue.id,
peer
);
}
}
}
}
function knownStateIn(
msg:
| SubscribeMessage
| TellKnownStateMessage
| WrongAssumedKnownStateMessage
) {
return {
coValueID: msg.coValueID,
header: msg.header,
sessions: msg.sessions,
};
}

View File

@@ -14,10 +14,8 @@
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"noEmit": true,
"types": [
"bun-types" // add Bun global
],
// "noUncheckedIndexedAccess": true
}
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
},
"include": ["./src/**/*"],
}

2781
yarn.lock Normal file

File diff suppressed because it is too large Load Diff