Merge pull request #6 from gardencmp/anselm/gar-81-accountsagents-as-teams

Implement accounts as teams
This commit is contained in:
Anselm Eickhoff
2023-08-14 15:18:08 +01:00
committed by GitHub
12 changed files with 854 additions and 856 deletions

115
src/account.ts Normal file
View File

@@ -0,0 +1,115 @@
import { CoValueHeader } from './coValue.js';
import { CoValueID } from './contentType.js';
import { AgentSecret, RecipientID, RecipientSecret, SignatoryID, SignatorySecret, getAgentID, getAgentRecipientID, getAgentRecipientSecret, getAgentSignatoryID, getAgentSignatorySecret } from './crypto.js';
import { AgentID } from './ids.js';
import { CoMap, LocalNode } from './index.js';
import { Team, TeamContent } from './permissions.js';
export function accountHeaderForInitialAgentSecret(agentSecret: AgentSecret): CoValueHeader {
const agent = getAgentID(agentSecret);
return {
type: "comap",
ruleset: {type: "team", initialAdmin: agent},
meta: {
type: "account"
},
createdAt: null,
uniqueness: null,
}
}
export class Account extends Team {
get id(): AccountID {
return this.teamMap.id;
}
getCurrentAgentID(): AgentID {
const agents = this.teamMap.keys().filter((k): k is AgentID => k.startsWith("recipient_"));
if (agents.length !== 1) {
throw new Error("Expected exactly one agent in account, got " + agents.length);
}
return agents[0]!;
}
}
export interface GeneralizedControlledAccount {
id: AccountIDOrAgentID;
agentSecret: AgentSecret;
currentAgentID: () => AgentID;
currentSignatoryID: () => SignatoryID;
currentSignatorySecret: () => SignatorySecret;
currentRecipientID: () => RecipientID;
currentRecipientSecret: () => RecipientSecret;
}
export class ControlledAccount extends Account implements GeneralizedControlledAccount {
agentSecret: AgentSecret;
constructor(agentSecret: AgentSecret, teamMap: CoMap<TeamContent, {}>, node: LocalNode) {
super(teamMap, node);
this.agentSecret = agentSecret;
}
currentAgentID(): AgentID {
return getAgentID(this.agentSecret);
}
currentSignatoryID(): SignatoryID {
return getAgentSignatoryID(this.currentAgentID());
}
currentSignatorySecret(): SignatorySecret {
return getAgentSignatorySecret(this.agentSecret);
}
currentRecipientID(): RecipientID {
return getAgentRecipientID(this.currentAgentID());
}
currentRecipientSecret(): RecipientSecret {
return getAgentRecipientSecret(this.agentSecret);
}
}
export class AnonymousControlledAccount implements GeneralizedControlledAccount {
agentSecret: AgentSecret;
constructor(agentSecret: AgentSecret) {
this.agentSecret = agentSecret;
}
get id(): AgentID {
return getAgentID(this.agentSecret);
}
currentAgentID(): AgentID {
return getAgentID(this.agentSecret);
}
currentSignatoryID(): SignatoryID {
return getAgentSignatoryID(this.currentAgentID());
}
currentSignatorySecret(): SignatorySecret {
return getAgentSignatorySecret(this.agentSecret);
}
currentRecipientID(): RecipientID {
return getAgentRecipientID(this.currentAgentID());
}
currentRecipientSecret(): RecipientSecret {
return getAgentRecipientSecret(this.agentSecret);
}
}
export type AccountMeta = {type: "account"};
export type AccountID = CoValueID<CoMap<TeamContent, AccountMeta>>;
export type AccountIDOrAgentID = AgentID | AccountID;
export type AccountOrAgentID = AgentID | Account;
export type AccountOrAgentSecret = AgentSecret | Account;

View File

@@ -1,25 +1,17 @@
import {
Transaction,
getAgent,
getAgentID,
newRandomAgentCredential,
newRandomSessionID,
} from './coValue.js';
import { LocalNode } from './node.js';
import { createdNowUnique, sign } from './crypto.js';
import { Transaction } from "./coValue.js";
import { LocalNode } from "./node.js";
import { createdNowUnique, getAgentSignatorySecret, newRandomAgentSecret, sign } from "./crypto.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
test("Can create coValue with new agent credentials and add transaction to it", () => {
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const [account, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(account, sessionID);
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique()
...createdNowUnique(),
});
const transaction: Transaction = {
@@ -42,24 +34,21 @@ test("Can create coValue with new agent credentials and add transaction to it",
node.ownSessionID,
[transaction],
expectedNewHash,
sign(agentCredential.signatorySecret, expectedNewHash)
sign(account.currentSignatorySecret(), expectedNewHash)
)
).toBe(true);
});
test("transactions with wrong signature are rejected", () => {
const wrongAgent = newRandomAgentCredential("wrongAgent");
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const wrongAgent = newRandomAgentSecret();
const [agentSecret, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(agentSecret, sessionID);
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique()
...createdNowUnique(),
});
const transaction: Transaction = {
@@ -82,23 +71,20 @@ test("transactions with wrong signature are rejected", () => {
node.ownSessionID,
[transaction],
expectedNewHash,
sign(wrongAgent.signatorySecret, expectedNewHash)
sign(getAgentSignatorySecret(wrongAgent), expectedNewHash)
)
).toBe(false);
});
test("transactions with correctly signed, but wrong hash are rejected", () => {
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const [account, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(account, sessionID);
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique()
...createdNowUnique(),
});
const transaction: Transaction = {
@@ -131,7 +117,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
node.ownSessionID,
[transaction],
expectedNewHash,
sign(agentCredential.signatorySecret, expectedNewHash)
sign(account.currentSignatorySecret(), expectedNewHash)
)
).toBe(false);
});

View File

