From 209839d889ca51bb98ae09335c375f5f85e18488 Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 9 Aug 2023 11:47:41 +0100 Subject: [PATCH] Lots of improvements --- package.json | 2 + src/coValue.test.ts | 5 +- src/coValue.ts | 111 ++++++++++++++--- src/contentType.test.ts | 6 + src/contentType.ts | 227 +---------------------------------- src/contentTypes/coList.ts | 24 ++++ src/contentTypes/coMap.ts | 195 ++++++++++++++++++++++++++++++ src/contentTypes/coStream.ts | 24 ++++ src/contentTypes/static.ts | 22 ++++ src/crypto.ts | 30 ++++- src/ids.ts | 7 ++ src/index.ts | 34 ++++-- src/node.ts | 12 +- src/permissions.test.ts | 32 +++++ src/permissions.ts | 11 +- src/sync.test.ts | 9 +- src/sync.ts | 43 ++----- 17 files changed, 499 insertions(+), 295 deletions(-) create mode 100644 src/contentTypes/coList.ts create mode 100644 src/contentTypes/coMap.ts create mode 100644 src/contentTypes/coStream.ts create mode 100644 src/contentTypes/static.ts create mode 100644 src/ids.ts diff --git a/package.json b/package.json index 316b1385d..6af70a283 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,10 @@ { "name": "cojson", "module": "src/index.ts", + "types": "src/index.ts", "type": "module", "license": "MIT", + "version": "0.0.3", "devDependencies": { "@types/jest": "^29.5.3", "@typescript-eslint/eslint-plugin": "^6.2.1", diff --git a/src/coValue.test.ts b/src/coValue.test.ts index 618eaaaea..bc221034b 100644 --- a/src/coValue.test.ts +++ b/src/coValue.test.ts @@ -7,7 +7,7 @@ import { newRandomSessionID, } from "./coValue"; import { LocalNode } from "./node"; -import { sign } from "./crypto"; +import { createdNowUnique, sign, uniquenessForHeader } from "./crypto"; test("Can create coValue with new agent credentials and add transaction to it", () => { const agentCredential = newRandomAgentCredential("agent1"); @@ -20,6 +20,7 @@ test("Can create coValue with new agent credentials and add transaction to it", type: "costream", ruleset: { type: "unsafeAllowAll" }, meta: null, + ...createdNowUnique() }); const transaction: Transaction = { @@ -59,6 +60,7 @@ test("transactions with wrong signature are rejected", () => { type: "costream", ruleset: { type: "unsafeAllowAll" }, meta: null, + ...createdNowUnique() }); const transaction: Transaction = { @@ -97,6 +99,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => { type: "costream", ruleset: { type: "unsafeAllowAll" }, meta: null, + ...createdNowUnique() }); const transaction: Transaction = { diff --git a/src/coValue.ts b/src/coValue.ts index ed820a20c..b4a1b4dc1 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -1,5 +1,8 @@ import { randomBytes } from "@noble/hashes/utils"; -import { CoList, CoMap, ContentType, Static, CoStream } from "./contentType"; +import { ContentType } from "./contentType"; +import { Static } from "./contentTypes/static"; +import { CoStream } from "./contentTypes/coStream"; +import { CoMap } from "./contentTypes/coMap"; import { Encrypted, Hash, @@ -22,23 +25,30 @@ import { decryptForTransaction, KeyID, unsealKeySecret, + signatorySecretToBytes, + recipientSecretToBytes, + signatorySecretFromBytes, + recipientSecretFromBytes, } from "./crypto"; import { JsonValue } from "./jsonValue"; import { base58 } from "@scure/base"; import { PermissionsDef as RulesetDef, + Team, determineValidTransactions, expectTeamContent, } from "./permissions"; import { LocalNode } from "./node"; import { CoValueKnownState, NewContentMessage } from "./sync"; - -export type RawCoValueID = `co_z${string}` | `co_${string}_z${string}`; +import { AgentID, RawCoValueID, SessionID, TransactionID } from "./ids"; +import { CoList } from "./contentTypes/coList"; export type CoValueHeader = { type: ContentType["type"]; ruleset: RulesetDef; meta: JsonValue; + createdAt: `2${string}` | null; + uniqueness: `z${string}` | null; publicNickname?: string; }; @@ -53,8 +63,6 @@ function coValueIDforHeader(header: CoValueHeader): RawCoValueID { } } -export type SessionID = `${AgentID}_session_z${string}`; - export function agentIDfromSessionID(sessionID: SessionID): AgentID { return sessionID.split("_session")[0] as AgentID; } @@ -94,14 +102,13 @@ export type DecryptedTransaction = { madeAt: number; }; -export type TransactionID = { sessionID: SessionID; txIndex: number }; - export class CoValue { id: RawCoValueID; node: LocalNode; header: CoValueHeader; sessions: { [key: SessionID]: SessionLog }; content?: ContentType; + listeners: Set<(content?: ContentType) => void> = new Set(); constructor(header: CoValueHeader, node: LocalNode) { this.id = coValueIDforHeader(header); @@ -185,6 +192,8 @@ export class CoValue { const transactions = this.sessions[sessionID]?.transactions ?? []; + console.log("transactions before", this.id, transactions.length, this.getValidSortedTransactions().length); + transactions.push(...newTransactions); this.sessions[sessionID] = { @@ -196,11 +205,28 @@ export class CoValue { this.content = undefined; - const _ = this.getCurrentContent(); + console.log("transactions after", this.id, transactions.length, this.getValidSortedTransactions().length); + + const content = this.getCurrentContent(); + + for (const listener of this.listeners) { + console.log("Calling listener (update)", this.id, content.toJSON()); + listener(content); + } return true; } + subscribe(listener: (content?: ContentType) => void): () => void { + this.listeners.add(listener); + console.log("Calling listener (initial)", this.id, this.getCurrentContent().toJSON()); + listener(this.getCurrentContent()); + + return () => { + this.listeners.delete(listener); + }; + } + expectedNewHashAfter( sessionID: SessionID, newTransactions: Transaction[] @@ -232,7 +258,9 @@ export class CoValue { const { secret: keySecret, id: keyID } = this.getCurrentReadKey(); if (!keySecret) { - throw new Error("Can't make transaction without read key secret"); + throw new Error( + "Can't make transaction without read key secret" + ); } transaction = { @@ -300,8 +328,8 @@ export class CoValue { getValidSortedTransactions(): DecryptedTransaction[] { const validTransactions = determineValidTransactions(this); - const allTransactions: DecryptedTransaction[] = validTransactions.map( - ({ txID, tx }) => { + const allTransactions: DecryptedTransaction[] = validTransactions + .map(({ txID, tx }) => { if (tx.privacy === "trusting") { return { txID, @@ -324,7 +352,9 @@ export class CoValue { ); if (!decrytedChanges) { - console.error("Failed to decrypt transaction despite having key"); + console.error( + "Failed to decrypt transaction despite having key" + ); return undefined; } return { @@ -334,8 +364,8 @@ export class CoValue { }; } } - } - ).filter((x): x is Exclude => !!x); + }) + .filter((x): x is Exclude => !!x); allTransactions.sort( (a, b) => a.madeAt - b.madeAt || @@ -446,6 +476,21 @@ export class CoValue { } } + getTeam(): Team { + if (this.header.ruleset.type !== "ownedByTeam") { + throw new Error("Only values owned by teams have teams"); + } + + return new Team( + expectTeamContent( + this.node + .expectCoValueLoaded(this.header.ruleset.team) + .getCurrentContent() + ), + this.node + ); + } + getTx(txID: TransactionID): Transaction | undefined { return this.sessions[txID.sessionID]?.transactions[txID.txIndex]; } @@ -510,8 +555,6 @@ export class CoValue { } } -export type AgentID = `co_agent${string}_z${string}`; - export type Agent = { signatoryID: SignatoryID; recipientID: RecipientID; @@ -535,6 +578,8 @@ export function getAgentCoValueHeader(agent: Agent): CoValueHeader { initialRecipientID: agent.recipientID, }, meta: null, + createdAt: null, + uniqueness: null, publicNickname: "agent" + (agent.publicNickname ? `-${agent.publicNickname}` : ""), }; @@ -551,13 +596,45 @@ export type AgentCredential = { }; export function newRandomAgentCredential( - publicNickname: string + 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; diff --git a/src/contentType.test.ts b/src/contentType.test.ts index 53f253e30..7e11f4327 100644 --- a/src/contentType.test.ts +++ b/src/contentType.test.ts @@ -5,6 +5,7 @@ import { newRandomAgentCredential, newRandomSessionID, } from "./coValue"; +import { createdNowUnique } from "./crypto"; import { LocalNode } from "./node"; test("Empty COJSON Map works", () => { @@ -18,6 +19,7 @@ test("Empty COJSON Map works", () => { type: "comap", ruleset: { type: "unsafeAllowAll" }, meta: null, + ...createdNowUnique() }); const content = coValue.getCurrentContent(); @@ -42,6 +44,7 @@ test("Can insert and delete Map entries in edit()", () => { type: "comap", ruleset: { type: "unsafeAllowAll" }, meta: null, + ...createdNowUnique() }); const content = coValue.getCurrentContent(); @@ -74,6 +77,7 @@ test("Can get map entry values at different points in time", () => { type: "comap", ruleset: { type: "unsafeAllowAll" }, meta: null, + ...createdNowUnique() }); const content = coValue.getCurrentContent(); @@ -113,6 +117,7 @@ test("Can get all historic values of key", () => { type: "comap", ruleset: { type: "unsafeAllowAll" }, meta: null, + ...createdNowUnique() }); const content = coValue.getCurrentContent(); @@ -170,6 +175,7 @@ test("Can get last tx ID for a key", () => { type: "comap", ruleset: { type: "unsafeAllowAll" }, meta: null, + ...createdNowUnique() }); const content = coValue.getCurrentContent(); diff --git a/src/contentType.ts b/src/contentType.ts index ac63c6d64..8f389e10e 100644 --- a/src/contentType.ts +++ b/src/contentType.ts @@ -1,5 +1,9 @@ -import { JsonAtom, JsonObject, JsonValue } from "./jsonValue"; -import { CoValue, RawCoValueID, TransactionID } from "./coValue"; +import { JsonValue } from "./jsonValue"; +import { RawCoValueID } from "./ids"; +import { CoMap } from "./contentTypes/coMap"; +import { CoStream } from "./contentTypes/coStream"; +import { Static } from "./contentTypes/static"; +import { CoList } from "./contentTypes/coList"; export type CoValueID = RawCoValueID & { readonly __type: T; @@ -11,225 +15,6 @@ export type ContentType = | CoStream | Static; -type MapOp = { - txID: TransactionID; - madeAt: number; - changeIdx: number; -} & MapOpPayload; - -// TODO: add after TransactionID[] for conflicts/ordering -export type MapOpPayload = - | { - op: "insert"; - key: K; - value: V; - } - | { - op: "delete"; - key: K; - }; - -export class CoMap< - M extends {[key: string]: JsonValue}, - Meta extends JsonValue, - K extends string = keyof M & string, - V extends JsonValue = M[K], - MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]} -> { - id: CoValueID>; - coValue: CoValue; - type: "comap" = "comap"; - ops: {[KK in K]?: MapOp[]}; - - constructor(coValue: CoValue) { - this.id = coValue.id as CoValueID>; - this.coValue = coValue; - this.ops = {}; - - this.fillOpsFromCoValue(); - } - - protected fillOpsFromCoValue() { - this.ops = {}; - - for (const { txID, changes, madeAt } of this.coValue.getValidSortedTransactions()) { - for (const [changeIdx, changeUntyped] of ( - changes - ).entries()) { - const change = changeUntyped as MapOpPayload - let entries = this.ops[change.key]; - if (!entries) { - entries = []; - this.ops[change.key] = entries; - } - entries.push({ - txID, - madeAt, - changeIdx, - ...(change as any), - }); - } - } - } - - keys(): K[] { - return Object.keys(this.ops) as K[]; - } - - get(key: KK): M[KK] | undefined { - const ops = this.ops[key]; - if (!ops) { - return undefined; - } - - const lastEntry = ops[ops.length - 1]!; - - if (lastEntry.op === "delete") { - return undefined; - } else { - return lastEntry.value; - } - } - - getAtTime(key: KK, time: number): M[KK] | undefined { - const ops = this.ops[key]; - if (!ops) { - return undefined; - } - - const lastOpBeforeOrAtTime = ops.findLast((op) => op.madeAt <= time); - - if (!lastOpBeforeOrAtTime) { - return undefined; - } - - if (lastOpBeforeOrAtTime.op === "delete") { - return undefined; - } else { - return lastOpBeforeOrAtTime.value; - } - } - - getLastTxID(key: KK): TransactionID | undefined { - const ops = this.ops[key]; - if (!ops) { - return undefined; - } - - const lastEntry = ops[ops.length - 1]!; - - return lastEntry.txID; - } - - getHistory(key: KK): {at: number, txID: TransactionID, value: M[KK] | undefined}[] { - const ops = this.ops[key]; - if (!ops) { - return []; - } - - const history: {at: number, txID: TransactionID, value: M[KK] | undefined}[] = []; - - for (const op of ops) { - if (op.op === "delete") { - history.push({at: op.madeAt, txID: op.txID, value: undefined}); - } else { - history.push({at: op.madeAt, txID: op.txID, value: op.value}); - } - } - - return history; - } - - toJSON(): JsonObject { - const json: JsonObject = {}; - - for (const key of this.keys()) { - const value = this.get(key); - if (value !== undefined) { - json[key] = value; - } - } - - return json; - } - - edit(changer: (editable: WriteableCoMap) => void): CoMap { - const editable = new WriteableCoMap(this.coValue); - changer(editable); - return new CoMap(this.coValue); - } -} - -export class WriteableCoMap< - M extends {[key: string]: JsonValue}, - Meta extends JsonValue, - K extends string = keyof M & string, - V extends JsonValue = M[K], - MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]} -> extends CoMap { - set(key: KK, value: M[KK], privacy: "private" | "trusting" = "private"): void { - this.coValue.makeTransaction([ - { - op: "insert", - key, - value, - }, - ], privacy); - - this.fillOpsFromCoValue(); - } - - delete(key: K, privacy: "private" | "trusting" = "private"): void { - this.coValue.makeTransaction([ - { - op: "delete", - key, - }, - ], privacy); - - this.fillOpsFromCoValue(); - } -} - -export class CoList { - id: CoValueID>; - type: "colist" = "colist"; - - constructor(coValue: CoValue) { - this.id = coValue.id as CoValueID>; - } - - toJSON(): JsonObject { - throw new Error("Method not implemented."); - } -} - -export class CoStream { - id: CoValueID>; - type: "costream" = "costream"; - - constructor(coValue: CoValue) { - this.id = coValue.id as CoValueID>; - } - - toJSON(): JsonObject { - throw new Error("Method not implemented."); - } -} - -export class Static { - id: CoValueID>; - type: "static" = "static"; - - constructor(coValue: CoValue) { - this.id = coValue.id as CoValueID>; - } - - toJSON(): JsonObject { - throw new Error("Method not implemented."); - } -} - export function expectMap(content: ContentType): CoMap<{ [key: string]: string }, {}> { if (content.type !== "comap") { throw new Error("Expected map"); diff --git a/src/contentTypes/coList.ts b/src/contentTypes/coList.ts new file mode 100644 index 000000000..4ea8c1f6b --- /dev/null +++ b/src/contentTypes/coList.ts @@ -0,0 +1,24 @@ +import { JsonObject, JsonValue } from "../jsonValue"; +import { CoValueID } from "../contentType"; +import { CoValue } from "../coValue"; + +export class CoList { + id: CoValueID>; + type: "colist" = "colist"; + coValue: CoValue; + + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; + this.coValue = coValue; + } + + toJSON(): JsonObject { + throw new Error("Method not implemented."); + } + + subscribe(listener: (coMap: CoList) => void): () => void { + return this.coValue.subscribe((content) => { + listener(content as CoList); + }); + } +} diff --git a/src/contentTypes/coMap.ts b/src/contentTypes/coMap.ts new file mode 100644 index 000000000..6fd45aa32 --- /dev/null +++ b/src/contentTypes/coMap.ts @@ -0,0 +1,195 @@ +import { JsonObject, JsonValue } from "../jsonValue"; +import { TransactionID } from "../ids"; +import { CoValueID } from "../contentType"; +import { CoValue } from "../coValue"; + +type MapOp = { + txID: TransactionID; + madeAt: number; + changeIdx: number; +} & MapOpPayload; +// TODO: add after TransactionID[] for conflicts/ordering + +export type MapOpPayload = { + op: "insert"; + key: K; + value: V; +} | +{ + op: "delete"; + key: K; +}; + +export class CoMap< + M extends { [key: string]: JsonValue; }, + Meta extends JsonValue, + K extends string = keyof M & string, + V extends JsonValue = M[K], + MM extends { [key: string]: JsonValue; } = { + [KK in K]: M[KK]; + } +> { + id: CoValueID>; + coValue: CoValue; + type: "comap" = "comap"; + ops: { + [KK in K]?: MapOp[]; + }; + + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; + this.coValue = coValue; + this.ops = {}; + + this.fillOpsFromCoValue(); + } + + protected fillOpsFromCoValue() { + this.ops = {}; + + for (const { txID, changes, madeAt } of this.coValue.getValidSortedTransactions()) { + for (const [changeIdx, changeUntyped] of ( + changes + ).entries()) { + const change = changeUntyped as MapOpPayload; + let entries = this.ops[change.key]; + if (!entries) { + entries = []; + this.ops[change.key] = entries; + } + entries.push({ + txID, + madeAt, + changeIdx, + ...(change as any), + }); + } + } + } + + keys(): K[] { + return Object.keys(this.ops) as K[]; + } + + get(key: KK): M[KK] | undefined { + const ops = this.ops[key]; + if (!ops) { + return undefined; + } + + const lastEntry = ops[ops.length - 1]!; + + if (lastEntry.op === "delete") { + return undefined; + } else { + return lastEntry.value; + } + } + + getAtTime(key: KK, time: number): M[KK] | undefined { + const ops = this.ops[key]; + if (!ops) { + return undefined; + } + + const lastOpBeforeOrAtTime = ops.findLast((op) => op.madeAt <= time); + + if (!lastOpBeforeOrAtTime) { + return undefined; + } + + if (lastOpBeforeOrAtTime.op === "delete") { + return undefined; + } else { + return lastOpBeforeOrAtTime.value; + } + } + + getLastTxID(key: KK): TransactionID | undefined { + const ops = this.ops[key]; + if (!ops) { + return undefined; + } + + const lastEntry = ops[ops.length - 1]!; + + return lastEntry.txID; + } + + getHistory(key: KK): { at: number; txID: TransactionID; value: M[KK] | undefined; }[] { + const ops = this.ops[key]; + if (!ops) { + return []; + } + + const history: { at: number; txID: TransactionID; value: M[KK] | undefined; }[] = []; + + for (const op of ops) { + if (op.op === "delete") { + history.push({ at: op.madeAt, txID: op.txID, value: undefined }); + } else { + history.push({ at: op.madeAt, txID: op.txID, value: op.value }); + } + } + + return history; + } + + toJSON(): JsonObject { + const json: JsonObject = {}; + + for (const key of this.keys()) { + const value = this.get(key); + if (value !== undefined) { + json[key] = value; + } + } + + return json; + } + + edit(changer: (editable: WriteableCoMap) => void): CoMap { + const editable = new WriteableCoMap(this.coValue); + changer(editable); + return new CoMap(this.coValue); + } + + subscribe(listener: (coMap: CoMap) => void): () => void { + return this.coValue.subscribe((content) => { + listener(content as CoMap); + }); + } +} + +export class WriteableCoMap< + M extends { [key: string]: JsonValue; }, + Meta extends JsonValue, + K extends string = keyof M & string, + V extends JsonValue = M[K], + MM extends { [key: string]: JsonValue; } = { + [KK in K]: M[KK]; + } +> extends CoMap { + set(key: KK, value: M[KK], privacy: "private" | "trusting" = "private"): void { + this.coValue.makeTransaction([ + { + op: "insert", + key, + value, + }, + ], privacy); + + this.fillOpsFromCoValue(); + } + + delete(key: K, privacy: "private" | "trusting" = "private"): void { + this.coValue.makeTransaction([ + { + op: "delete", + key, + }, + ], privacy); + + this.fillOpsFromCoValue(); + } +} diff --git a/src/contentTypes/coStream.ts b/src/contentTypes/coStream.ts new file mode 100644 index 000000000..ab17583ad --- /dev/null +++ b/src/contentTypes/coStream.ts @@ -0,0 +1,24 @@ +import { JsonObject, JsonValue } from "../jsonValue"; +import { CoValueID } from "../contentType"; +import { CoValue } from "../coValue"; + +export class CoStream { + id: CoValueID>; + type: "costream" = "costream"; + coValue: CoValue; + + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; + this.coValue = coValue; + } + + toJSON(): JsonObject { + throw new Error("Method not implemented."); + } + + subscribe(listener: (coMap: CoStream) => void): () => void { + return this.coValue.subscribe((content) => { + listener(content as CoStream); + }); + } +} diff --git a/src/contentTypes/static.ts b/src/contentTypes/static.ts new file mode 100644 index 000000000..db17d1e18 --- /dev/null +++ b/src/contentTypes/static.ts @@ -0,0 +1,22 @@ +import { JsonObject, JsonValue } from "../jsonValue"; +import { CoValueID } from "../contentType"; +import { CoValue } from "../coValue"; + +export class Static { + id: CoValueID>; + type: "static" = "static"; + coValue: CoValue; + + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; + this.coValue = coValue; + } + + toJSON(): JsonObject { + throw new Error("Method not implemented."); + } + + subscribe(listener: (coMap: Static) => void): () => void { + throw new Error("Method not implemented."); + } +} diff --git a/src/crypto.ts b/src/crypto.ts index a7a935049..104dcf891 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -5,7 +5,7 @@ import { base58, base64url } from "@scure/base"; 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"; +import { RawCoValueID, TransactionID } from "./ids"; export type SignatorySecret = `signatorySecret_z${string}`; export type SignatoryID = `signatory_z${string}`; @@ -24,6 +24,14 @@ export function newRandomSignatory(): SignatorySecret { )}`; } +export function signatorySecretToBytes(secret: SignatorySecret): Uint8Array { + return base58.decode(secret.substring("signatorySecret_z".length)); +} + +export function signatorySecretFromBytes(bytes: Uint8Array): SignatorySecret { + return `signatorySecret_z${base58.encode(bytes)}`; +} + export function getSignatoryID(secret: SignatorySecret): SignatoryID { return `signatory_z${base58.encode( ed25519.getPublicKey( @@ -56,6 +64,14 @@ export function newRandomRecipient(): RecipientSecret { return `recipientSecret_z${base58.encode(x25519.utils.randomPrivateKey())}`; } +export function recipientSecretToBytes(secret: RecipientSecret): Uint8Array { + return base58.decode(secret.substring("recipientSecret_z".length)); +} + +export function recipientSecretFromBytes(bytes: Uint8Array): RecipientSecret { + return `recipientSecret_z${base58.encode(bytes)}`; +} + export function getRecipientID(secret: RecipientSecret): RecipientID { return `recipient_z${base58.encode( x25519.getPublicKey( @@ -295,3 +311,15 @@ export function unsealKeySecret( return decrypt(sealedInfo.encrypted, sealingSecret, nOnceMaterial); } + +export function uniquenessForHeader(): `z${string}` { + return `z${base58.encode(randomBytes(12))}`; +} + +export function createdNowUnique(): {createdAt: `2${string}`, uniqueness: `z${string}`} { + const createdAt = (new Date()).toISOString() as `2${string}`; + return { + createdAt, + uniqueness: uniquenessForHeader(), + } +} \ No newline at end of file diff --git a/src/ids.ts b/src/ids.ts new file mode 100644 index 000000000..8d1b6c90e --- /dev/null +++ b/src/ids.ts @@ -0,0 +1,7 @@ +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 SessionID = `${AgentID}_session_z${string}`; diff --git a/src/index.ts b/src/index.ts index 9ddcd6bca..df000bba7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,28 @@ -import { ContentType } from "./contentType"; -import { JsonValue } from "./jsonValue"; -import { CoValue } from "./coValue"; +import type { CoValueID, ContentType } from "./contentType"; +import type { JsonValue } from "./jsonValue"; +import { + CoValue, + agentCredentialFromBytes, + agentCredentialToBytes, + getAgent, + getAgentID, + newRandomAgentCredential, + newRandomSessionID, +} from "./coValue"; import { LocalNode } from "./node"; +import { CoMap } from "./contentTypes/coMap"; type Value = JsonValue | ContentType; -export { - JsonValue, - ContentType, - Value, - LocalNode, - CoValue -} +const internals = { + agentCredentialToBytes, + agentCredentialFromBytes, + getAgent, + getAgentID, + newRandomAgentCredential, + newRandomSessionID, +}; + +export { LocalNode, CoValue, CoMap, internals }; + +export type { Value, JsonValue, ContentType, CoValueID }; diff --git a/src/node.ts b/src/node.ts index 9e53e817f..76474f0b0 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,10 +1,7 @@ -import { newRandomKeySecret, seal } from "./crypto"; +import { createdNowUnique, newRandomKeySecret, seal } from "./crypto"; import { - RawCoValueID, CoValue, AgentCredential, - AgentID, - SessionID, Agent, getAgent, getAgentID, @@ -15,6 +12,8 @@ import { } from "./coValue"; import { Team, expectTeamContent } from "./permissions"; import { SyncManager } from "./sync"; +import { AgentID, RawCoValueID, SessionID } from "./ids"; +import { CoValueID, ContentType } from "."; export class LocalNode { coValues: { [key: RawCoValueID]: CoValueState } = {}; @@ -61,6 +60,10 @@ export class LocalNode { return entry.done; } + async load(id: CoValueID): Promise { + return (await this.loadCoValue(id)).getCurrentContent() as T; + } + expectCoValueLoaded(id: RawCoValueID, expectation?: string): CoValue { const entry = this.coValues[id]; if (!entry) { @@ -112,6 +115,7 @@ export class LocalNode { type: "comap", ruleset: { type: "team", initialAdmin: this.agentID }, meta: null, + ...createdNowUnique(), publicNickname: "team", }); diff --git a/src/permissions.test.ts b/src/permissions.test.ts index c52a260a9..86c550799 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -8,6 +8,7 @@ import { LocalNode } from "./node"; import { expectMap } from "./contentType"; import { expectTeamContent } from "./permissions"; import { + createdNowUnique, getRecipientID, newRandomKeySecret, seal, @@ -47,6 +48,7 @@ function newTeam() { type: "comap", ruleset: { type: "team", initialAdmin: adminID }, meta: null, + ...createdNowUnique(), publicNickname: "team", }); @@ -343,6 +345,7 @@ test("Admins can write to an object that is owned by their team", () => { type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + ...createdNowUnique(), publicNickname: "childObject", }); @@ -386,6 +389,7 @@ test("Writers can write to an object that is owned by their team", () => { type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + ...createdNowUnique(), publicNickname: "childObject", }); @@ -447,6 +451,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, + ...createdNowUnique(), publicNickname: "childObject", }); @@ -521,6 +526,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, + ...createdNowUnique(), publicNickname: "childObject", }); @@ -580,6 +586,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, + ...createdNowUnique(), publicNickname: "childObject", }); @@ -660,6 +667,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, + ...createdNowUnique(), publicNickname: "childObject", }); @@ -759,6 +767,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, + ...createdNowUnique(), publicNickname: "childObject", }); @@ -864,6 +873,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, + ...createdNowUnique(), publicNickname: "childObject", }); @@ -944,6 +954,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, + ...createdNowUnique(), publicNickname: "childObject", }); @@ -1085,6 +1096,7 @@ 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, + ...createdNowUnique(), publicNickname: "childObject", }); @@ -1267,3 +1279,23 @@ test("Admins can set team read rey, make a private transaction in an owned objec ).get("foo3") ).toBeUndefined(); }); + +test("Can create two owned objects in the same team and they will have different ids", () => { + const { node, team, admin, adminID } = newTeam(); + + const childObject1 = node.createCoValue({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + ...createdNowUnique() + }); + + const childObject2 = node.createCoValue({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + ...createdNowUnique() + }); + + expect(childObject1.id).not.toEqual(childObject2.id); +}); \ No newline at end of file diff --git a/src/permissions.ts b/src/permissions.ts index 3f15883c2..efe8027ac 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,4 +1,5 @@ -import { CoMap, ContentType, MapOpPayload } from "./contentType"; +import { ContentType } from "./contentType"; +import { CoMap, MapOpPayload } from "./contentTypes/coMap"; import { JsonValue } from "./jsonValue"; import { Encrypted, @@ -7,23 +8,20 @@ import { RecipientID, SealedSet, SignatoryID, - encryptForTransaction, + createdNowUnique, newRandomKeySecret, seal, sealKeySecret, } from "./crypto"; import { AgentCredential, - AgentID, CoValue, - RawCoValueID, - SessionID, Transaction, - TransactionID, TrustingTransaction, agentIDfromSessionID, } from "./coValue"; import { LocalNode } from "."; +import { AgentID, RawCoValueID, SessionID, TransactionID } from "./ids"; export type PermissionsDef = | { type: "team"; initialAdmin: AgentID; parentTeams?: RawCoValueID[] } @@ -355,6 +353,7 @@ export class Team { team: this.teamMap.id, }, meta: meta || null, + ...createdNowUnique(), publicNickname: "map", }) .getCurrentContent() as CoMap; diff --git a/src/sync.test.ts b/src/sync.test.ts index 20c14e788..460a80f1c 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -1,5 +1,4 @@ import { - AgentID, getAgent, getAgentID, newRandomAgentCredential, @@ -7,13 +6,15 @@ import { } from "./coValue"; import { LocalNode } from "./node"; import { Peer, PeerID, SyncMessage } from "./sync"; -import { MapOpPayload, expectMap } from "./contentType"; +import { expectMap } from "./contentType"; +import { MapOpPayload } from "./contentTypes/coMap"; import { Team } from "./permissions"; import { ReadableStream, WritableStream, TransformStream, } from "isomorphic-streams"; +import { AgentID } from "./ids"; test( "Node replies with initial tx and header to empty subscribe", @@ -73,6 +74,8 @@ test( type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + createdAt: map.coValue.header.createdAt, + uniqueness: map.coValue.header.uniqueness, publicNickname: "map", }, newContent: { @@ -609,8 +612,6 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a const team = node.createTeam(); - team.createMap(); - const [inRx, _inTx] = newStreamPair(); const [outRx, outTx] = newStreamPair(); diff --git a/src/sync.ts b/src/sync.ts index 72ee1aec2..cd40324d1 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,9 +1,10 @@ import { Hash, Signature } from "./crypto"; -import { CoValueHeader, RawCoValueID, SessionID, Transaction } from "./coValue"; +import { CoValueHeader, Transaction } from "./coValue"; import { CoValue } from "./coValue"; import { LocalNode } from "./node"; import { newLoadingState } from "./node"; import { ReadableStream, WritableStream, WritableStreamDefaultWriter } from "isomorphic-streams"; +import { RawCoValueID, SessionID } from "./ids"; export type CoValueKnownState = { coValueID: RawCoValueID; @@ -79,31 +80,6 @@ export interface PeerState { role: "peer" | "server" | "client"; } -export function weAreStrictlyAhead( - ourKnownState: CoValueKnownState, - theirKnownState: CoValueKnownState -): boolean { - if (theirKnownState.header && !ourKnownState.header) { - return false; - } - - const allSessions = new Set([ - ...(Object.keys(ourKnownState.sessions) as SessionID[]), - ...(Object.keys(theirKnownState.sessions) as SessionID[]), - ]); - - for (const sessionID of allSessions) { - const ourSession = ourKnownState.sessions[sessionID]; - const theirSession = theirKnownState.sessions[sessionID]; - - if ((ourSession || 0) < (theirSession || 0)) { - return false; - } - } - - return true; -} - export function combinedKnownStates( stateA: CoValueKnownState, stateB: CoValueKnownState @@ -480,11 +456,7 @@ export class SyncManager { for (const peer of Object.values(this.peers)) { const optimisticKnownState = peer.optimisticKnownStates[coValue.id]; - const shouldSync = - optimisticKnownState || - peer.role === "server"; - - if (shouldSync) { + if (optimisticKnownState) { await this.tellUntoldKnownStateIncludingDependencies( coValue.id, peer @@ -493,6 +465,15 @@ export class SyncManager { coValue.id, peer ); + } else if (peer.role === "server") { + await this.subscribeToIncludingDependencies( + coValue.id, + peer + ); + await this.sendNewContentIncludingDependencies( + coValue.id, + peer + ); } } }