@@ -1,22 +1,14 @@
import { randomBytes } from "@noble/hashes/utils";
import { ContentType } from './contentType.js';
import { Static } from './contentTypes/static.js';
import { CoStream } from './contentTypes/coStream.js';
import { CoMap } from './contentTypes/coMap.js';
import { ContentType } from "./contentType.js";
import { Static } from "./contentTypes/static.js";
import { CoStream } from "./contentTypes/coStream.js";
import { CoMap } from "./contentTypes/coMap.js";
import {
Encrypted,
Hash,
KeySecret,
RecipientID,
RecipientSecret,
SignatoryID,
SignatorySecret,
Signature,
StreamingHash,
getRecipientID,
getSignatoryID,
newRandomRecipient,
newRandomSignatory,
openAs,
shortHash,
sign,
@@ -25,34 +17,37 @@ import {
decryptForTransaction,
KeyID,
unsealKeySecret,
signatorySecretToBytes,
recipientSecretToBytes,
signatorySecretFromBytes,
recipientSecretFromBytes,
} from './crypto.js';
import { JsonValue } from './jsonValue.js';
getAgentSignatoryID,
getAgentRecipientID,
} from "./crypto.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import { base58 } from "@scure/base";
import {
PermissionsDef as RulesetDef,
Team,
determineValidTransactions,
expectTeamContent,
} from './permissions.js';
import { LocalNode } from './node.js';
import { CoValueKnownState, NewContentMessage } from './sync.js';
import { AgentID, RawCoValueID, SessionID, TransactionID } from './ids.js';
import { CoList } from './contentTypes/coList.js';
} from "./permissions.js";
import { LocalNode } from "./node.js";
import { CoValueKnownState, NewContentMessage } from "./sync.js";
import { RawCoValueID, SessionID, TransactionID } from "./ids.js";
import { CoList } from "./contentTypes/coList.js";
import {
AccountID,
AccountIDOrAgentID,
GeneralizedControlledAccount,
} from "./account.js";
export type CoValueHeader = {
type: ContentType["type"];
ruleset: RulesetDef;
meta: JsonValue;
meta: JsonObject | null;
createdAt: `2${string}` | null;
uniqueness: `z${string}` | null;
publicNickname?: string;
};
function coValueIDforHeader(header: CoValueHeader): RawCoValueID {
export function coValueIDforHeader(header: CoValueHeader): RawCoValueID {
const hash = shortHash(header);
if (header.publicNickname) {
return `co_${header.publicNickname}_z${hash.slice(
@@ -63,12 +58,14 @@ function coValueIDforHeader(header: CoValueHeader): RawCoValueID {
}
}
export function agentIDfromSessionID(sessionID: SessionID): AgentID {
return sessionID.split("_session")[0] as AgentID;
export function accountOrAgentIDfromSessionID(
sessionID: SessionID
): AccountIDOrAgentID {
return sessionID.split("_session")[0] as AccountIDOrAgentID;
}
export function newRandomSessionID(agentID: AgentID): SessionID {
return `${agentID}_session_z${base58.encode(randomBytes(8))}`;
export function newRandomSessionID(accountID: AccountIDOrAgentID): SessionID {
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
}
type SessionLog = {
@@ -117,12 +114,12 @@ export class CoValue {
this.node = node;
}
testWithDifferentCredentials(
agentCredential: AgentCredential,
testWithDifferentAccount(
account: GeneralizedControlledAccount,
ownSessionID: SessionID
): CoValue {
const newNode = this.node.testWithDifferentCredentials(
agentCredential,
const newNode = this.node.testWithDifferentAccount(
account,
ownSessionID
);
@@ -160,13 +157,18 @@ export class CoValue {
newHash: Hash,
newSignature: Signature
): boolean {
const signatoryID = this.node.expectAgentLoaded(
agentIDfromSessionID(sessionID),
"Expected to know signatory of transaction"
).signatoryID;
const signatoryID = getAgentSignatoryID(
this.node.resolveAccount(
accountOrAgentIDfromSessionID(sessionID),
"Expected to know signatory of transaction"
)
);
if (!signatoryID) {
console.warn("Unknown agent", agentIDfromSessionID(sessionID));
console.warn(
"Unknown agent",
accountOrAgentIDfromSessionID(sessionID)
);
return false;
}
@@ -281,7 +283,7 @@ export class CoValue {
]);
const signature = sign(
this.node.agentCredential.signatorySecret,
this.node.account.currentSignatorySecret(),
expectedNewHash
);
@@ -407,16 +409,18 @@ export class CoValue {
for (const entry of readKeyHistory) {
if (entry.value?.keyID === keyID) {
const revealer = agentIDfromSessionID(entry.txID.sessionID);
const revealerAgent = this.node.expectAgentLoaded(
const revealer = accountOrAgentIDfromSessionID(
entry.txID.sessionID
);
const revealerAgent = this.node.resolveAccount(
revealer,
"Expected to know revealer"
);
const secret = openAs(
entry.value.revelation,
this.node.agentCredential.recipientSecret,
revealerAgent.recipientID,
this.node.account.currentRecipientSecret(),
getAgentRecipientID(revealerAgent),
{
in: this.id,
tx: entry.txID,
@@ -542,93 +546,11 @@ export class CoValue {
return this.header.ruleset.type === "team"
? expectTeamContent(this.getCurrentContent())
.keys()
.filter((k): k is AgentID => k.startsWith("co_agent"))
.filter((k): k is AccountID => k.startsWith("co_"))
: this.header.ruleset.type === "ownedByTeam"
? [this.header.ruleset.team]
: [];
}
}
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,
};
}
export function getAgentCoValueHeader(agent: Agent): CoValueHeader {
return {
type: "comap",
ruleset: {
type: "agent",
initialSignatoryID: agent.signatoryID,
initialRecipientID: agent.recipientID,
},
meta: null,
createdAt: null,
uniqueness: null,
publicNickname:
"agent" + (agent.publicNickname ? `-${agent.publicNickname}` : ""),
};
}
export function getAgentID(agent: Agent): AgentID {
return coValueIDforHeader(getAgentCoValueHeader(agent)) as AgentID;
}
export type AgentCredential = {
signatorySecret: SignatorySecret;
recipientSecret: RecipientSecret;
publicNickname?: string;
};
export function newRandomAgentCredential(
publicNickname?: string
): AgentCredential {
const signatorySecret = newRandomSignatory();
const recipientSecret = newRandomRecipient();
return { signatorySecret, recipientSecret, publicNickname };
}
export function agentCredentialToBytes(cred: AgentCredential): Uint8Array {
if (cred.publicNickname) {
throw new Error("Can't convert agent credential with publicNickname");
}
const bytes = new Uint8Array(64);
const signatorySecretBytes = signatorySecretToBytes(cred.signatorySecret);
if (signatorySecretBytes.length !== 32) {
throw new Error("Invalid signatorySecret length");
}
bytes.set(signatorySecretBytes);
const recipientSecretBytes = recipientSecretToBytes(cred.recipientSecret);
if (recipientSecretBytes.length !== 32) {
throw new Error("Invalid recipientSecret length");
}
bytes.set(recipientSecretBytes, 32);
return bytes;
}
export function agentCredentialFromBytes(
bytes: Uint8Array
): AgentCredential | undefined {
if (bytes.length !== 64) {
return undefined;
}
const signatorySecret = signatorySecretFromBytes(bytes.slice(0, 32));
const recipientSecret = recipientSecretFromBytes(bytes.slice(32));
return { signatorySecret, recipientSecret };
}
// type Role = "admin" | "writer" | "reader";
// type PermissionsDef = CJMap<AgentID, Role, {[agent: AgentID]: Role}>;
export { SessionID };

View File

@@ -1,25 +1,16 @@
import {
agentIDfromSessionID,
getAgent,
getAgentID,
newRandomAgentCredential,
newRandomSessionID,
} from './coValue.js';
import { accountOrAgentIDfromSessionID } from "./coValue.js";
import { createdNowUnique } from "./crypto.js";
import { LocalNode } from "./node.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
test("Empty COJSON Map works", () => {
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique()
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
@@ -34,17 +25,13 @@ test("Empty COJSON Map works", () => {
});
test("Can insert and delete Map entries in edit()", () => {
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique()
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
@@ -67,17 +54,13 @@ test("Can insert and delete Map entries in edit()", () => {
});
test("Can get map entry values at different points in time", () => {
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique()
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
@@ -90,13 +73,13 @@ test("Can get map entry values at different points in time", () => {
content.edit((editable) => {
const beforeA = Date.now();
while(Date.now() < beforeA + 10){}
while (Date.now() < beforeA + 10) {}
editable.set("hello", "A", "trusting");
const beforeB = Date.now();
while(Date.now() < beforeB + 10){}
while (Date.now() < beforeB + 10) {}
editable.set("hello", "B", "trusting");
const beforeC = Date.now();
while(Date.now() < beforeC + 10){}
while (Date.now() < beforeC + 10) {}
editable.set("hello", "C", "trusting");
expect(editable.get("hello")).toEqual("C");
expect(editable.getAtTime("hello", Date.now())).toEqual("C");
@@ -107,17 +90,13 @@ test("Can get map entry values at different points in time", () => {
});
test("Can get all historic values of key", () => {
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique()
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
@@ -137,9 +116,7 @@ test("Can get all historic values of key", () => {
const txDel = editable.getLastTxID("hello");
editable.set("hello", "C", "trusting");
const txC = editable.getLastTxID("hello");
expect(
editable.getHistory("hello")
).toEqual([
expect(editable.getHistory("hello")).toEqual([
{
txID: txA,
value: "A",
@@ -165,17 +142,13 @@ test("Can get all historic values of key", () => {
});
test("Can get last tx ID for a key", () => {
const agentCredential = newRandomAgentCredential("agent1");
const node = new LocalNode(
agentCredential,
newRandomSessionID(getAgentID(getAgent(agentCredential)))
);
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique()
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
@@ -190,8 +163,8 @@ test("Can get last tx ID for a key", () => {
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(sessionID && accountOrAgentIDfromSessionID(sessionID)).toEqual(
node.account.id
);
expect(editable.getLastTxID("hello")?.txIndex).toEqual(0);
editable.set("hello", "B", "trusting");

View File

@@ -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 { RawCoValueID, TransactionID } from './ids.js';
import { AgentID, RawCoValueID, TransactionID } from './ids.js';
export type SignatorySecret = `signatorySecret_z${string}`;
export type SignatoryID = `signatory_z${string}`;
@@ -15,6 +15,8 @@ export type RecipientSecret = `recipientSecret_z${string}`;
export type RecipientID = `recipient_z${string}`;
export type Sealed<T> = `sealed_U${string}` & { __type: T };
export type AgentSecret = `${RecipientSecret}/${SignatorySecret}`;
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
@@ -80,6 +82,51 @@ export function getRecipientID(secret: RecipientSecret): RecipientID {
)}`;
}
export function newRandomAgentSecret(): AgentSecret {
return `${newRandomRecipient()}/${newRandomSignatory()}`;
}
export function agentSecretToBytes(secret: AgentSecret): Uint8Array {
const [recipientSecret, signatorySecret] = secret.split("/");
return new Uint8Array([
...recipientSecretToBytes(recipientSecret as RecipientSecret),
...signatorySecretToBytes(signatorySecret as SignatorySecret),
]);
}
export function agentSecretFromBytes(bytes: Uint8Array): AgentSecret {
const recipientSecret = recipientSecretFromBytes(
bytes.slice(0, 32)
);
const signatorySecret = signatorySecretFromBytes(
bytes.slice(32)
);
return `${recipientSecret}/${signatorySecret}`;
}
export function getAgentID(secret: AgentSecret): AgentID {
const [recipientSecret, signatorySecret] = secret.split("/");
return `${getRecipientID(
recipientSecret as RecipientSecret
)}/${getSignatoryID(signatorySecret as SignatorySecret)}`;
}
export function getAgentSignatoryID(agentId: AgentID): SignatoryID {
return agentId.split("/")[1] as SignatoryID;
}
export function getAgentSignatorySecret(agentSecret: AgentSecret): SignatorySecret {
return agentSecret.split("/")[1] as SignatorySecret;
}
export function getAgentRecipientID(agentId: AgentID): RecipientID {
return agentId.split("/")[0] as RecipientID;
}
export function getAgentRecipientSecret(agentSecret: AgentSecret): RecipientSecret {
return agentSecret.split("/")[0] as RecipientSecret;
}
export type SealedSet<T> = {
[recipient: RecipientID]: Sealed<T>;
};

View File

@@ -1,7 +1,13 @@
import { AccountIDOrAgentID } from './account.js';
export type RawCoValueID = `co_z${string}` | `co_${string}_z${string}`;
export type TransactionID = { sessionID: SessionID; txIndex: number };
export type AgentID = `co_agent${string}_z${string}`;
export type AgentID = `recipient_z${string}/signatory_z${string}`;
export type SessionID = `${AgentID}_session_z${string}`;
export function isAgentID(id: string): id is AgentID {
return typeof id === "string" && id.startsWith("recipient_") && id.includes("/signatory_");
}
export type SessionID = `${AccountIDOrAgentID}_session_z${string}`;

View File

@@ -1,29 +1,19 @@
import {
CoValue,
agentCredentialFromBytes,
agentCredentialToBytes,
getAgent,
getAgentID,
newRandomAgentCredential,
newRandomSessionID,
} from './coValue.js';
import { LocalNode } from './node.js';
import { CoMap } from './contentTypes/coMap.js';
import { CoValue, newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { CoMap } from "./contentTypes/coMap.js";
import { agentSecretFromBytes, agentSecretToBytes } from "./crypto.js";
import type { AgentCredential } from './coValue.js';
import type { AgentID, SessionID } from './ids.js';
import type { CoValueID, ContentType } from './contentType.js';
import type { JsonValue } from './jsonValue.js';
import type { SyncMessage } from './sync.js';
import type { SessionID } from "./ids.js";
import type { CoValueID, ContentType } from "./contentType.js";
import type { JsonValue } from "./jsonValue.js";
import type { SyncMessage } from "./sync.js";
import type { AgentSecret } from "./crypto.js";
type Value = JsonValue | ContentType;
const internals = {
agentCredentialToBytes,
agentCredentialFromBytes,
getAgent,
getAgentID,
newRandomAgentCredential,
agentSecretFromBytes,
agentSecretToBytes,
newRandomSessionID,
};
@@ -34,8 +24,7 @@ export type {
JsonValue,
ContentType,
CoValueID,
AgentCredential,
AgentSecret,
SessionID,
AgentID,
SyncMessage
SyncMessage,
};

View File

@@ -1,38 +1,40 @@
import { createdNowUnique, newRandomKeySecret, seal } from './crypto.js';
import {
CoValue,
AgentCredential,
Agent,
getAgent,
createdNowUnique,
getAgentID,
getAgentCoValueHeader,
CoValueHeader,
newRandomAgentCredential,
} from './coValue.js';
import { Team, expectTeamContent } from './permissions.js';
import { SyncManager } from './sync.js';
import { AgentID, RawCoValueID, SessionID } from './ids.js';
import { CoValueID, ContentType } from './contentType.js';
getAgentRecipientID,
getAgentRecipientSecret,
newRandomAgentSecret,
newRandomKeySecret,
seal,
} from "./crypto.js";
import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js";
import { Team, TeamContent, expectTeamContent } from "./permissions.js";
import { SyncManager } from "./sync.js";
import { AgentID, RawCoValueID, SessionID, isAgentID } from "./ids.js";
import { CoValueID, ContentType } from "./contentType.js";
import {
Account,
AccountMeta,
AccountIDOrAgentID,
accountHeaderForInitialAgentSecret,
GeneralizedControlledAccount,
ControlledAccount,
AnonymousControlledAccount,
} from "./account.js";
import { CoMap } from "./index.js";
export class LocalNode {
coValues: { [key: RawCoValueID]: CoValueState } = {};
agentCredential: AgentCredential;
agentID: AgentID;
account: GeneralizedControlledAccount;
ownSessionID: SessionID;
sync = new SyncManager(this);
constructor(agentCredential: AgentCredential, ownSessionID: SessionID) {
this.agentCredential = agentCredential;
const agent = getAgent(agentCredential);
const agentID = getAgentID(agent);
this.agentID = agentID;
constructor(
account: GeneralizedControlledAccount,
ownSessionID: SessionID
) {
this.account = account;
this.ownSessionID = ownSessionID;
const agentCoValue = new CoValue(getAgentCoValueHeader(agent), this);
this.coValues[agentCoValue.id] = {
state: "loaded",
coValue: agentCoValue,
};
}
createCoValue(header: CoValueHeader): CoValue {
@@ -80,39 +82,72 @@ export class LocalNode {
return entry.coValue;
}
createAgent(publicNickname: string): AgentCredential {
const agentCredential = newRandomAgentCredential(publicNickname);
createAccount(_publicNickname: string): ControlledAccount {
const agentSecret = newRandomAgentSecret();
this.createCoValue(getAgentCoValueHeader(getAgent(agentCredential)));
const account = this.createCoValue(
accountHeaderForInitialAgentSecret(agentSecret)
).testWithDifferentAccount(new AnonymousControlledAccount(agentSecret), newRandomSessionID(getAgentID(agentSecret)));
return agentCredential;
expectTeamContent(account.getCurrentContent()).edit((editable) => {
editable.set(getAgentID(agentSecret), "admin", "trusting");
const readKey = newRandomKeySecret();
const revelation = seal(
readKey.secret,
getAgentRecipientSecret(agentSecret),
new Set([getAgentRecipientID(getAgentID(agentSecret))]),
{
in: account.id,
tx: account.nextTransactionID(),
}
);
editable.set(
"readKey",
{ keyID: readKey.id, revelation },
"trusting"
);
});
return new ControlledAccount(
agentSecret,
account.getCurrentContent() as CoMap<TeamContent, AccountMeta>,
this
);
}
expectAgentLoaded(id: AgentID, expectation?: string): Agent {
const coValue = this.expectCoValueLoaded(
id,
expectation
);
resolveAccount(id: AccountIDOrAgentID, expectation?: string): AgentID {
if (isAgentID(id)) {
return id;
}
if (coValue.header.type !== "comap" || coValue.header.ruleset.type !== "agent") {
const coValue = this.expectCoValueLoaded(id, expectation);
if (
coValue.header.type !== "comap" ||
coValue.header.ruleset.type !== "team" ||
!coValue.header.meta ||
!("type" in coValue.header.meta) ||
coValue.header.meta.type !== "account"
) {
throw new Error(
`${
expectation ? expectation + ": " : ""
}CoValue ${id} is not an agent`
}CoValue ${id} is not an account`
);
}
return {
recipientID: coValue.header.ruleset.initialRecipientID,
signatoryID: coValue.header.ruleset.initialSignatoryID,
publicNickname: coValue.header.publicNickname?.replace("agent-", ""),
}
return new Account(
coValue.getCurrentContent() as CoMap<TeamContent, AccountMeta>,
this
).getCurrentAgentID();
}
createTeam(): Team {
const teamCoValue = this.createCoValue({
type: "comap",
ruleset: { type: "team", initialAdmin: this.agentID },
ruleset: { type: "team", initialAdmin: this.account.id },
meta: null,
...createdNowUnique(),
publicNickname: "team",
@@ -121,13 +156,13 @@ export class LocalNode {
let teamContent = expectTeamContent(teamCoValue.getCurrentContent());
teamContent = teamContent.edit((editable) => {
editable.set(this.agentID, "admin", "trusting");
editable.set(this.account.id, "admin", "trusting");
const readKey = newRandomKeySecret();
const revelation = seal(
readKey.secret,
this.agentCredential.recipientSecret,
new Set([getAgent(this.agentCredential).recipientID]),
this.account.currentRecipientSecret(),
new Set([this.account.currentRecipientID()]),
{
in: teamCoValue.id,
tx: teamCoValue.nextTransactionID(),
@@ -144,11 +179,11 @@ export class LocalNode {
return new Team(teamContent, this);
}
testWithDifferentCredentials(
agentCredential: AgentCredential,
testWithDifferentAccount(
account: GeneralizedControlledAccount,
ownSessionID: SessionID
): LocalNode {
const newNode = new LocalNode(agentCredential, ownSessionID);
const newNode = new LocalNode(account, ownSessionID);
newNode.coValues = Object.fromEntries(
Object.entries(this.coValues)

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,30 @@
import { ContentType } from './contentType.js';
import { CoValueID, ContentType } from './contentType.js';
import { CoMap, MapOpPayload } from './contentTypes/coMap.js';
import { JsonValue } from './jsonValue.js';
import {
Encrypted,
KeyID,
KeySecret,
RecipientID,
SealedSet,
SignatoryID,
createdNowUnique,
newRandomKeySecret,
seal,
sealKeySecret,
getAgentRecipientID
} from './crypto.js';
import {
AgentCredential,
CoValue,
Transaction,
TrustingTransaction,
agentIDfromSessionID,
accountOrAgentIDfromSessionID,
} from './coValue.js';
import { LocalNode } from "./node.js";
import { AgentID, RawCoValueID, SessionID, TransactionID } from './ids.js';
import { RawCoValueID, SessionID, TransactionID, isAgentID } from './ids.js';
import { AccountIDOrAgentID, GeneralizedControlledAccount } from './account.js';
export type PermissionsDef =
| { type: "team"; initialAdmin: AgentID; parentTeams?: RawCoValueID[] }
| { type: "team"; initialAdmin: AccountIDOrAgentID; }
| { type: "ownedByTeam"; team: RawCoValueID }
| {
type: "agent";
initialSignatoryID: SignatoryID;
initialRecipientID: RecipientID;
}
| { type: "unsafeAllowAll" };
export type Role = "reader" | "writer" | "admin" | "revoked";
@@ -68,7 +62,7 @@ export function determineValidTransactions(
throw new Error("Team must have initialAdmin");
}
const memberState: { [agent: AgentID]: Role } = {};
const memberState: { [agent: AccountIDOrAgentID]: Role } = {};
const validTransactions: { txID: TransactionID; tx: Transaction }[] =
[];
@@ -79,10 +73,10 @@ export function determineValidTransactions(
tx,
} of allTrustingTransactionsSorted) {
// console.log("before", { memberState, validTransactions });
const transactor = agentIDfromSessionID(sessionID);
const transactor = accountOrAgentIDfromSessionID(sessionID);
const change = tx.changes[0] as
| MapOpPayload<AgentID, Role>
| MapOpPayload<AccountIDOrAgentID, Role>
| MapOpPayload<"readKey", JsonValue>;
if (tx.changes.length !== 1) {
console.warn("Team transaction must have exactly one change");
@@ -164,7 +158,7 @@ export function determineValidTransactions(
return Object.entries(coValue.sessions).flatMap(
([sessionID, sessionLog]) => {
const transactor = agentIDfromSessionID(sessionID as SessionID);
const transactor = accountOrAgentIDfromSessionID(sessionID as SessionID);
return sessionLog.transactions
.filter((tx) => {
const transactorRoleAtTxTime = teamContent.getAtTime(
@@ -192,15 +186,12 @@ export function determineValidTransactions(
}));
}
);
} else if (coValue.header.ruleset.type === "agent") {
// TODO
return [];
} else {
throw new Error("Unknown ruleset type " + (coValue.header.ruleset as any).type);
}
}
export type TeamContent = { [key: AgentID]: Role } & {
export type TeamContent = { [key: AccountIDOrAgentID]: Role } & {
readKey: {
keyID: KeyID;
revelation: SealedSet<KeySecret>;
@@ -230,20 +221,20 @@ export class Team {
this.node = node;
}
get id(): RawCoValueID {
get id(): CoValueID<CoMap<TeamContent, {}>> {
return this.teamMap.id;
}
addMember(agentID: AgentID, role: Role) {
addMember(accountID: AccountIDOrAgentID, role: Role) {
this.teamMap = this.teamMap.edit((map) => {
const agent = this.node.expectAgentLoaded(agentID, "Expected to know agent to add them to team");
const agent = this.node.resolveAccount(accountID, "Expected to know agent to add them to team");
if (!agent) {
throw new Error("Unknown agent " + agentID);
throw new Error("Unknown account/agent " + accountID);
}
map.set(agentID, role, "trusting");
if (map.get(agentID) !== role) {
map.set(accountID, role, "trusting");
if (map.get(accountID) !== role) {
throw new Error("Failed to set role");
}
@@ -255,8 +246,8 @@ export class Team {
const revelation = seal(
currentReadKey.secret,
this.teamMap.coValue.node.agentCredential.recipientSecret,
new Set([agent.recipientID]),
this.teamMap.coValue.node.account.currentRecipientSecret(),
new Set([getAgentRecipientID(agent)]),
{
in: this.teamMap.coValue.id,
tx: this.teamMap.coValue.nextTransactionID(),
@@ -273,7 +264,7 @@ export class Team {
rotateReadKey() {
const currentlyPermittedReaders = this.teamMap.keys().filter((key) => {
if (key.startsWith("co_agent")) {
if (key.startsWith("co_") || isAgentID(key)) {
const role = this.teamMap.get(key);
return (
role === "admin" || role === "writer" || role === "reader"
@@ -281,7 +272,7 @@ export class Team {
} else {
return false;
}
}) as AgentID[];
}) as AccountIDOrAgentID[];
const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey();
@@ -298,15 +289,15 @@ export class Team {
const newReadKeyRevelation = seal(
newReadKey.secret,
this.teamMap.coValue.node.agentCredential.recipientSecret,
this.teamMap.coValue.node.account.currentRecipientSecret(),
new Set(
currentlyPermittedReaders.map(
(reader) => {
const readerAgent = this.node.expectAgentLoaded(reader, "Expected to know currently permitted reader");
const readerAgent = this.node.resolveAccount(reader, "Expected to know currently permitted reader");
if (!readerAgent) {
throw new Error("Unknown agent " + reader);
}
return readerAgent.recipientID
return getAgentRecipientID(readerAgent)
}
)
),
@@ -334,9 +325,9 @@ export class Team {
});
}
removeMember(agentID: AgentID) {
removeMember(accountID: AccountIDOrAgentID) {
this.teamMap = this.teamMap.edit((map) => {
map.set(agentID, "revoked", "trusting");
map.set(accountID, "revoked", "trusting");
});
this.rotateReadKey();
@@ -359,14 +350,14 @@ export class Team {
.getCurrentContent() as CoMap<M, Meta>;
}
testWithDifferentCredentials(
credential: AgentCredential,
testWithDifferentAccount(
account: GeneralizedControlledAccount,
sessionId: SessionID
): Team {
return new Team(
expectTeamContent(
this.teamMap.coValue
.testWithDifferentCredentials(credential, sessionId)
.testWithDifferentAccount(account, sessionId)
.getCurrentContent()
),
this.node

View File

@@ -1,115 +1,103 @@
import {
getAgent,
getAgentID,
newRandomAgentCredential,
newRandomSessionID,
} from './coValue.js';
import { LocalNode } from './node.js';
import { Peer, PeerID, SyncMessage } from './sync.js';
import { expectMap } from './contentType.js';
import { MapOpPayload } from './contentTypes/coMap.js';
import { Team } from './permissions.js';
import { newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { Peer, PeerID, SyncMessage } from "./sync.js";
import { expectMap } from "./contentType.js";
import { MapOpPayload } from "./contentTypes/coMap.js";
import { Team } from "./permissions.js";
import {
ReadableStream,
WritableStream,
TransformStream,
} from "isomorphic-streams";
import { AgentID } from './ids.js';
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
import { AccountID } from "./account.js";
test(
"Node replies with initial tx and header to empty subscribe",
async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
test("Node replies with initial tx and header to empty subscribe", async () => {
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const node = new LocalNode(admin, newRandomSessionID(adminID));
const team = node.createTeam();
const team = node.createTeam();
const map = team.createMap();
const map = team.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
});
map.edit((editable) => {
editable.set("hello", "world", "trusting");
});
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
node.sync.addPeer({
id: "test",
incoming: inRx,
outgoing: outTx,
role: "peer",
});
node.sync.addPeer({
id: "test",
incoming: inRx,
outgoing: outTx,
role: "peer",
});
const writer = inTx.getWriter();
const writer = inTx.getWriter();
await writer.write({
action: "subscribe",
coValueID: map.coValue.id,
header: false,
sessions: {},
});
await writer.write({
action: "subscribe",
coValueID: map.coValue.id,
header: false,
sessions: {},
});
const reader = outRx.getReader();
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
expect((await reader.read()).value).toMatchObject(admStateEx(adminID));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
action: "tellKnownState",
...map.coValue.knownState(),
} satisfies SyncMessage);
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
action: "tellKnownState",
...map.coValue.knownState(),
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
expect((await reader.read()).value).toMatchObject(admContEx(adminID));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const newContentMsg = await reader.read();
const newContentMsg = await reader.read();
expect(newContentMsg.value).toEqual({
action: "newContent",
coValueID: map.coValue.id,
header: {
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
createdAt: map.coValue.header.createdAt,
uniqueness: map.coValue.header.uniqueness,
publicNickname: "map",
expect(newContentMsg.value).toEqual({
action: "newContent",
coValueID: map.coValue.id,
header: {
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
createdAt: map.coValue.header.createdAt,
uniqueness: map.coValue.header.uniqueness,
publicNickname: "map",
},
newContent: {
[node.ownSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[0]!.madeAt,
changes: [
{
op: "insert",
key: "hello",
value: "world",
} satisfies MapOpPayload<string, string>,
],
},
],
lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!,
lastSignature:
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
},
newContent: {
[node.ownSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting",
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[0]!.madeAt,
changes: [
{
op: "insert",
key: "hello",
value: "world",
} satisfies MapOpPayload<string, string>,
],
},
],
lastHash:
map.coValue.sessions[node.ownSessionID]!.lastHash!,
lastSignature:
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
},
);
},
} satisfies SyncMessage);
});
test("Node replies with only new tx to subscribe with some known state", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
@@ -143,7 +131,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
const reader = outRx.getReader();
expect((await reader.read()).value).toMatchObject(admStateEx(adminID));
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownStateMsg = await reader.read();
@@ -152,7 +140,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
...map.coValue.knownState(),
} satisfies SyncMessage);
expect((await reader.read()).value).toMatchObject(admContEx(adminID));
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapNewContentMsg = await reader.read();
@@ -166,7 +154,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
after: 1,
newTransactions: [
{
privacy: "trusting",
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[1]!.madeAt,
changes: [
@@ -187,14 +175,12 @@ test("Node replies with only new tx to subscribe with some known state", async (
});
test.todo(
"TODO: node only replies with new tx to subscribe with some known state, even in the depended on coValues",
"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("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
@@ -223,7 +209,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
const reader = outRx.getReader();
expect((await reader.read()).value).toMatchObject(admStateEx(adminID));
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownStateMsg = await reader.read();
@@ -232,7 +218,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
...map.coValue.knownState(),
} satisfies SyncMessage);
expect((await reader.read()).value).toMatchObject(admContEx(adminID));
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapNewContentHeaderOnlyMsg = await reader.read();
@@ -258,7 +244,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
after: 0,
newTransactions: [
{
privacy: "trusting",
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[0]!.madeAt,
changes: [
@@ -291,7 +277,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
after: 1,
newTransactions: [
{
privacy: "trusting",
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[1]!.madeAt,
changes: [
@@ -312,10 +298,8 @@ test("After subscribing, node sends own known state and new txs to peer", async
});
test("Client replies with known new content to tellKnownState from server", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
@@ -350,7 +334,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
},
});
expect((await reader.read()).value).toMatchObject(admStateEx(adminID));
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownStateMsg = await reader.read();
@@ -359,7 +343,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
...map.coValue.knownState(),
} satisfies SyncMessage);
expect((await reader.read()).value).toMatchObject(admContEx(adminID));
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapNewContentMsg = await reader.read();
@@ -373,7 +357,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
after: 0,
newTransactions: [
{
privacy: "trusting",
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[0]!.madeAt,
changes: [
@@ -394,10 +378,8 @@ test("Client replies with known new content to tellKnownState from server", asyn
});
test("No matter the optimistic known state, node respects invalid known state messages and resyncs", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
@@ -426,7 +408,7 @@ test("No matter the optimistic known state, node respects invalid known state me
const reader = outRx.getReader();
expect((await reader.read()).value).toMatchObject(admStateEx(adminID));
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownStateMsg = await reader.read();
@@ -435,7 +417,7 @@ test("No matter the optimistic known state, node respects invalid known state me
...map.coValue.knownState(),
} satisfies SyncMessage);
expect((await reader.read()).value).toMatchObject(admContEx(adminID));
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapNewContentHeaderOnlyMsg = await reader.read();
@@ -478,7 +460,7 @@ test("No matter the optimistic known state, node respects invalid known state me
after: 1,
newTransactions: [
{
privacy: "trusting",
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[1]!.madeAt,
changes: [
@@ -499,10 +481,8 @@ test("No matter the optimistic known state, node respects invalid known state me
});
test("If we add a peer, but it never subscribes to a coValue, it won't get any messages", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
@@ -524,14 +504,14 @@ test("If we add a peer, but it never subscribes to a coValue, it won't get any m
const reader = outRx.getReader();
await expect(shouldNotResolve(reader.read(), {timeout: 100})).resolves.toBeUndefined();
await expect(
shouldNotResolve(reader.read(), { timeout: 100 })
).resolves.toBeUndefined();
});
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("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
@@ -548,10 +528,10 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
});
const reader = outRx.getReader();
expect((await reader.read()).value).toMatchObject({
action: "subscribe",
coValueID: adminID,
});
// expect((await reader.read()).value).toMatchObject({
// action: "subscribe",
// coValueID: adminID,
// });
expect((await reader.read()).value).toMatchObject({
action: "subscribe",
coValueID: team.teamMap.coValue.id,
@@ -570,7 +550,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
editable.set("hello", "world", "trusting");
});
expect((await reader.read()).value).toMatchObject(admContEx(adminID));
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapNewContentMsg = await reader.read();
@@ -584,7 +564,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
after: 0,
newTransactions: [
{
privacy: "trusting",
privacy: "trusting" as const,
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[0]!.madeAt,
changes: [
@@ -605,10 +585,8 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
});
test("If we add a server peer, newly created coValues are auto-subscribed to", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
@@ -623,10 +601,10 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
});
const reader = outRx.getReader();
expect((await reader.read()).value).toMatchObject({
action: "subscribe",
coValueID: adminID,
});
// expect((await reader.read()).value).toMatchObject({
// action: "subscribe",
// coValueID: admin.id,
// });
expect((await reader.read()).value).toMatchObject({
action: "subscribe",
coValueID: team.teamMap.coValue.id,
@@ -641,7 +619,7 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
...map.coValue.knownState(),
} satisfies SyncMessage);
expect((await reader.read()).value).toMatchObject(admContEx(adminID));
// expect((await reader.read()).value).toMatchObject(admContEx(adminID));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapContentMsg = await reader.read();
@@ -655,14 +633,12 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
});
test.todo(
"TODO: when receiving a subscribe response that is behind our optimistic state (due to already sent content), we ignore it",
"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 coValues to it", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
@@ -680,7 +656,7 @@ test("When we connect a new server peer, we try to sync all existing coValues to
const reader = outRx.getReader();
const _adminSubscribeMessage = await reader.read();
// const _adminSubscribeMessage = await reader.read();
const teamSubscribeMessage = await reader.read();
expect(teamSubscribeMessage.value).toEqual({
@@ -697,10 +673,8 @@ test("When we connect a new server peer, we try to sync all existing coValues to
});
test("When receiving a subscribe with a known state that is ahead of our own, peers should respond with a corresponding subscribe response message", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
@@ -729,7 +703,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
const reader = outRx.getReader();
expect((await reader.read()).value).toMatchObject(admStateEx(adminID));
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownState = await reader.read();
@@ -741,10 +715,9 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
test.skip("When replaying creation and transactions of a coValue as new content, the receiving peer integrates this information", async () => {
// TODO: this test is mostly correct but also slightly unrealistic, make sure we pass all messages back and forth as expected and then it should work
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node1 = new LocalNode(admin, newRandomSessionID(adminID));
const node1 = new LocalNode(admin, session);
const team = node1.createTeam();
@@ -761,7 +734,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
const to1 = inTx1.getWriter();
const from1 = outRx1.getReader();
const node2 = new LocalNode(admin, newRandomSessionID(adminID));
const node2 = new LocalNode(admin, newRandomSessionID(admin.id));
const [inRx2, inTx2] = newStreamPair<SyncMessage>();
const [outRx2, outTx2] = newStreamPair<SyncMessage>();
@@ -779,7 +752,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
const adminSubscribeMessage = await from1.read();
expect(adminSubscribeMessage.value).toMatchObject({
action: "subscribe",
coValueID: adminID,
coValueID: admin.id,
});
const teamSubscribeMsg = await from1.read();
expect(teamSubscribeMsg.value).toMatchObject({
@@ -790,8 +763,8 @@ test.skip("When replaying creation and transactions of a coValue as new content,
await to2.write(adminSubscribeMessage.value!);
await to2.write(teamSubscribeMsg.value!);
const adminTellKnownStateMsg = await from2.read();
expect(adminTellKnownStateMsg.value).toMatchObject(admStateEx(adminID));
// const adminTellKnownStateMsg = await from2.read();
// expect(adminTellKnownStateMsg.value).toMatchObject(admStateEx(admin.id));
const teamTellKnownStateMsg = await from2.read();
expect(teamTellKnownStateMsg.value).toMatchObject(teamStateEx(team));
@@ -802,16 +775,16 @@ test.skip("When replaying creation and transactions of a coValue as new content,
]
).toBeDefined();
await to1.write(adminTellKnownStateMsg.value!);
// await to1.write(adminTellKnownStateMsg.value!);
await to1.write(teamTellKnownStateMsg.value!);
const adminContentMsg = await from1.read();
expect(adminContentMsg.value).toMatchObject(admContEx(adminID));
// const adminContentMsg = await from1.read();
// expect(adminContentMsg.value).toMatchObject(admContEx(admin.id));
const teamContentMsg = await from1.read();
expect(teamContentMsg.value).toMatchObject(teamContentEx(team));
await to2.write(adminContentMsg.value!);
// await to2.write(adminContentMsg.value!);
await to2.write(teamContentMsg.value!);
const map = team.createMap();
@@ -863,10 +836,9 @@ test.skip("When replaying creation and transactions of a coValue as new content,
test.skip("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 () => {
// TODO: this test is mostly correct but also slightly unrealistic, make sure we pass all messages back and forth as expected and then it should work
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node1 = new LocalNode(admin, newRandomSessionID(adminID));
const node1 = new LocalNode(admin, session);
const team = node1.createTeam();
@@ -875,7 +847,7 @@ test.skip("When loading a coValue on one node, the server node it is requested f
editable.set("hello", "world", "trusting");
});
const node2 = new LocalNode(admin, newRandomSessionID(adminID));
const node2 = new LocalNode(admin, newRandomSessionID(admin.id));
const [node1asPeer, node2asPeer] = connectedPeers("peer1", "peer2");
@@ -892,10 +864,9 @@ test.skip("When loading a coValue on one node, the server node it is requested f
});
test("Can sync a coValue through a server to another client", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const [admin, session] = randomAnonymousAccountAndSessionID();
const client1 = new LocalNode(admin, newRandomSessionID(adminID));
const client1 = new LocalNode(admin, session);
const team = client1.createTeam();
@@ -904,10 +875,9 @@ test("Can sync a coValue through a server to another client", async () => {
editable.set("hello", "world", "trusting");
});
const serverUser = newRandomAgentCredential("serverUser");
const serverUserID = getAgentID(getAgent(serverUser));
const [serverUser, serverSession] = randomAnonymousAccountAndSessionID();
const server = new LocalNode(serverUser, newRandomSessionID(serverUserID));
const server = new LocalNode(serverUser, serverSession);
const [serverAsPeer, client1AsPeer] = connectedPeers("server", "client1", {
peer1role: "server",
@@ -917,7 +887,7 @@ test("Can sync a coValue through a server to another client", async () => {
client1.sync.addPeer(serverAsPeer);
server.sync.addPeer(client1AsPeer);
const client2 = new LocalNode(admin, newRandomSessionID(adminID));
const client2 = new LocalNode(admin, newRandomSessionID(admin.id));
const [serverAsOtherPeer, client2AsPeer] = connectedPeers(
"server",
@@ -936,10 +906,9 @@ test("Can sync a coValue through a server to another client", async () => {
});
test("Can sync a coValue with private transactions through a server to another client", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const [admin, session] = randomAnonymousAccountAndSessionID();
const client1 = new LocalNode(admin, newRandomSessionID(adminID));
const client1 = new LocalNode(admin, session);
const team = client1.createTeam();
@@ -948,10 +917,9 @@ test("Can sync a coValue with private transactions through a server to another c
editable.set("hello", "world", "private");
});
const serverUser = newRandomAgentCredential("serverUser");
const serverUserID = getAgentID(getAgent(serverUser));
const [serverUser, serverSession] = randomAnonymousAccountAndSessionID();
const server = new LocalNode(serverUser, newRandomSessionID(serverUserID));
const server = new LocalNode(serverUser, serverSession);
const [serverAsPeer, client1AsPeer] = connectedPeers("server", "client1", {
trace: true,
@@ -962,7 +930,7 @@ test("Can sync a coValue with private transactions through a server to another c
client1.sync.addPeer(serverAsPeer);
server.sync.addPeer(client1AsPeer);
const client2 = new LocalNode(admin, newRandomSessionID(adminID));
const client2 = new LocalNode(admin, newRandomSessionID(admin.id));
const [serverAsOtherPeer, client2AsPeer] = connectedPeers(
"server",
@@ -981,10 +949,8 @@ test("Can sync a coValue with private transactions through a server to another c
});
test("When a peer's incoming/readable stream closes, we remove the peer", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
@@ -999,10 +965,10 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
});
const reader = outRx.getReader();
expect((await reader.read()).value).toMatchObject({
action: "subscribe",
coValueID: adminID,
});
// expect((await reader.read()).value).toMatchObject({
// action: "subscribe",
// coValueID: admin.id,
// });
expect((await reader.read()).value).toMatchObject({
action: "subscribe",
coValueID: team.teamMap.coValue.id,
@@ -1017,7 +983,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
...map.coValue.knownState(),
} satisfies SyncMessage);
expect((await reader.read()).value).toMatchObject(admContEx(adminID));
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapContentMsg = await reader.read();
@@ -1037,10 +1003,8 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
});
test("When a peer's outgoing/writable stream closes, we remove the peer", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const node = new LocalNode(admin, newRandomSessionID(adminID));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
@@ -1055,10 +1019,10 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
});
const reader = outRx.getReader();
expect((await reader.read()).value).toMatchObject({
action: "subscribe",
coValueID: adminID,
});
// expect((await reader.read()).value).toMatchObject({
// action: "subscribe",
// coValueID: admin.id,
// });
expect((await reader.read()).value).toMatchObject({
action: "subscribe",
coValueID: team.teamMap.coValue.id,
@@ -1073,7 +1037,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
...map.coValue.knownState(),
} satisfies SyncMessage);
expect((await reader.read()).value).toMatchObject(admContEx(adminID));
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapContentMsg = await reader.read();
@@ -1095,13 +1059,12 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
await new Promise((resolve) => setTimeout(resolve, 100));
expect(node.sync.peers["test"]).toBeUndefined();
})
});
test("If we start loading a coValue before connecting to a peer that has it, it will load it once we connect", async () => {
const admin = newRandomAgentCredential("admin");
const adminID = getAgentID(getAgent(admin));
const [admin, session] = randomAnonymousAccountAndSessionID();
const node1 = new LocalNode(admin, newRandomSessionID(adminID));
const node1 = new LocalNode(admin, session);
const team = node1.createTeam();
@@ -1110,9 +1073,13 @@ test("If we start loading a coValue before connecting to a peer that has it, it
editable.set("hello", "world", "trusting");
});
const node2 = new LocalNode(admin, newRandomSessionID(adminID));
const node2 = new LocalNode(admin, newRandomSessionID(admin.id));
const [node1asPeer, node2asPeer] = connectedPeers("peer1", "peer2", {peer1role: 'server', peer2role: 'client', trace: true});
const [node1asPeer, node2asPeer] = connectedPeers("peer1", "peer2", {
peer1role: "server",
peer2role: "client",
trace: true,
});
node1.sync.addPeer(node2asPeer);
@@ -1127,7 +1094,7 @@ test("If we start loading a coValue before connecting to a peer that has it, it
expect(expectMap(mapOnNode2.getCurrentContent()).get("hello")).toEqual(
"world"
);
})
});
function teamContentEx(team: Team) {
return {
@@ -1136,7 +1103,7 @@ function teamContentEx(team: Team) {
};
}
function admContEx(adminID: AgentID) {
function admContEx(adminID: AccountID) {
return {
action: "newContent",
coValueID: adminID,
@@ -1150,7 +1117,7 @@ function teamStateEx(team: Team) {
};
}
function admStateEx(adminID: AgentID) {
function admStateEx(adminID: AccountID) {
return {
action: "tellKnownState",
coValueID: adminID,
@@ -1188,11 +1155,13 @@ function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
await nextItemReady;
}
}
throw new Error("Should only use one retry to get next item in queue.")
throw new Error(
"Should only use one retry to get next item in queue."
);
},
cancel(reason) {
console.log("Manually closing reader")
console.log("Manually closing reader");
readerClosed = true;
},
});
@@ -1210,7 +1179,7 @@ function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
}
},
abort(reason) {
console.log("Manually closing writer")
console.log("Manually closing writer");
writerClosed = true;
resolveNextItemReady();
return Promise.resolve();
@@ -1220,7 +1189,10 @@ function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
return [readable, writable];
}
function shouldNotResolve<T>(promise: Promise<T>, ops: { timeout: number }): Promise<void> {
function shouldNotResolve<T>(
promise: Promise<T>,
ops: { timeout: number }
): Promise<void> {
return new Promise((resolve, reject) => {
promise
.then((v) =>

79
src/testUtils.ts Normal file
View File

@@ -0,0 +1,79 @@
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "./crypto.js";
import { SessionID, newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { expectTeamContent } from "./permissions.js";
import { AnonymousControlledAccount } from "./account.js";
export function randomAnonymousAccountAndSessionID(): [AnonymousControlledAccount, SessionID] {
const agentSecret = newRandomAgentSecret();
const sessionID = newRandomSessionID(getAgentID(agentSecret));
return [new AnonymousControlledAccount(agentSecret), sessionID];
}
export function newTeam() {
const [admin, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, sessionID);
const team = node.createCoValue({
type: "comap",
ruleset: { type: "team", initialAdmin: admin.id },
meta: null,
...createdNowUnique(),
publicNickname: "team",
});
const teamContent = expectTeamContent(team.getCurrentContent());
teamContent.edit((editable) => {
editable.set(admin.id, "admin", "trusting");
expect(editable.get(admin.id)).toEqual("admin");
});
return { node, team, admin };
}
export function teamWithTwoAdmins() {
const { team, admin, node } = newTeam();
const otherAdmin = node.createAccount("otherAdmin");
let content = expectTeamContent(team.getCurrentContent());
content.edit((editable) => {
editable.set(otherAdmin.id, "admin", "trusting");
expect(editable.get(otherAdmin.id)).toEqual("admin");
});
content = expectTeamContent(team.getCurrentContent());
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.get(otherAdmin.id)).toEqual("admin");
return { team, admin, otherAdmin, node };
}
export function newTeamHighLevel() {
const [admin, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, sessionID);
const team = node.createTeam();
return { admin, node, team };
}
export function teamWithTwoAdminsHighLevel() {
const { admin, node, team } = newTeamHighLevel();
const otherAdmin = node.createAccount("otherAdmin");
team.addMember(otherAdmin.id, "admin");
return { admin, node, team, otherAdmin };
}