From d0993189d20fe78ec8b09abc3acfcf3ab738bd5d Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 19 Jul 2023 11:55:18 +0100 Subject: [PATCH 01/47] Technology is anything that wasn't around when you were born. --- .gitignore | 171 ++++++++++++++++++++ LICENSE.txt | 19 +++ README.md | 53 ++++++ bun.lockb | Bin 0 -> 3145 bytes package.json | 19 +++ src/cojsonValue.test.ts | 103 ++++++++++++ src/cojsonValue.ts | 183 +++++++++++++++++++++ src/crypto.test.ts | 103 ++++++++++++ src/crypto.ts | 245 ++++++++++++++++++++++++++++ src/index.ts | 14 ++ src/jsonValue.ts | 6 + src/multilog.test.ts | 138 ++++++++++++++++ src/multilog.ts | 348 ++++++++++++++++++++++++++++++++++++++++ src/node.ts | 75 +++++++++ src/permissions.test.ts | 311 +++++++++++++++++++++++++++++++++++ src/permissions.ts | 171 ++++++++++++++++++++ tsconfig.json | 21 +++ 17 files changed, 1980 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100755 bun.lockb create mode 100644 package.json create mode 100644 src/cojsonValue.test.ts create mode 100644 src/cojsonValue.ts create mode 100644 src/crypto.test.ts create mode 100644 src/crypto.ts create mode 100644 src/index.ts create mode 100644 src/jsonValue.ts create mode 100644 src/multilog.test.ts create mode 100644 src/multilog.ts create mode 100644 src/node.ts create mode 100644 src/permissions.test.ts create mode 100644 src/permissions.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..dd6b144d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,171 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +.DS_Store \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..1a7607611 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright 2023, Garden Computing, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..24c0090fb --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# CoJSON + +CoJSON ("Collaborative JSON") will be a minimal protocol and implementation for collaborative values (CRDTs + public-key cryptography). + +CoJSON is developed by [Garden Computing](https://gcmp.io) as the underpinnings of [Jazz](https://jazz.tools), a framework for building apps with telepathic data. + +The protocol and implementation will cover: + +- how to represent collaborative values internally +- the APIs collaborative values expose +- how to sync and query for collaborative values between peers +- how to enforce access rights within collaborative values locally and at sync boundaries + +THIS IS WORK IN PROGRESS + +## Core Value Types + +### `Immutable` Values (JSON) +- null +- boolean +- number +- string + - stringly-encoded CoJSON identifiers & data (`CoValueID`, `AgentID`, `SessionID`, `SignatoryID`, `SignatorySecret`, `Signature`, `RecipientID`, `RecipientSecret`, `Sealed`, `Hash`, `ShortHash`, `SecretKey`, `EncryptedChunk`, `Role`) + +- array +- object + +### `Collaborative` Values +- CoMap (`string` → `Immutable`, last-writer-wins per key) + - Team (`AgentID` → `Role`) +- CoList (`Immutable[]`, addressable positions, insertAfter semantics) + - Agent (`{signatoryID, recipientID}[]`) +- MultiStream (independent per-session streams of `Immutable`s) +- Static (single addressable `Immutable`) + +## Implementation Abstractions +- MultiLog + - Session Logs + - Transactions + - Private (encrypted) transactions + - Trusting (unencrypted) transactions + - Rulesets +- CoValue Types +- LocalNode + - Peers + - AgentCredentials +- Peer + +## Extensions & higher-level protocols + +### More complex datastructures +- MarCo: a clean way to collaboratively mark up rich text with CoJSON +- CoJSON Tree: a clean way to represent collaborative tree structures with CoJSON \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..e498e2f15b9c847f2b245b68f0fa209587c189da GIT binary patch literal 3145 zcmd^Bdr(wm6u-a%ipirjA0VB&4t$$LSyJftf~N*g3oRvz0;IoThQ6Gjr#B=brEP z`_6a1*NF^g%6N_`GME|GTrMjrGyB1(Fj+LkT7!vIXf0fcT2PcL{X8j(I@0djKdGuF zkN#z-D}Phx!c{?=mD7#$7R=ALJ=VFcXUgFT&9zExUGm<9BDQbdo~j>pXX_rl8sPzWlr=d6p4cXK+q-fv(nf##u1*7U z%bV)IHO=DsAG3f`2;UNj18rA7m}3gTAAw#^0UojB8xtrx3I00RkShn?OXT<`cp3&J z_y>vK0H72YAIC&I#^8>S_-*3&i1C3B*4k~s%|kZOXd2@gx$}_x!EnO?!$|lqiwEW8 zb)}YUolRvJ`G;f+qhl!tGrz|W71oJF~+dK zp~kjX12R`P?p|hBZn+-P9|kcJUXo`FTcVThXpy&QJWp?|%v|MzO(Q2AWQF^wy5VNdclUQEEitab(REuvVT*X z^2>{9&*)Apn44&q9=D!5EKRE&u77WLiI4Y^bG_QL(ktfhgAJCWD+X%6+K~NiMov;t z=7sRq{G2b|pXtIY!9Yckoc~TQ$tn!|W^TRycaLq&lGs@%y-pmQM0tm0$ z+SmL_T(*5+dD`ofjcwV|g#5tj$nom=_Mqy7Lw_I*$%8i`1ubD(7fLKws%;Zb&i?xR zM8$r_Uv_muZSUiK#&yqiue@|4{E^^ep%jyyH0#(IkIiB6XNq@61h-1N{A*fky92K6 zbm7H4OGNT>awQ$wQ*py`J7e+8IWwZ`g$YaSRcvQide8BxA;B4!H`<~i%qfR$JGSQj zSu^#w=T9|UZfNN%+|&Q&%;?;lC?pzj|KNQ@C3QQ_men^P%L@{;G=^6FC;!OA+|;gn z*iFd)vD<#}u4)stUoy{WXoHy-)Fu;6UR?SGj%8D2F-jS4sAMf#xonP1Z8GT07A?Mg zD8sj%f^FP~{UYHw@zV24E{lyx1>7hMxK?;K;5o)Khvx%1;zjIYH#nd>g%KbQ@yLUG z$cy~gt|NT#SfltQ=f}joTow;Ai@Ot->h77r{H#q;R{89;Y*C=c(kE#ptpp^X@x>=E z_&86KT3~sBqDW0Z-gr-srAk^w$5K={V6b{1RRDNY-~l#LSCBfv;faNFg|DHb@*tIj zhyhOqc(67hwFY?LB#zDo!y_5BQ=B)^cP}>2mXxy`oxVa~IkVc7D{uz0E>))bJ2mll zQqv(awTQJ9tS)3xb&n(p6{Re%;S8k$mT?a2X|tus#4;K~sh;I{VwG{_EHU(IUJnMZ zDdSkCNX@faH7_WzFjK(h1TZL!(4ei*3qmQM$S@qMGr-RZ+FV*<P$Fdoh^l9 acisnlXW4L`WC;NoN2_8H3VZ9Jl)nJ%=VM?1 literal 0 HcmV?d00001 diff --git a/package.json b/package.json new file mode 100644 index 000000000..45c1931e1 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "cojson", + "module": "src/index.ts", + "type": "module", + "license": "MIT", + "devDependencies": { + "bun-types": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@noble/ciphers": "^0.1.3", + "@noble/curves": "^1.1.0", + "@noble/hashes": "^1.3.1", + "@scure/base": "^1.1.1", + "fast-json-stable-stringify": "^2.1.0" + } +} \ No newline at end of file diff --git a/src/cojsonValue.test.ts b/src/cojsonValue.test.ts new file mode 100644 index 000000000..bb613dc05 --- /dev/null +++ b/src/cojsonValue.test.ts @@ -0,0 +1,103 @@ +import { test, expect } from "bun:test"; +import { + getAgent, + getAgentID, + newRandomAgentCredential, + newRandomSessionID, +} from "./multilog"; +import { LocalNode } from "./node"; + +test("Empty COJSON Map works", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = multilog.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + expect([...content.keys()]).toEqual([]); + expect(content.toJSON()).toEqual({}); +}); + +test("Can insert and delete Map entries in edit()", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = multilog.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + + content.edit((editable) => { + editable.set("hello", "world"); + expect(editable.get("hello")).toEqual("world"); + editable.set("foo", "bar"); + expect(editable.get("foo")).toEqual("bar"); + expect([...editable.keys()]).toEqual(["hello", "foo"]); + editable.delete("foo"); + expect(editable.get("foo")).toEqual(undefined); + }); +}); + +test("Can get map entry values at different points in time", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = multilog.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + + content.edit((editable) => { + const beforeA = Date.now(); + Bun.sleepSync(1); + editable.set("hello", "A"); + const beforeB = Date.now(); + Bun.sleepSync(1); + editable.set("hello", "B"); + const beforeC = Date.now(); + Bun.sleepSync(1); + editable.set("hello", "C"); + expect(editable.get("hello")).toEqual("C"); + expect(editable.getAtTime("hello", Date.now())).toEqual("C"); + expect(editable.getAtTime("hello", beforeA)).toEqual(undefined); + expect(editable.getAtTime("hello", beforeB)).toEqual("A"); + expect(editable.getAtTime("hello", beforeC)).toEqual("B"); + }); +}) \ No newline at end of file diff --git a/src/cojsonValue.ts b/src/cojsonValue.ts new file mode 100644 index 000000000..37f65f353 --- /dev/null +++ b/src/cojsonValue.ts @@ -0,0 +1,183 @@ +import { JsonAtom, JsonObject, JsonValue } from "./jsonValue"; +import { MultiLog, MultiLogID, TransactionID } from "./multilog"; + +export type CoValueID = MultiLogID & { + readonly __type: T; +}; + +export type CoValue = + | CoMap + | CoList + | MultiStream + | 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< + K extends string, + V extends JsonValue, + Meta extends JsonValue +> { + id: CoValueID>; + multiLog: MultiLog; + type: "comap" = "comap"; + ops: Map[]>; + + constructor(multiLog: MultiLog) { + this.id = multiLog.id as CoValueID>; + this.multiLog = multiLog; + this.ops = new Map(); + + this.fillOpsFromMultilog(); + } + + protected fillOpsFromMultilog() { + for (const { txID, changes, madeAt } of this.multiLog.getValidSortedTransactions()) { + for (const [changeIdx, change] of ( + changes as MapOpPayload[] + ).entries()) { + let entries = this.ops.get(change.key); + if (!entries) { + entries = []; + this.ops.set(change.key, entries); + } + entries.push({ + txID, + madeAt, + changeIdx, + ...change, + }); + } + } + } + + keys(): IterableIterator { + return this.ops.keys(); + } + + get(key: K): V | undefined { + const ops = this.ops.get(key); + if (!ops) { + return undefined; + } + + let lastEntry = ops[ops.length - 1]; + + if (lastEntry.op === "delete") { + return undefined; + } else { + return lastEntry.value; + } + } + + getAtTime(key: K, time: number): V | undefined { + const ops = this.ops.get(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; + } + } + + 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): void { + const editable = new WriteableCoMap(this.multiLog); + changer(editable); + } +} + +export class WriteableCoMap< + K extends string, + V extends JsonValue, + Meta extends JsonValue +> extends CoMap { + // TODO: change default to private + set(key: K, value: V, privacy: "private" | "trusting" = "trusting"): void { + this.multiLog.makeTransaction([ + { + op: "insert", + key, + value, + }, + ], privacy); + + this.fillOpsFromMultilog(); + } + + // TODO: change default to private + delete(key: K, privacy: "private" | "trusting" = "trusting"): void { + this.multiLog.makeTransaction([ + { + op: "delete", + key, + }, + ], privacy); + + this.fillOpsFromMultilog(); + } +} + +export class CoList { + id: CoValueID>; + type: "colist" = "colist"; + + constructor(multilog: MultiLog) { + this.id = multilog.id as CoValueID>; + } +} + +export class MultiStream { + id: CoValueID>; + type: "multistream" = "multistream"; + + constructor(multilog: MultiLog) { + this.id = multilog.id as CoValueID>; + } +} + +export class Static { + id: CoValueID>; + type: "static" = "static"; + + constructor(multilog: MultiLog) { + this.id = multilog.id as CoValueID>; + } +} diff --git a/src/crypto.test.ts b/src/crypto.test.ts new file mode 100644 index 000000000..8a50dd46a --- /dev/null +++ b/src/crypto.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "bun:test"; +import { + getRecipientID, + getSignatoryID, + secureHash, + newRandomRecipient, + newRandomSignatory, + sealFor, + sign, + unsealAs, + verify, + shortHash, + newRandomSecretKey, + EncryptionStream, + DecryptionStream, +} from "./crypto"; + +test("Signatures round-trip and use stable stringify", () => { + const data = { b: "world", a: "hello" }; + const signatory = newRandomSignatory(); + const signature = sign(signatory, data); + + expect(signature).toMatch(/^signature_z/); + expect( + verify(signature, { a: "hello", b: "world" }, getSignatoryID(signatory)) + ).toBe(true); +}); + +test("Invalid signatures don't verify", () => { + const data = { b: "world", a: "hello" }; + const signatory = newRandomSignatory(); + const signatory2 = newRandomSignatory(); + const wrongSignature = sign(signatory2, data); + + expect(verify(wrongSignature, data, getSignatoryID(signatory))).toBe(false); +}); + +test("Sealing round-trips", () => { + const data = { b: "world", a: "hello" }; + const recipient = newRandomRecipient(); + const sealed = sealFor(getRecipientID(recipient), data); + + expect(sealed).toMatch(/^sealed_U/); + expect(unsealAs(recipient, sealed)).toEqual(data); +}); + +test("Invalid receiver can't unseal", () => { + const data = { b: "world", a: "hello" }; + const recipient = newRandomRecipient(); + const recipient2 = newRandomRecipient(); + const sealed = sealFor(getRecipientID(recipient), data); + + expect(unsealAs(recipient2, sealed)).toBeUndefined(); +}); + +test("Hashing is deterministic", () => { + expect(secureHash({ b: "world", a: "hello" })).toEqual( + secureHash({ a: "hello", b: "world" }) + ); + + expect(shortHash({ b: "world", a: "hello" })).toEqual( + shortHash({ a: "hello", b: "world" }) + ); +}); + +test("Encryption streams round-trip", () => { + const secretKey = newRandomSecretKey(); + const nonce = new Uint8Array(24); + + const encryptionStream = new EncryptionStream(secretKey, nonce); + const decryptionStream = new DecryptionStream(secretKey, nonce); + + const encryptedChunks = [ + encryptionStream.encrypt({ a: "hello" }), + encryptionStream.encrypt({ b: "world" }), + ]; + + const decryptedChunks = encryptedChunks.map((chunk) => + decryptionStream.decrypt(chunk) + ); + + expect(decryptedChunks).toEqual([{ a: "hello" }, { b: "world" }]); +}); + +test("Encryption streams don't decrypt with a wrong key", () => { + const secretKey = newRandomSecretKey(); + const secretKey2 = newRandomSecretKey(); + const nonce = new Uint8Array(24); + + const encryptionStream = new EncryptionStream(secretKey, nonce); + const decryptionStream = new DecryptionStream(secretKey2, nonce); + + const encryptedChunks = [ + encryptionStream.encrypt({ a: "hello" }), + encryptionStream.encrypt({ b: "world" }), + ]; + + const decryptedChunks = encryptedChunks.map((chunk) => + decryptionStream.decrypt(chunk) + ); + + expect(decryptedChunks).toEqual([undefined, undefined]); +}); diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 000000000..276d0cdcd --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,245 @@ +import { ed25519, x25519 } from "@noble/curves/ed25519"; +import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa"; +import { JsonValue } from "./jsonValue"; +import { base58, base64url } from "@scure/base"; +import stableStringify from "fast-json-stable-stringify"; +import { blake2b } from "@noble/hashes/blake2b"; +import { concatBytes } from "@noble/ciphers/utils"; +import { blake3 } from "@noble/hashes/blake3"; +import { randomBytes } from "@noble/ciphers/webcrypto/utils"; + +export type SignatorySecret = `signatorySecret_z${string}`; +export type SignatoryID = `signatory_z${string}`; +export type Signature = `signature_z${string}`; + +export type RecipientSecret = `recipientSecret_z${string}`; +export type RecipientID = `recipient_z${string}`; +export type Sealed = `sealed_U${string}`; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export function newRandomSignatory(): SignatorySecret { + return `signatorySecret_z${base58.encode( + ed25519.utils.randomPrivateKey() + )}`; +} + +export function getSignatoryID(secret: SignatorySecret): SignatoryID { + return `signatory_z${base58.encode( + ed25519.getPublicKey( + base58.decode(secret.substring("signatorySecret_z".length)) + ) + )}`; +} + +export function sign(secret: SignatorySecret, message: JsonValue): Signature { + const signature = ed25519.sign( + textEncoder.encode(stableStringify(message)), + base58.decode(secret.substring("signatorySecret_z".length)) + ); + return `signature_z${base58.encode(signature)}`; +} + +export function verify( + signature: Signature, + message: JsonValue, + id: SignatoryID +): boolean { + return ed25519.verify( + base58.decode(signature.substring("signature_z".length)), + textEncoder.encode(stableStringify(message)), + base58.decode(id.substring("signatory_z".length)) + ); +} + +export function newRandomRecipient(): RecipientSecret { + return `recipientSecret_z${base58.encode(x25519.utils.randomPrivateKey())}`; +} + +export function getRecipientID(secret: RecipientSecret): RecipientID { + return `recipient_z${base58.encode( + x25519.getPublicKey( + base58.decode(secret.substring("recipientSecret_z".length)) + ) + )}`; +} + +// same construction as libsodium sealed_Uox +export function sealFor(recipient: RecipientID, message: JsonValue): Sealed { + const ephemeralSenderPriv = x25519.utils.randomPrivateKey(); + const ephemeralSenderPub = x25519.getPublicKey(ephemeralSenderPriv); + const recipientPub = base58.decode( + recipient.substring("recipient_z".length) + ); + const sharedSecret = x25519.getSharedSecret( + ephemeralSenderPriv, + recipientPub + ); + const nonce = blake2b(concatBytes(ephemeralSenderPub, recipientPub)).slice( + 0, + 24 + ); + + const plaintext = textEncoder.encode(stableStringify(message)); + + const sealedBox = concatBytes( + ephemeralSenderPub, + xsalsa20_poly1305(sharedSecret, nonce).encrypt(plaintext) + ); + + return `sealed_U${base64url.encode(sealedBox)}`; +} + +export function unsealAs( + recipientSecret: RecipientSecret, + sealed: Sealed +): JsonValue | undefined { + const sealedBytes = base64url.decode(sealed.substring("sealed_U".length)); + + const ephemeralSenderPub = sealedBytes.slice(0, 32); + const recipentPriv = base58.decode( + recipientSecret.substring("recipientSecret_z".length) + ); + const recipientPub = x25519.getPublicKey(recipentPriv); + const sharedSecret = x25519.getSharedSecret( + recipentPriv, + ephemeralSenderPub + ); + const nonce = blake2b(concatBytes(ephemeralSenderPub, recipientPub)).slice( + 0, + 24 + ); + + const ciphertext = sealedBytes.slice(32); + try { + const plaintext = xsalsa20_poly1305(sharedSecret, nonce).decrypt( + ciphertext + ); + return JSON.parse(textDecoder.decode(plaintext)); + } catch (e) { + return undefined; + } +} + +export type Hash = `hash_z${string}`; + +export function secureHash(value: JsonValue): Hash { + return `hash_z${base58.encode( + blake3(textEncoder.encode(stableStringify(value))) + )}`; +} + +export class StreamingHash { + state: ReturnType; + + constructor(fromClone?: ReturnType) { + this.state = fromClone || blake3.create({}); + } + + update(value: JsonValue) { + this.state.update(textEncoder.encode(stableStringify(value))); + } + + digest(): Hash { + const hash = this.state.digest(); + return `hash_z${base58.encode(hash)}`; + } + + clone(): StreamingHash { + return new StreamingHash(this.state.clone()); + } +} + +export type ShortHash = `shortHash_z${string}`; + +export function shortHash(value: JsonValue): ShortHash { + return `shortHash_z${base58.encode( + blake3(textEncoder.encode(stableStringify(value))).slice(0, 19) + )}`; +} + +export type EncryptedStreamChunk = + `encryptedChunk_U${string}`; + +export type SecretKey = `secretKey_z${string}`; + +export function newRandomSecretKey(): SecretKey { + return `secretKey_z${base58.encode(randomBytes(32))}`; +} + +export class EncryptionStream { + secretKey: Uint8Array; + nonce: Uint8Array; + counter: number; + + constructor(secretKey: SecretKey, nonce: Uint8Array) { + this.secretKey = base58.decode( + secretKey.substring("secretKey_z".length) + ); + this.nonce = nonce; + this.counter = 0; + } + + static resume(secretKey: SecretKey, nonce: Uint8Array, counter: number) { + const stream = new EncryptionStream(secretKey, nonce); + stream.counter = counter; + return stream; + } + + encrypt(value: T): EncryptedStreamChunk { + const plaintext = textEncoder.encode(stableStringify(value)); + const ciphertext = xsalsa20( + this.secretKey, + this.nonce, + plaintext, + new Uint8Array(plaintext.length), + this.counter + ); + this.counter++; + + return `encryptedChunk_U${base64url.encode(ciphertext)}`; + } +} + +export class DecryptionStream { + secretKey: Uint8Array; + nonce: Uint8Array; + counter: number; + + constructor(secretKey: SecretKey, nonce: Uint8Array) { + this.secretKey = base58.decode( + secretKey.substring("secretKey_z".length) + ); + this.nonce = nonce; + this.counter = 0; + } + + static resume(secretKey: SecretKey, nonce: Uint8Array, counter: number) { + const stream = new DecryptionStream(secretKey, nonce); + stream.counter = counter; + return stream; + } + + decrypt( + encryptedChunk: EncryptedStreamChunk + ): T | undefined { + const ciphertext = base64url.decode( + encryptedChunk.substring("encryptedChunk_U".length) + ); + const plaintext = xsalsa20( + this.secretKey, + this.nonce, + ciphertext, + new Uint8Array(ciphertext.length), + this.counter + ); + this.counter++; + + try { + return JSON.parse(textDecoder.decode(plaintext)); + } catch (e) { + return undefined; + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..fc26e7985 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,14 @@ +import { CoValue } from "./cojsonValue"; +import { JsonValue } from "./jsonValue"; +import { MultiLog } from "./multilog"; +import { LocalNode } from "./node"; + +type Value = JsonValue | CoValue; + +export { + JsonValue, + CoValue as CoJsonValue, + Value, + LocalNode as Node, + MultiLog +} diff --git a/src/jsonValue.ts b/src/jsonValue.ts new file mode 100644 index 000000000..b3bc130a0 --- /dev/null +++ b/src/jsonValue.ts @@ -0,0 +1,6 @@ +import { CoValueID, CoValue } from "./cojsonValue"; + +export type JsonAtom = string | number | boolean | null; +export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID; +export type JsonArray = JsonValue[]; +export type JsonObject = { [key: string]: JsonValue; }; diff --git a/src/multilog.test.ts b/src/multilog.test.ts new file mode 100644 index 000000000..09fa2aa0f --- /dev/null +++ b/src/multilog.test.ts @@ -0,0 +1,138 @@ +import { expect, test } from "bun:test"; +import { + MultiLog, + Transaction, + getAgent, + getAgentID, + newRandomAgentCredential, + newRandomSessionID, +} from "./multilog"; +import { LocalNode } from "./node"; +import { sign } from "./crypto"; + +test("Can create multilog with new agent credentials and add transaction to it", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "multistream", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const transaction: Transaction = { + privacy: "trusting", + madeAt: Date.now(), + changes: [ + { + hello: "world", + }, + ], + }; + + const { expectedNewHash } = multilog.expectedNewHashAfter( + node.ownSessionID, + [transaction] + ); + + expect( + multilog.tryAddTransactions( + node.ownSessionID, + [transaction], + expectedNewHash, + sign(agentCredential.signatorySecret, expectedNewHash) + ) + ).toBe(true); +}); + +test("transactions with wrong signature are rejected", () => { + const agent = newRandomAgentCredential(); + const wrongAgent = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "multistream", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const transaction: Transaction = { + privacy: "trusting", + madeAt: Date.now(), + changes: [ + { + hello: "world", + }, + ], + }; + + const { expectedNewHash } = multilog.expectedNewHashAfter( + node.ownSessionID, + [transaction] + ); + + expect( + multilog.tryAddTransactions( + node.ownSessionID, + [transaction], + expectedNewHash, + sign(wrongAgent.signatorySecret, expectedNewHash) + ) + ).toBe(false); +}); + +test("transactions with correctly signed, but wrong hash are rejected", () => { + const agent = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "multistream", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const transaction: Transaction = { + privacy: "trusting", + madeAt: Date.now(), + changes: [ + { + hello: "world", + }, + ], + }; + + const { expectedNewHash } = multilog.expectedNewHashAfter( + node.ownSessionID, + [ + { + privacy: "trusting", + madeAt: Date.now(), + changes: [ + { + hello: "wrong", + }, + ], + }, + ] + ); + + expect( + multilog.tryAddTransactions( + node.ownSessionID, + [transaction], + expectedNewHash, + sign(agent.signatorySecret, expectedNewHash) + ) + ).toBe(false); +}); diff --git a/src/multilog.ts b/src/multilog.ts new file mode 100644 index 000000000..4e1828e2f --- /dev/null +++ b/src/multilog.ts @@ -0,0 +1,348 @@ +import { randomBytes } from "@noble/hashes/utils"; +import { + CoList, + CoMap, + CoValue, + Static, + MultiStream, +} from "./cojsonValue"; +import { + EncryptedStreamChunk, + Hash, + RecipientID, + RecipientSecret, + SignatoryID, + SignatorySecret, + Signature, + StreamingHash, + getRecipientID, + getSignatoryID, + newRandomRecipient, + newRandomSignatory, + shortHash, + sign, + verify, +} from "./crypto"; +import { JsonValue } from "./jsonValue"; +import { base58 } from "@scure/base"; +import { + PermissionsDef as RulesetDef, + determineValidTransactions, +} from "./permissions"; + +export type MultiLogID = `coval_${string}`; + +export type MultiLogHeader = { + type: CoValue['type']; + ruleset: RulesetDef; + meta: JsonValue; +}; + +function multilogIDforHeader(header: MultiLogHeader): MultiLogID { + const hash = shortHash(header); + return `coval_${hash.slice("shortHash_".length)}`; +} + +export type SessionID = `session_${string}_${AgentID}`; + +export function agentIDfromSessionID(sessionID: SessionID): AgentID { + return `agent_${sessionID.substring(sessionID.lastIndexOf("_") + 1)}`; +} + +export function newRandomSessionID(agentID: AgentID): SessionID { + return `session_${base58.encode(randomBytes(8))}_${agentID}`; +} + +type SessionLog = { + transactions: Transaction[]; + lastHash?: Hash; + streamingHash: StreamingHash; + lastSignature: string; +}; + +export type PrivateTransaction = { + privacy: "private"; + madeAt: number; + encryptedChanges: EncryptedStreamChunk; +}; + +export type TrustingTransaction = { + privacy: "trusting"; + madeAt: number; + changes: JsonValue[]; +}; + +export type Transaction = PrivateTransaction | TrustingTransaction; + +export type DecryptedTransaction = { + txID: TransactionID; + changes: JsonValue[]; + madeAt: number; +}; + +export type TransactionID = { sessionID: SessionID; txIndex: number }; + +export class MultiLog { + id: MultiLogID; + header: MultiLogHeader; + sessions: { [key: SessionID]: SessionLog }; + agentCredential: AgentCredential; + ownSessionID: SessionID; + knownAgents: { [key: AgentID]: Agent }; + requiredMultiLogs: { [key: MultiLogID]: MultiLog }; + content?: CoValue; + + constructor( + header: MultiLogHeader, + agentCredential: AgentCredential, + ownSessionID: SessionID, + knownAgents: { [key: AgentID]: Agent }, + requiredMultiLogs: { [key: MultiLogID]: MultiLog } + ) { + this.id = multilogIDforHeader(header); + this.header = header; + this.sessions = {}; + this.agentCredential = agentCredential; + this.ownSessionID = ownSessionID; + this.knownAgents = knownAgents; + this.requiredMultiLogs = requiredMultiLogs; + } + + testWithDifferentCredentials( + agentCredential: AgentCredential, + ownSessionID: SessionID + ): MultiLog { + const knownAgents = { + ...this.knownAgents, + [agentIDfromSessionID(ownSessionID)]: getAgent(agentCredential), + }; + const cloned = new MultiLog( + this.header, + agentCredential, + ownSessionID, + knownAgents, + this.requiredMultiLogs + ); + + cloned.sessions = JSON.parse(JSON.stringify(this.sessions)); + + return cloned; + } + + knownState(): MultilogKnownState { + return { + header: true, + sessions: Object.fromEntries( + Object.entries(this.sessions).map(([k, v]) => [ + k, + v.transactions.length, + ]) + ), + }; + } + + get meta(): JsonValue { + return this.header?.meta ?? null; + } + + tryAddTransactions( + sessionID: SessionID, + newTransactions: Transaction[], + newHash: Hash, + newSignature: Signature + ): boolean { + const signatoryID = + this.knownAgents[agentIDfromSessionID(sessionID)]?.signatoryID; + + if (!signatoryID) { + console.warn("Unknown agent", agentIDfromSessionID(sessionID)); + return false; + } + + const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter( + sessionID, + newTransactions + ); + + if (newHash !== expectedNewHash) { + console.warn("Invalid hash", { newHash, expectedNewHash }); + return false; + } + + if (!verify(newSignature, newHash, signatoryID)) { + console.warn( + "Invalid signature", + newSignature, + newHash, + signatoryID + ); + return false; + } + + const transactions = this.sessions[sessionID]?.transactions ?? []; + + transactions.push(...newTransactions); + + this.sessions[sessionID] = { + transactions, + lastHash: newHash, + streamingHash: newStreamingHash, + lastSignature: newSignature, + }; + + this.content = undefined; + + const _ = this.getCurrentContent(); + + return true; + } + + expectedNewHashAfter( + sessionID: SessionID, + newTransactions: Transaction[] + ): { expectedNewHash: Hash; newStreamingHash: StreamingHash } { + const streamingHash = + this.sessions[sessionID]?.streamingHash.clone() ?? + new StreamingHash(); + for (const transaction of newTransactions) { + streamingHash.update(transaction); + } + + const newStreamingHash = streamingHash.clone(); + + return { + expectedNewHash: streamingHash.digest(), + newStreamingHash, + }; + } + + makeTransaction( + changes: JsonValue[], + privacy: "private" | "trusting" + ): boolean { + const madeAt = Date.now(); + + const transaction: Transaction = + privacy === "private" + ? (() => { + throw new Error("Not implemented"); + })() + : { + privacy: "trusting", + madeAt, + changes, + }; + + const sessionID = this.ownSessionID; + + const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [ + transaction, + ]); + + const signature = sign( + this.agentCredential.signatorySecret, + expectedNewHash + ); + + return this.tryAddTransactions( + sessionID, + [transaction], + expectedNewHash, + signature + ); + } + + getCurrentContent(): CoValue { + if (this.content) { + return this.content; + } + + if (this.header.type === "comap") { + this.content = new CoMap(this); + } else if (this.header.type === "colist") { + this.content = new CoList(this); + } else if (this.header.type === "multistream") { + this.content = new MultiStream(this); + } else if (this.header.type === "static") { + this.content = new Static(this); + } else { + throw new Error(`Unknown multilog type ${this.header.type}`); + } + + return this.content; + } + + getValidSortedTransactions(): DecryptedTransaction[] { + const validTransactions = determineValidTransactions(this); + + const allTransactions: DecryptedTransaction[] = validTransactions.map( + ({ txID, tx }) => { + if (tx.privacy === "private") { + throw new Error("Private transactions not supported yet"); + } else { + return { + txID, + changes: tx.changes, + madeAt: tx.madeAt, + }; + } + } + ); + // TODO: sort by timestamp, then by txID + allTransactions.sort((a, b) => a.madeAt - b.madeAt); + + return allTransactions; + } +} + +type MultilogKnownState = { + header: boolean; + sessions: { [key: SessionID]: number }; +}; + +export type AgentID = `agent_${string}`; + +export type Agent = { + signatoryID: SignatoryID; + recipientID: RecipientID; +}; + +export function getAgent(agentCredential: AgentCredential) { + return { + signatoryID: getSignatoryID(agentCredential.signatorySecret), + recipientID: getRecipientID(agentCredential.recipientSecret), + }; +} + +export function getAgentMultilogHeader(agent: Agent): MultiLogHeader { + return { + type: "comap", + ruleset: { + type: "agent", + initialSignatoryID: agent.signatoryID, + initialRecipientID: agent.recipientID, + }, + meta: null, + }; +} + +export function getAgentID(agent: Agent): AgentID { + return `agent_${multilogIDforHeader(getAgentMultilogHeader(agent)).slice( + "coval_".length + )}`; +} + +export type AgentCredential = { + signatorySecret: SignatorySecret; + recipientSecret: RecipientSecret; +}; + +export function newRandomAgentCredential(): AgentCredential { + const signatorySecret = newRandomSignatory(); + const recipientSecret = newRandomRecipient(); + return { signatorySecret, recipientSecret }; +} + +// type Role = "admin" | "writer" | "reader"; + +// type PermissionsDef = CJMap; diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 000000000..ce34db012 --- /dev/null +++ b/src/node.ts @@ -0,0 +1,75 @@ +import { + MultiLogID, + MultiLog, + AgentCredential, + AgentID, + SessionID, + Agent, + getAgent, + getAgentID, + getAgentMultilogHeader, + MultiLogHeader, +} from "./multilog"; + +export class LocalNode { + multilogs: { [key: MultiLogID]: Promise | MultiLog } = {}; + // peers: {[key: Hostname]: Peer} = {}; + agentCredential: AgentCredential; + agentID: AgentID; + ownSessionID: SessionID; + knownAgents: { [key: AgentID]: Agent } = {}; + + constructor(agentCredential: AgentCredential, ownSessionID: SessionID) { + this.agentCredential = agentCredential; + const agent = getAgent(agentCredential); + const agentID = getAgentID(agent); + this.agentID = agentID; + this.knownAgents[agentID] = agent; + this.ownSessionID = ownSessionID; + + const agentMultilog = new MultiLog( + getAgentMultilogHeader(agent), + agentCredential, + ownSessionID, + this.knownAgents, + {} + ); + this.multilogs[agentMultilog.id] = Promise.resolve(agentMultilog); + } + + createMultiLog(header: MultiLogHeader): MultiLog { + const requiredMultiLogs = header.ruleset.type === "ownedByTeam" ? { + [header.ruleset.team]: this.expectMultiLogLoaded(header.ruleset.team) + } : {}; + + const multilog = new MultiLog( + header, + this.agentCredential, + this.ownSessionID, + this.knownAgents, + requiredMultiLogs + ); + this.multilogs[multilog.id] = multilog; + return multilog; + } + + expectMultiLogLoaded(id: MultiLogID): MultiLog { + const multilog = this.multilogs[id]; + if (!multilog) { + throw new Error(`Unknown multilog ${id}`); + } + if (multilog instanceof Promise) { + throw new Error(`Multilog ${id} not yet loaded`); + } + return multilog; + } +} + +// type Hostname = string; + +// interface Peer { +// hostname: Hostname; +// incoming: ReadableStream; +// outgoing: WritableStream; +// optimisticKnownStates: {[multilogID: MultiLogID]: MultilogKnownState}; +// } diff --git a/src/permissions.test.ts b/src/permissions.test.ts new file mode 100644 index 000000000..7e008330c --- /dev/null +++ b/src/permissions.test.ts @@ -0,0 +1,311 @@ +import { test, expect } from "bun:test"; +import { + AgentID, + getAgent, + getAgentID, + newRandomAgentCredential, + newRandomSessionID, +} from "./multilog"; +import { LocalNode } from "./node"; +import { CoJsonValue } from "."; +import { CoMap } from "./cojsonValue"; +import { Role } from "./permissions"; + +function teamWithTwoAdmins() { + const { multilog, admin, adminID } = newTeam(); + + const otherAdmin = newRandomAgentCredential(); + const otherAdminID = getAgentID(getAgent(otherAdmin)); + + let content = expectTeam(multilog.getCurrentContent()); + + content.edit((editable) => { + editable.set(otherAdminID, "admin", "trusting"); + expect(editable.get(otherAdminID)).toEqual("admin"); + }); + + content = expectTeam(multilog.getCurrentContent()); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.get(otherAdminID)).toEqual("admin"); + return { multilog, admin, adminID, otherAdmin, otherAdminID }; +} + +function newTeam() { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const multilog = node.createMultiLog({ + type: "comap", + ruleset: { type: "team", initialAdmin: adminID }, + meta: null, + }); + + const content = expectTeam(multilog.getCurrentContent()); + + content.edit((editable) => { + editable.set(adminID, "admin", "trusting"); + expect(editable.get(adminID)).toEqual("admin"); + }); + + return { node, multilog, admin, adminID }; +} + +function expectTeam(content: CoJsonValue): CoMap { + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + return content as CoMap; +} + +function expectMap(content: CoJsonValue): CoMap { + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + return content as CoMap; +} + +test("Initial admin can add another admin to a team", () => { + teamWithTwoAdmins(); +}); + +test("Added admin can add a third admin to a team", () => { + const { multilog, otherAdmin, otherAdminID } = teamWithTwoAdmins(); + + const otherAdminMultilog = multilog.testWithDifferentCredentials( + otherAdmin, + newRandomSessionID(otherAdminID) + ); + + let otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + + expect(otherContent.get(otherAdminID)).toEqual("admin"); + + const thirdAdmin = newRandomAgentCredential(); + const thirdAdminID = getAgentID(getAgent(thirdAdmin)); + + otherContent.edit((editable) => { + editable.set(thirdAdminID, "admin", "trusting"); + expect(editable.get(thirdAdminID)).toEqual("admin"); + }); + + otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + + expect(otherContent.get(thirdAdminID)).toEqual("admin"); +}); + +test("Admins can't demote other admins in a team", () => { + const { multilog, adminID, otherAdmin, otherAdminID } = teamWithTwoAdmins(); + + let content = expectTeam(multilog.getCurrentContent()); + + content.edit((editable) => { + editable.set(otherAdminID, "writer", "trusting"); + expect(editable.get(otherAdminID)).toEqual("admin"); + }); + + content = expectTeam(multilog.getCurrentContent()); + expect(content.get(otherAdminID)).toEqual("admin"); + + const otherAdminMultilog = multilog.testWithDifferentCredentials( + otherAdmin, + newRandomSessionID(otherAdminID) + ); + + let otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + + otherContent.edit((editable) => { + editable.set(adminID, "writer", "trusting"); + expect(editable.get(adminID)).toEqual("admin"); + }); + + otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + + expect(otherContent.get(adminID)).toEqual("admin"); +}); + +test("Admins an add writers to a team, who can't add admins, writers, or readers", () => { + const { multilog } = newTeam(); + const writer = newRandomAgentCredential(); + const writerID = getAgentID(getAgent(writer)); + + let content = expectTeam(multilog.getCurrentContent()); + + content.edit((editable) => { + editable.set(writerID, "writer", "trusting"); + expect(editable.get(writerID)).toEqual("writer"); + }); + + content = expectTeam(multilog.getCurrentContent()); + expect(content.get(writerID)).toEqual("writer"); + + const writerMultilog = multilog.testWithDifferentCredentials( + writer, + newRandomSessionID(writerID) + ); + + let writerContent = expectTeam(writerMultilog.getCurrentContent()); + + expect(writerContent.get(writerID)).toEqual("writer"); + + const otherAgent = newRandomAgentCredential(); + const otherAgentID = getAgentID(getAgent(otherAgent)); + + writerContent.edit((editable) => { + editable.set(otherAgentID, "admin", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + + editable.set(otherAgentID, "writer", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + + editable.set(otherAgentID, "reader", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + }); + + writerContent = expectTeam(writerMultilog.getCurrentContent()); + + expect(writerContent.get(otherAgentID)).toBeUndefined(); +}); + +test("Admins can add readers to a team, who can't add admins, writers, or readers", () => { + const { multilog } = newTeam(); + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + let content = expectTeam(multilog.getCurrentContent()); + + content.edit((editable) => { + editable.set(readerID, "reader", "trusting"); + expect(editable.get(readerID)).toEqual("reader"); + }); + + content = expectTeam(multilog.getCurrentContent()); + expect(content.get(readerID)).toEqual("reader"); + + const readerMultilog = multilog.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + let readerContent = expectTeam(readerMultilog.getCurrentContent()); + + expect(readerContent.get(readerID)).toEqual("reader"); + + const otherAgent = newRandomAgentCredential(); + const otherAgentID = getAgentID(getAgent(otherAgent)); + + readerContent.edit((editable) => { + editable.set(otherAgentID, "admin", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + + editable.set(otherAgentID, "writer", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + + editable.set(otherAgentID, "reader", "trusting"); + expect(editable.get(otherAgentID)).toBeUndefined(); + }); + + readerContent = expectTeam(readerMultilog.getCurrentContent()); + + expect(readerContent.get(otherAgentID)).toBeUndefined(); +}); + +test("Admins can write to an object that is owned by their team", () => { + const { node, multilog, adminID } = newTeam(); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: multilog.id }, + meta: null, + }); + + let childContent = expectMap(childObject.getCurrentContent()); + + childContent.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toEqual("bar"); + }); + + childContent = expectMap(childObject.getCurrentContent()); + + expect(childContent.get("foo")).toEqual("bar"); +}) + +test("Writers can write to an object that is owned by their team", () => { + const { node, multilog, adminID } = newTeam(); + + const content = expectTeam(multilog.getCurrentContent()); + + const writer = newRandomAgentCredential(); + const writerID = getAgentID(getAgent(writer)); + + content.edit((editable) => { + editable.set(writerID, "writer", "trusting"); + expect(editable.get(writerID)).toEqual("writer"); + }); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: multilog.id }, + meta: null, + }); + + const childObjectAsWriter = childObject.testWithDifferentCredentials( + writer, + newRandomSessionID(writerID) + ); + + let childContent = expectMap(childObjectAsWriter.getCurrentContent()); + + childContent.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toEqual("bar"); + }); + + childContent = expectMap(childObjectAsWriter.getCurrentContent()); + + expect(childContent.get("foo")).toEqual("bar"); +}); + +test("Readers can not write to an object that is owned by their team", () => { + const { node, multilog, adminID } = newTeam(); + + const content = expectTeam(multilog.getCurrentContent()); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + content.edit((editable) => { + editable.set(readerID, "reader", "trusting"); + expect(editable.get(readerID)).toEqual("reader"); + }); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: multilog.id }, + meta: null, + }); + + const childObjectAsReader = childObject.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + let childContent = expectMap(childObjectAsReader.getCurrentContent()); + + childContent.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toBeUndefined(); + }); + + childContent = expectMap(childObjectAsReader.getCurrentContent()); + + expect(childContent.get("foo")).toBeUndefined(); +}); diff --git a/src/permissions.ts b/src/permissions.ts new file mode 100644 index 000000000..a44427ef8 --- /dev/null +++ b/src/permissions.ts @@ -0,0 +1,171 @@ +import { MapOpPayload } from "./cojsonValue"; +import { RecipientID, SignatoryID } from "./crypto"; +import { + AgentID, + MultiLog, + MultiLogID, + SessionID, + Transaction, + TransactionID, + TrustingTransaction, + agentIDfromSessionID, +} from "./multilog"; + +export type PermissionsDef = + | { type: "team"; initialAdmin: AgentID; parentTeams?: MultiLogID[] } + | { type: "ownedByTeam"; team: MultiLogID } + | { + type: "agent"; + initialSignatoryID: SignatoryID; + initialRecipientID: RecipientID; + } + | { type: "unsafeAllowAll" }; + +export type Role = "reader" | "writer" | "admin" | "revoked"; + +export function determineValidTransactions( + multilog: MultiLog +): { txID: TransactionID; tx: Transaction }[] { + if (multilog.header.ruleset.type === "team") { + const allTrustingTransactionsSorted = Object.entries( + multilog.sessions + ).flatMap(([sessionID, sessionLog]) => { + return sessionLog.transactions + .map((tx, txIndex) => ({ sessionID, txIndex, tx })) + .filter(({ tx }) => { + if (tx.privacy === "trusting") { + return true; + } else { + console.warn("Unexpected private transaction in Team"); + return false; + } + }) as { + sessionID: SessionID; + txIndex: number; + tx: TrustingTransaction; + }[]; + }); + + allTrustingTransactionsSorted.sort((a, b) => { + return a.tx.madeAt - b.tx.madeAt; + }); + + const initialAdmin = multilog.header.ruleset.initialAdmin; + + if (!initialAdmin) { + throw new Error("Team must have initialAdmin"); + } + + const memberState: { [agent: AgentID]: Role } = {}; + + const validTransactions: { txID: TransactionID; tx: Transaction }[] = + []; + + for (const { + sessionID, + txIndex, + tx, + } of allTrustingTransactionsSorted) { + // console.log("before", { memberState, validTransactions }); + const transactor = agentIDfromSessionID(sessionID); + + const change = tx.changes[0] as MapOpPayload; + if (tx.changes.length !== 1) { + console.warn("Team transaction must have exactly one change"); + continue; + } + + const affectedMember = change.key; + + if (change.op !== "insert") { + console.warn("Team transaction must set a role"); + continue; + } + + const assignedRole = change.value; + + if ( + change.value !== "admin" && + change.value !== "writer" && + change.value !== "reader" + ) { + console.warn("Team transaction must set a valid role"); + continue; + } + + const isFirstSelfAppointment = + !memberState[transactor] && + transactor === initialAdmin && + change.op === "insert" && + change.key === transactor && + change.value === "admin"; + + if (!isFirstSelfAppointment) { + if (memberState[transactor] !== "admin") { + console.warn( + "Team transaction must be made by current admin" + ); + continue; + } + + if ( + memberState[affectedMember] === "admin" && + affectedMember !== transactor && + assignedRole !== "admin" + ) { + console.warn("Admins can only demote themselves."); + continue; + } + } + + memberState[affectedMember] = change.value; + validTransactions.push({ txID: { sessionID, txIndex }, tx }); + + // console.log("after", { memberState, validTransactions }); + } + + return validTransactions; + } else if (multilog.header.ruleset.type === "ownedByTeam") { + const teamContent = + multilog.requiredMultiLogs[ + multilog.header.ruleset.team + ].getCurrentContent(); + + if (teamContent.type !== "comap") { + throw new Error("Team must be a map"); + } + + return Object.entries(multilog.sessions).flatMap( + ([sessionID, sessionLog]) => { + const transactor = agentIDfromSessionID(sessionID as SessionID); + return sessionLog.transactions + .filter((tx) => { + const transactorRoleAtTxTime = teamContent.getAtTime( + transactor, + tx.madeAt + ); + + return ( + transactorRoleAtTxTime === "admin" || + transactorRoleAtTxTime === "writer" + ); + }) + .map((tx, txIndex) => ({ + txID: { sessionID: sessionID as SessionID, txIndex }, + tx, + })); + } + ); + } else if (multilog.header.ruleset.type === "unsafeAllowAll") { + return Object.entries(multilog.sessions).flatMap( + ([sessionID, sessionLog]) => { + return sessionLog.transactions.map((tx, txIndex) => ({ + txID: { sessionID: sessionID as SessionID, txIndex }, + tx, + })); + } + ); + } else { + throw new Error("Unknown ruleset type " + multilog.header.ruleset.type); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..29f8aa003 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "preserve", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "noEmit": true, + "types": [ + "bun-types" // add Bun global + ] + } +} From e062b6cad35479a298a66a36654f1e58e1709b05 Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 19 Jul 2023 16:07:17 +0100 Subject: [PATCH 02/47] Move to non-anonymous sealing for less random data --- src/crypto.test.ts | 57 ++++++++++++++++++------- src/crypto.ts | 102 +++++++++++++++++++++++++++------------------ 2 files changed, 104 insertions(+), 55 deletions(-) diff --git a/src/crypto.test.ts b/src/crypto.test.ts index 8a50dd46a..56dff9338 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -5,15 +5,20 @@ import { secureHash, newRandomRecipient, newRandomSignatory, - sealFor, + seal, sign, - unsealAs, + openAs, verify, shortHash, newRandomSecretKey, EncryptionStream, DecryptionStream, } from "./crypto"; +import { base58, base64url } from "@scure/base"; +import { x25519 } from "@noble/curves/ed25519"; +import { xsalsa20_poly1305 } from "@noble/ciphers/_slow"; +import { blake3 } from "@noble/hashes/blake3"; +import stableStringify from "fast-json-stable-stringify"; test("Signatures round-trip and use stable stringify", () => { const data = { b: "world", a: "hello" }; @@ -35,22 +40,44 @@ test("Invalid signatures don't verify", () => { expect(verify(wrongSignature, data, getSignatoryID(signatory))).toBe(false); }); -test("Sealing round-trips", () => { +test("Sealing round-trips, but invalid receiver can't unseal", () => { const data = { b: "world", a: "hello" }; - const recipient = newRandomRecipient(); - const sealed = sealFor(getRecipientID(recipient), data); - - expect(sealed).toMatch(/^sealed_U/); - expect(unsealAs(recipient, sealed)).toEqual(data); -}); - -test("Invalid receiver can't unseal", () => { - const data = { b: "world", a: "hello" }; - const recipient = newRandomRecipient(); + const sender = newRandomRecipient(); + const recipient1 = newRandomRecipient(); const recipient2 = newRandomRecipient(); - const sealed = sealFor(getRecipientID(recipient), data); + const recipient3 = newRandomRecipient(); - expect(unsealAs(recipient2, sealed)).toBeUndefined(); + const nOnceMaterial = { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + } as const; + + const sealed = seal(data, sender, new Set([getRecipientID(recipient1), getRecipientID(recipient2)]), nOnceMaterial); + + console.log(sealed) + + expect(sealed[getRecipientID(recipient1)]).toMatch(/^sealed_U/); + expect(sealed[getRecipientID(recipient2)]).toMatch(/^sealed_U/); + expect(openAs(sealed, recipient1, getRecipientID(sender), nOnceMaterial)).toEqual(data); + expect(openAs(sealed, recipient2, getRecipientID(sender), nOnceMaterial)).toEqual(data); + expect(openAs(sealed, recipient3, getRecipientID(sender), nOnceMaterial)).toBeUndefined(); + + // trying with wrong recipient secret, by hand + const nOnce = blake3( + (new TextEncoder).encode(stableStringify(nOnceMaterial)) + ).slice(0, 24); + const recipient3priv = base58.decode( + recipient3.substring("recipientSecret_z".length) + ); + const senderPub = base58.decode(getRecipientID(sender).substring("recipient_z".length)); + const sealedBytes = base64url.decode(sealed[getRecipientID(recipient1)].substring("sealed_U".length)); + const sharedSecret = x25519.getSharedSecret(recipient3priv, senderPub); + + expect(() => { + const _ = xsalsa20_poly1305(sharedSecret, nOnce).decrypt( + sealedBytes + ); + }).toThrow("Wrong tag"); }); test("Hashing is deterministic", () => { diff --git a/src/crypto.ts b/src/crypto.ts index 276d0cdcd..bde8c8bb8 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -3,10 +3,9 @@ import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa"; import { JsonValue } from "./jsonValue"; import { base58, base64url } from "@scure/base"; import stableStringify from "fast-json-stable-stringify"; -import { blake2b } from "@noble/hashes/blake2b"; -import { concatBytes } from "@noble/ciphers/utils"; import { blake3 } from "@noble/hashes/blake3"; import { randomBytes } from "@noble/ciphers/webcrypto/utils"; +import { MultiLogID, TransactionID } from "./multilog"; export type SignatorySecret = `signatorySecret_z${string}`; export type SignatoryID = `signatory_z${string}`; @@ -65,57 +64,80 @@ export function getRecipientID(secret: RecipientSecret): RecipientID { )}`; } -// same construction as libsodium sealed_Uox -export function sealFor(recipient: RecipientID, message: JsonValue): Sealed { - const ephemeralSenderPriv = x25519.utils.randomPrivateKey(); - const ephemeralSenderPub = x25519.getPublicKey(ephemeralSenderPriv); - const recipientPub = base58.decode( - recipient.substring("recipient_z".length) - ); - const sharedSecret = x25519.getSharedSecret( - ephemeralSenderPriv, - recipientPub - ); - const nonce = blake2b(concatBytes(ephemeralSenderPub, recipientPub)).slice( - 0, - 24 +type SealedSet = { + [recipient: RecipientID]: Sealed; +}; + +export function seal( + message: JsonValue, + from: RecipientSecret, + to: Set, + nOnceMaterial: { in: MultiLogID; tx: TransactionID } +): SealedSet { + const nOnce = blake3( + textEncoder.encode(stableStringify(nOnceMaterial)) + ).slice(0, 24); + + const recipientsSorted = Array.from(to).sort(); + const recipientPubs = recipientsSorted.map((recipient) => { + return base58.decode(recipient.substring("recipient_z".length)); + }); + const senderPriv = base58.decode( + from.substring("recipientSecret_z".length) ); const plaintext = textEncoder.encode(stableStringify(message)); - const sealedBox = concatBytes( - ephemeralSenderPub, - xsalsa20_poly1305(sharedSecret, nonce).encrypt(plaintext) - ); + const sealedSet: SealedSet = {}; - return `sealed_U${base64url.encode(sealedBox)}`; + for (let i = 0; i < recipientsSorted.length; i++) { + const recipient = recipientsSorted[i]; + const sharedSecret = x25519.getSharedSecret( + senderPriv, + recipientPubs[i] + ); + + const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt( + plaintext + ); + + sealedSet[recipient] = `sealed_U${base64url.encode(sealedBytes)}`; + } + + return sealedSet; } -export function unsealAs( - recipientSecret: RecipientSecret, - sealed: Sealed +export function openAs( + sealedSet: SealedSet, + recipient: RecipientSecret, + from: RecipientID, + nOnceMaterial: { in: MultiLogID; tx: TransactionID } ): JsonValue | undefined { + const nOnce = blake3( + textEncoder.encode(stableStringify(nOnceMaterial)) + ).slice(0, 24); + + const recipientPriv = base58.decode( + recipient.substring("recipientSecret_z".length) + ); + + const senderPub = base58.decode(from.substring("recipient_z".length)); + + const sealed = sealedSet[getRecipientID(recipient)]; + console.log("sealed", sealed); + if (!sealed) { + return undefined; + } + const sealedBytes = base64url.decode(sealed.substring("sealed_U".length)); - const ephemeralSenderPub = sealedBytes.slice(0, 32); - const recipentPriv = base58.decode( - recipientSecret.substring("recipientSecret_z".length) - ); - const recipientPub = x25519.getPublicKey(recipentPriv); - const sharedSecret = x25519.getSharedSecret( - recipentPriv, - ephemeralSenderPub - ); - const nonce = blake2b(concatBytes(ephemeralSenderPub, recipientPub)).slice( - 0, - 24 + const sharedSecret = x25519.getSharedSecret(recipientPriv, senderPub); + + const plaintext = xsalsa20_poly1305(sharedSecret, nOnce).decrypt( + sealedBytes ); - const ciphertext = sealedBytes.slice(32); try { - const plaintext = xsalsa20_poly1305(sharedSecret, nonce).decrypt( - ciphertext - ); return JSON.parse(textDecoder.decode(plaintext)); } catch (e) { return undefined; From f3a4b2b3f3da81273bd467b09547b10ac6115063 Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 19 Jul 2023 16:39:58 +0100 Subject: [PATCH 03/47] Clean up permission tests --- src/permissions.test.ts | 110 +++++++++++++++++++--------------------- 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 7e008330c..9a18cfacc 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -12,26 +12,26 @@ import { CoMap } from "./cojsonValue"; import { Role } from "./permissions"; function teamWithTwoAdmins() { - const { multilog, admin, adminID } = newTeam(); + const { team, admin, adminID } = newTeam(); const otherAdmin = newRandomAgentCredential(); const otherAdminID = getAgentID(getAgent(otherAdmin)); - let content = expectTeam(multilog.getCurrentContent()); + let content = expectTeam(team.getCurrentContent()); content.edit((editable) => { editable.set(otherAdminID, "admin", "trusting"); expect(editable.get(otherAdminID)).toEqual("admin"); }); - content = expectTeam(multilog.getCurrentContent()); + content = expectTeam(team.getCurrentContent()); if (content.type !== "comap") { throw new Error("Expected map"); } expect(content.get(otherAdminID)).toEqual("admin"); - return { multilog, admin, adminID, otherAdmin, otherAdminID }; + return { team, admin, adminID, otherAdmin, otherAdminID }; } function newTeam() { @@ -40,20 +40,20 @@ function newTeam() { const node = new LocalNode(admin, newRandomSessionID(adminID)); - const multilog = node.createMultiLog({ + const team = node.createMultiLog({ type: "comap", ruleset: { type: "team", initialAdmin: adminID }, meta: null, }); - const content = expectTeam(multilog.getCurrentContent()); + const teamContent = expectTeam(team.getCurrentContent()); - content.edit((editable) => { + teamContent.edit((editable) => { editable.set(adminID, "admin", "trusting"); expect(editable.get(adminID)).toEqual("admin"); }); - return { node, multilog, admin, adminID }; + return { node, team, admin, adminID }; } function expectTeam(content: CoJsonValue): CoMap { @@ -77,14 +77,14 @@ test("Initial admin can add another admin to a team", () => { }); test("Added admin can add a third admin to a team", () => { - const { multilog, otherAdmin, otherAdminID } = teamWithTwoAdmins(); + const { team, otherAdmin, otherAdminID } = teamWithTwoAdmins(); - const otherAdminMultilog = multilog.testWithDifferentCredentials( + const teamAsOtherAdmin = team.testWithDifferentCredentials( otherAdmin, newRandomSessionID(otherAdminID) ); - let otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + let otherContent = expectTeam(teamAsOtherAdmin.getCurrentContent()); expect(otherContent.get(otherAdminID)).toEqual("admin"); @@ -96,69 +96,69 @@ test("Added admin can add a third admin to a team", () => { expect(editable.get(thirdAdminID)).toEqual("admin"); }); - otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + otherContent = expectTeam(teamAsOtherAdmin.getCurrentContent()); expect(otherContent.get(thirdAdminID)).toEqual("admin"); }); test("Admins can't demote other admins in a team", () => { - const { multilog, adminID, otherAdmin, otherAdminID } = teamWithTwoAdmins(); + const { team, adminID, otherAdmin, otherAdminID } = teamWithTwoAdmins(); - let content = expectTeam(multilog.getCurrentContent()); + let teamContent = expectTeam(team.getCurrentContent()); - content.edit((editable) => { + teamContent.edit((editable) => { editable.set(otherAdminID, "writer", "trusting"); expect(editable.get(otherAdminID)).toEqual("admin"); }); - content = expectTeam(multilog.getCurrentContent()); - expect(content.get(otherAdminID)).toEqual("admin"); + teamContent = expectTeam(team.getCurrentContent()); + expect(teamContent.get(otherAdminID)).toEqual("admin"); - const otherAdminMultilog = multilog.testWithDifferentCredentials( + const teamAsOtherAdmin = team.testWithDifferentCredentials( otherAdmin, newRandomSessionID(otherAdminID) ); - let otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + let teamContentAsOtherAdmin = expectTeam(teamAsOtherAdmin.getCurrentContent()); - otherContent.edit((editable) => { + teamContentAsOtherAdmin.edit((editable) => { editable.set(adminID, "writer", "trusting"); expect(editable.get(adminID)).toEqual("admin"); }); - otherContent = expectTeam(otherAdminMultilog.getCurrentContent()); + teamContentAsOtherAdmin = expectTeam(teamAsOtherAdmin.getCurrentContent()); - expect(otherContent.get(adminID)).toEqual("admin"); + expect(teamContentAsOtherAdmin.get(adminID)).toEqual("admin"); }); test("Admins an add writers to a team, who can't add admins, writers, or readers", () => { - const { multilog } = newTeam(); + const { team } = newTeam(); const writer = newRandomAgentCredential(); const writerID = getAgentID(getAgent(writer)); - let content = expectTeam(multilog.getCurrentContent()); + let teamContent = expectTeam(team.getCurrentContent()); - content.edit((editable) => { + teamContent.edit((editable) => { editable.set(writerID, "writer", "trusting"); expect(editable.get(writerID)).toEqual("writer"); }); - content = expectTeam(multilog.getCurrentContent()); - expect(content.get(writerID)).toEqual("writer"); + teamContent = expectTeam(team.getCurrentContent()); + expect(teamContent.get(writerID)).toEqual("writer"); - const writerMultilog = multilog.testWithDifferentCredentials( + const teamAsWriter = team.testWithDifferentCredentials( writer, newRandomSessionID(writerID) ); - let writerContent = expectTeam(writerMultilog.getCurrentContent()); + let teamContentAsWriter = expectTeam(teamAsWriter.getCurrentContent()); - expect(writerContent.get(writerID)).toEqual("writer"); + expect(teamContentAsWriter.get(writerID)).toEqual("writer"); const otherAgent = newRandomAgentCredential(); const otherAgentID = getAgentID(getAgent(otherAgent)); - writerContent.edit((editable) => { + teamContentAsWriter.edit((editable) => { editable.set(otherAgentID, "admin", "trusting"); expect(editable.get(otherAgentID)).toBeUndefined(); @@ -169,39 +169,39 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers expect(editable.get(otherAgentID)).toBeUndefined(); }); - writerContent = expectTeam(writerMultilog.getCurrentContent()); + teamContentAsWriter = expectTeam(teamAsWriter.getCurrentContent()); - expect(writerContent.get(otherAgentID)).toBeUndefined(); + expect(teamContentAsWriter.get(otherAgentID)).toBeUndefined(); }); test("Admins can add readers to a team, who can't add admins, writers, or readers", () => { - const { multilog } = newTeam(); + const { team } = newTeam(); const reader = newRandomAgentCredential(); const readerID = getAgentID(getAgent(reader)); - let content = expectTeam(multilog.getCurrentContent()); + let teamContent = expectTeam(team.getCurrentContent()); - content.edit((editable) => { + teamContent.edit((editable) => { editable.set(readerID, "reader", "trusting"); expect(editable.get(readerID)).toEqual("reader"); }); - content = expectTeam(multilog.getCurrentContent()); - expect(content.get(readerID)).toEqual("reader"); + teamContent = expectTeam(team.getCurrentContent()); + expect(teamContent.get(readerID)).toEqual("reader"); - const readerMultilog = multilog.testWithDifferentCredentials( + const teamAsReader = team.testWithDifferentCredentials( reader, newRandomSessionID(readerID) ); - let readerContent = expectTeam(readerMultilog.getCurrentContent()); + let teamContentAsReader = expectTeam(teamAsReader.getCurrentContent()); - expect(readerContent.get(readerID)).toEqual("reader"); + expect(teamContentAsReader.get(readerID)).toEqual("reader"); const otherAgent = newRandomAgentCredential(); const otherAgentID = getAgentID(getAgent(otherAgent)); - readerContent.edit((editable) => { + teamContentAsReader.edit((editable) => { editable.set(otherAgentID, "admin", "trusting"); expect(editable.get(otherAgentID)).toBeUndefined(); @@ -212,17 +212,17 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader expect(editable.get(otherAgentID)).toBeUndefined(); }); - readerContent = expectTeam(readerMultilog.getCurrentContent()); + teamContentAsReader = expectTeam(teamAsReader.getCurrentContent()); - expect(readerContent.get(otherAgentID)).toBeUndefined(); + expect(teamContentAsReader.get(otherAgentID)).toBeUndefined(); }); test("Admins can write to an object that is owned by their team", () => { - const { node, multilog, adminID } = newTeam(); + const { node, team } = newTeam(); const childObject = node.createMultiLog({ type: "comap", - ruleset: { type: "ownedByTeam", team: multilog.id }, + ruleset: { type: "ownedByTeam", team: team.id }, meta: null, }); @@ -236,24 +236,22 @@ test("Admins can write to an object that is owned by their team", () => { childContent = expectMap(childObject.getCurrentContent()); expect(childContent.get("foo")).toEqual("bar"); -}) +}); test("Writers can write to an object that is owned by their team", () => { - const { node, multilog, adminID } = newTeam(); - - const content = expectTeam(multilog.getCurrentContent()); + const { node, team } = newTeam(); const writer = newRandomAgentCredential(); const writerID = getAgentID(getAgent(writer)); - content.edit((editable) => { + expectTeam(team.getCurrentContent()).edit((editable) => { editable.set(writerID, "writer", "trusting"); expect(editable.get(writerID)).toEqual("writer"); }); const childObject = node.createMultiLog({ type: "comap", - ruleset: { type: "ownedByTeam", team: multilog.id }, + ruleset: { type: "ownedByTeam", team: team.id }, meta: null, }); @@ -275,21 +273,19 @@ test("Writers can write to an object that is owned by their team", () => { }); test("Readers can not write to an object that is owned by their team", () => { - const { node, multilog, adminID } = newTeam(); - - const content = expectTeam(multilog.getCurrentContent()); + const { node, team } = newTeam(); const reader = newRandomAgentCredential(); const readerID = getAgentID(getAgent(reader)); - content.edit((editable) => { + expectTeam(team.getCurrentContent()).edit((editable) => { editable.set(readerID, "reader", "trusting"); expect(editable.get(readerID)).toEqual("reader"); }); const childObject = node.createMultiLog({ type: "comap", - ruleset: { type: "ownedByTeam", team: multilog.id }, + ruleset: { type: "ownedByTeam", team: team.id }, meta: null, }); From a335cc05265bd35a780d059b0466c1650583ccda Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 19 Jul 2023 17:16:13 +0100 Subject: [PATCH 04/47] Structural types for CoMap --- src/cojsonValue.ts | 57 ++++++++++++++++++++++------------------- src/permissions.test.ts | 10 ++++---- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/cojsonValue.ts b/src/cojsonValue.ts index 37f65f353..a9466a17c 100644 --- a/src/cojsonValue.ts +++ b/src/cojsonValue.ts @@ -6,7 +6,7 @@ export type CoValueID = MultiLogID & { }; export type CoValue = - | CoMap + | CoMap<{[key: string]: JsonValue}, JsonValue> | CoList | MultiStream | Static; @@ -30,49 +30,52 @@ export type MapOpPayload = }; export class CoMap< - K extends string, - V extends JsonValue, - Meta extends JsonValue + 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>; + id: CoValueID>; multiLog: MultiLog; type: "comap" = "comap"; - ops: Map[]>; + ops: {[KK in K]?: MapOp[]}; constructor(multiLog: MultiLog) { - this.id = multiLog.id as CoValueID>; + this.id = multiLog.id as CoValueID>; this.multiLog = multiLog; - this.ops = new Map(); + this.ops = {}; this.fillOpsFromMultilog(); } protected fillOpsFromMultilog() { for (const { txID, changes, madeAt } of this.multiLog.getValidSortedTransactions()) { - for (const [changeIdx, change] of ( - changes as MapOpPayload[] + for (const [changeIdx, changeUntyped] of ( + changes ).entries()) { - let entries = this.ops.get(change.key); + const change = changeUntyped as MapOpPayload + let entries = this.ops[change.key]; if (!entries) { entries = []; - this.ops.set(change.key, entries); + this.ops[change.key] = entries; } entries.push({ txID, madeAt, changeIdx, - ...change, + ...(change as any), }); } } } - keys(): IterableIterator { - return this.ops.keys(); + keys(): K[] { + return Object.keys(this.ops) as K[]; } - get(key: K): V | undefined { - const ops = this.ops.get(key); + get(key: KK): M[KK] | undefined { + const ops = this.ops[key]; if (!ops) { return undefined; } @@ -86,8 +89,8 @@ export class CoMap< } } - getAtTime(key: K, time: number): V | undefined { - const ops = this.ops.get(key); + getAtTime(key: KK, time: number): M[KK] | undefined { + const ops = this.ops[key]; if (!ops) { return undefined; } @@ -118,19 +121,21 @@ export class CoMap< return json; } - edit(changer: (editable: WriteableCoMap) => void): void { - const editable = new WriteableCoMap(this.multiLog); + edit(changer: (editable: WriteableCoMap) => void): void { + const editable = new WriteableCoMap(this.multiLog); changer(editable); } } export class WriteableCoMap< - K extends string, - V extends JsonValue, - Meta extends JsonValue -> extends 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]} +> extends CoMap { // TODO: change default to private - set(key: K, value: V, privacy: "private" | "trusting" = "trusting"): void { + set(key: KK, value: M[KK], privacy: "private" | "trusting" = "trusting"): void { this.multiLog.makeTransaction([ { op: "insert", diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 9a18cfacc..e2cb6b336 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -56,20 +56,20 @@ function newTeam() { return { node, team, admin, adminID }; } -function expectTeam(content: CoJsonValue): CoMap { +function expectTeam(content: CoJsonValue): CoMap<{[key: AgentID]: Role}, {}> { if (content.type !== "comap") { throw new Error("Expected map"); } - return content as CoMap; + return content as CoMap<{[key: AgentID]: Role}, {}>; } -function expectMap(content: CoJsonValue): CoMap { +function expectMap(content: CoJsonValue): CoMap<{[key: string]: string}, {}> { if (content.type !== "comap") { throw new Error("Expected map"); } - return content as CoMap; + return content as CoMap<{[key: string]: string}, {}>; } test("Initial admin can add another admin to a team", () => { @@ -304,4 +304,4 @@ test("Readers can not write to an object that is owned by their team", () => { childContent = expectMap(childObjectAsReader.getCurrentContent()); expect(childContent.get("foo")).toBeUndefined(); -}); +}); \ No newline at end of file From cb4f75801c4bf1f16083c0acdc2ebd9709474a75 Mon Sep 17 00:00:00 2001 From: Anselm Date: Thu, 20 Jul 2023 13:57:15 +0100 Subject: [PATCH 05/47] Simple tx encryption based on current key --- src/cojsonValue.test.ts | 35 +++++++++++ src/cojsonValue.ts | 19 ++++++ src/crypto.test.ts | 89 ++++++++++++++++----------- src/crypto.ts | 131 +++++++++++++++------------------------- src/index.ts | 4 +- src/multilog.ts | 116 ++++++++++++++++++++++++++++------- src/permissions.test.ts | 70 ++++++++++++++------- src/permissions.ts | 38 ++++++++++-- 8 files changed, 335 insertions(+), 167 deletions(-) diff --git a/src/cojsonValue.test.ts b/src/cojsonValue.test.ts index bb613dc05..14652a2ae 100644 --- a/src/cojsonValue.test.ts +++ b/src/cojsonValue.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "bun:test"; import { + agentIDfromSessionID, getAgent, getAgentID, newRandomAgentCredential, @@ -100,4 +101,38 @@ test("Can get map entry values at different points in time", () => { expect(editable.getAtTime("hello", beforeB)).toEqual("A"); expect(editable.getAtTime("hello", beforeC)).toEqual("B"); }); +}); + +test("Can get last tx ID for a key", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = multilog.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + + content.edit((editable) => { + expect(editable.getLastTxID("hello")).toEqual(undefined); + editable.set("hello", "A"); + const sessionID = editable.getLastTxID("hello")?.sessionID + expect(sessionID && agentIDfromSessionID(sessionID)).toEqual(getAgentID(getAgent(agentCredential))); + expect(editable.getLastTxID("hello")?.txIndex).toEqual(0); + editable.set("hello", "B"); + expect(editable.getLastTxID("hello")?.txIndex).toEqual(1); + editable.set("hello", "C"); + expect(editable.getLastTxID("hello")?.txIndex).toEqual(2); + }); }) \ No newline at end of file diff --git a/src/cojsonValue.ts b/src/cojsonValue.ts index a9466a17c..f6d31a49f 100644 --- a/src/cojsonValue.ts +++ b/src/cojsonValue.ts @@ -108,6 +108,17 @@ export class CoMap< } } + getLastTxID(key: KK): TransactionID | undefined { + const ops = this.ops[key]; + if (!ops) { + return undefined; + } + + const lastEntry = ops[ops.length - 1]; + + return lastEntry.txID; + } + toJSON(): JsonObject { const json: JsonObject = {}; @@ -186,3 +197,11 @@ export class Static { this.id = multilog.id as CoValueID>; } } + +export function expectMap(content: CoValue): CoMap<{ [key: string]: string }, {}> { + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + return content as CoMap<{ [key: string]: string }, {}>; +} diff --git a/src/crypto.test.ts b/src/crypto.test.ts index 56dff9338..5f6b2de92 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -10,9 +10,9 @@ import { openAs, verify, shortHash, - newRandomSecretKey, - EncryptionStream, - DecryptionStream, + newRandomKeySecret, + encrypt, + decrypt, } from "./crypto"; import { base58, base64url } from "@scure/base"; import { x25519 } from "@noble/curves/ed25519"; @@ -52,31 +52,42 @@ test("Sealing round-trips, but invalid receiver can't unseal", () => { tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, } as const; - const sealed = seal(data, sender, new Set([getRecipientID(recipient1), getRecipientID(recipient2)]), nOnceMaterial); - - console.log(sealed) + const sealed = seal( + data, + sender, + new Set([getRecipientID(recipient1), getRecipientID(recipient2)]), + nOnceMaterial + ); expect(sealed[getRecipientID(recipient1)]).toMatch(/^sealed_U/); expect(sealed[getRecipientID(recipient2)]).toMatch(/^sealed_U/); - expect(openAs(sealed, recipient1, getRecipientID(sender), nOnceMaterial)).toEqual(data); - expect(openAs(sealed, recipient2, getRecipientID(sender), nOnceMaterial)).toEqual(data); - expect(openAs(sealed, recipient3, getRecipientID(sender), nOnceMaterial)).toBeUndefined(); + expect( + openAs(sealed, recipient1, getRecipientID(sender), nOnceMaterial) + ).toEqual(data); + expect( + openAs(sealed, recipient2, getRecipientID(sender), nOnceMaterial) + ).toEqual(data); + expect( + openAs(sealed, recipient3, getRecipientID(sender), nOnceMaterial) + ).toBeUndefined(); // trying with wrong recipient secret, by hand const nOnce = blake3( - (new TextEncoder).encode(stableStringify(nOnceMaterial)) + new TextEncoder().encode(stableStringify(nOnceMaterial)) ).slice(0, 24); const recipient3priv = base58.decode( recipient3.substring("recipientSecret_z".length) ); - const senderPub = base58.decode(getRecipientID(sender).substring("recipient_z".length)); - const sealedBytes = base64url.decode(sealed[getRecipientID(recipient1)].substring("sealed_U".length)); + const senderPub = base58.decode( + getRecipientID(sender).substring("recipient_z".length) + ); + const sealedBytes = base64url.decode( + sealed[getRecipientID(recipient1)].substring("sealed_U".length) + ); const sharedSecret = x25519.getSharedSecret(recipient3priv, senderPub); expect(() => { - const _ = xsalsa20_poly1305(sharedSecret, nOnce).decrypt( - sealedBytes - ); + const _ = xsalsa20_poly1305(sharedSecret, nOnce).decrypt(sealedBytes); }).toThrow("Wrong tag"); }); @@ -91,39 +102,49 @@ test("Hashing is deterministic", () => { }); test("Encryption streams round-trip", () => { - const secretKey = newRandomSecretKey(); - const nonce = new Uint8Array(24); - - const encryptionStream = new EncryptionStream(secretKey, nonce); - const decryptionStream = new DecryptionStream(secretKey, nonce); + const { secret } = newRandomKeySecret(); const encryptedChunks = [ - encryptionStream.encrypt({ a: "hello" }), - encryptionStream.encrypt({ b: "world" }), + encrypt({ a: "hello" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }), + encrypt({ b: "world" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + }), ]; - const decryptedChunks = encryptedChunks.map((chunk) => - decryptionStream.decrypt(chunk) + const decryptedChunks = encryptedChunks.map((chunk, i) => + decrypt(chunk, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, + }) ); expect(decryptedChunks).toEqual([{ a: "hello" }, { b: "world" }]); }); test("Encryption streams don't decrypt with a wrong key", () => { - const secretKey = newRandomSecretKey(); - const secretKey2 = newRandomSecretKey(); - const nonce = new Uint8Array(24); - - const encryptionStream = new EncryptionStream(secretKey, nonce); - const decryptionStream = new DecryptionStream(secretKey2, nonce); + const { secret } = newRandomKeySecret(); + const { secret: secret2 } = newRandomKeySecret(); const encryptedChunks = [ - encryptionStream.encrypt({ a: "hello" }), - encryptionStream.encrypt({ b: "world" }), + encrypt({ a: "hello" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }), + encrypt({ b: "world" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + }), ]; - const decryptedChunks = encryptedChunks.map((chunk) => - decryptionStream.decrypt(chunk) + const decryptedChunks = encryptedChunks.map((chunk, i) => + decrypt(chunk, secret2, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, + }) ); expect(decryptedChunks).toEqual([undefined, undefined]); diff --git a/src/crypto.ts b/src/crypto.ts index bde8c8bb8..2c9a8140e 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -5,7 +5,7 @@ import { base58, base64url } from "@scure/base"; import stableStringify from "fast-json-stable-stringify"; import { blake3 } from "@noble/hashes/blake3"; import { randomBytes } from "@noble/ciphers/webcrypto/utils"; -import { MultiLogID, TransactionID } from "./multilog"; +import { MultiLogID, SessionID, TransactionID } from "./multilog"; export type SignatorySecret = `signatorySecret_z${string}`; export type SignatoryID = `signatory_z${string}`; @@ -64,7 +64,7 @@ export function getRecipientID(secret: RecipientSecret): RecipientID { )}`; } -type SealedSet = { +export type SealedSet = { [recipient: RecipientID]: Sealed; }; @@ -124,7 +124,7 @@ export function openAs( const senderPub = base58.decode(from.substring("recipient_z".length)); const sealed = sealedSet[getRecipientID(recipient)]; - console.log("sealed", sealed); + if (!sealed) { return undefined; } @@ -181,87 +181,56 @@ export function shortHash(value: JsonValue): ShortHash { )}`; } -export type EncryptedStreamChunk = - `encryptedChunk_U${string}`; +export type Encrypted = + `encrypted_U${string}`; -export type SecretKey = `secretKey_z${string}`; +export type KeySecret = `keySecret_z${string}`; +export type KeyID = `key_z${string}`; -export function newRandomSecretKey(): SecretKey { - return `secretKey_z${base58.encode(randomBytes(32))}`; +export function newRandomKeySecret(): { secret: KeySecret; id: KeyID } { + return { + secret: `keySecret_z${base58.encode(randomBytes(32))}`, + id: `key_z${base58.encode(randomBytes(12))}`, + }; } -export class EncryptionStream { - secretKey: Uint8Array; - nonce: Uint8Array; - counter: number; +export function encrypt(value: T, keySecret: KeySecret, nOnceMaterial: {in: MultiLogID, tx: TransactionID}): Encrypted { + const keySecretBytes = base58.decode( + keySecret.substring("keySecret_z".length) + ); + const nOnce = blake3( + textEncoder.encode(stableStringify(nOnceMaterial)) + ).slice(0, 24); - constructor(secretKey: SecretKey, nonce: Uint8Array) { - this.secretKey = base58.decode( - secretKey.substring("secretKey_z".length) - ); - this.nonce = nonce; - this.counter = 0; + const plaintext = textEncoder.encode(stableStringify(value)); + const ciphertext = xsalsa20( + keySecretBytes, + nOnce, + plaintext, + ); + return `encrypted_U${base64url.encode(ciphertext)}`; +}; + +export function decrypt(encrypted: Encrypted, keySecret: KeySecret, nOnceMaterial: {in: MultiLogID, tx: TransactionID}): T | undefined { + const keySecretBytes = base58.decode( + keySecret.substring("keySecret_z".length) + ); + const nOnce = blake3( + textEncoder.encode(stableStringify(nOnceMaterial)) + ).slice(0, 24); + + const ciphertext = base64url.decode( + encrypted.substring("encrypted_U".length) + ); + const plaintext = xsalsa20( + keySecretBytes, + nOnce, + ciphertext, + ); + + try { + return JSON.parse(textDecoder.decode(plaintext)); + } catch (e) { + return undefined; } - - static resume(secretKey: SecretKey, nonce: Uint8Array, counter: number) { - const stream = new EncryptionStream(secretKey, nonce); - stream.counter = counter; - return stream; - } - - encrypt(value: T): EncryptedStreamChunk { - const plaintext = textEncoder.encode(stableStringify(value)); - const ciphertext = xsalsa20( - this.secretKey, - this.nonce, - plaintext, - new Uint8Array(plaintext.length), - this.counter - ); - this.counter++; - - return `encryptedChunk_U${base64url.encode(ciphertext)}`; - } -} - -export class DecryptionStream { - secretKey: Uint8Array; - nonce: Uint8Array; - counter: number; - - constructor(secretKey: SecretKey, nonce: Uint8Array) { - this.secretKey = base58.decode( - secretKey.substring("secretKey_z".length) - ); - this.nonce = nonce; - this.counter = 0; - } - - static resume(secretKey: SecretKey, nonce: Uint8Array, counter: number) { - const stream = new DecryptionStream(secretKey, nonce); - stream.counter = counter; - return stream; - } - - decrypt( - encryptedChunk: EncryptedStreamChunk - ): T | undefined { - const ciphertext = base64url.decode( - encryptedChunk.substring("encryptedChunk_U".length) - ); - const plaintext = xsalsa20( - this.secretKey, - this.nonce, - ciphertext, - new Uint8Array(ciphertext.length), - this.counter - ); - this.counter++; - - try { - return JSON.parse(textDecoder.decode(plaintext)); - } catch (e) { - return undefined; - } - } -} +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index fc26e7985..36d9a29d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,8 @@ type Value = JsonValue | CoValue; export { JsonValue, - CoValue as CoJsonValue, + CoValue, Value, - LocalNode as Node, + LocalNode, MultiLog } diff --git a/src/multilog.ts b/src/multilog.ts index 4e1828e2f..43678d875 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -1,14 +1,9 @@ import { randomBytes } from "@noble/hashes/utils"; +import { CoList, CoMap, CoValue, Static, MultiStream } from "./cojsonValue"; import { - CoList, - CoMap, - CoValue, - Static, - MultiStream, -} from "./cojsonValue"; -import { - EncryptedStreamChunk, + Encrypted, Hash, + KeySecret, RecipientID, RecipientSecret, SignatoryID, @@ -19,21 +14,25 @@ import { getSignatoryID, newRandomRecipient, newRandomSignatory, + openAs, shortHash, sign, verify, + encrypt, + decrypt, } from "./crypto"; import { JsonValue } from "./jsonValue"; import { base58 } from "@scure/base"; import { PermissionsDef as RulesetDef, determineValidTransactions, + expectTeam, } from "./permissions"; export type MultiLogID = `coval_${string}`; export type MultiLogHeader = { - type: CoValue['type']; + type: CoValue["type"]; ruleset: RulesetDef; meta: JsonValue; }; @@ -63,7 +62,7 @@ type SessionLog = { export type PrivateTransaction = { privacy: "private"; madeAt: number; - encryptedChanges: EncryptedStreamChunk; + encryptedChanges: Encrypted; }; export type TrustingTransaction = { @@ -145,6 +144,14 @@ export class MultiLog { return this.header?.meta ?? null; } + nextTransactionID(): TransactionID { + const sessionID = this.ownSessionID; + return { + sessionID, + txIndex: this.sessions[sessionID]?.transactions.length || 0, + }; + } + tryAddTransactions( sessionID: SessionID, newTransactions: Transaction[], @@ -224,9 +231,18 @@ export class MultiLog { const transaction: Transaction = privacy === "private" - ? (() => { - throw new Error("Not implemented"); - })() + ? { + privacy: "private", + madeAt, + encryptedChanges: encrypt( + changes, + this.getCurrentReadKey(), + { + in: this.id, + tx: this.nextTransactionID(), + } + ), + } : { privacy: "trusting", madeAt, @@ -277,15 +293,21 @@ export class MultiLog { const allTransactions: DecryptedTransaction[] = validTransactions.map( ({ txID, tx }) => { - if (tx.privacy === "private") { - throw new Error("Private transactions not supported yet"); - } else { - return { - txID, - changes: tx.changes, - madeAt: tx.madeAt, - }; - } + return { + txID, + madeAt: tx.madeAt, + changes: + tx.privacy === "private" + ? decrypt( + tx.encryptedChanges, + this.getCurrentReadKey(), + { + in: this.id, + tx: txID, + } + ) || (() => {throw new Error("Couldn't decrypt changes")})() + : tx.changes, + }; } ); // TODO: sort by timestamp, then by txID @@ -293,6 +315,56 @@ export class MultiLog { return allTransactions; } + + getCurrentReadKey(): KeySecret { + if (this.header.ruleset.type === "team") { + const content = expectTeam(this.getCurrentContent()); + + const currentRevelation = content.get("readKey"); + + if (!currentRevelation) { + throw new Error("No readKey"); + } + + const revelationTxID = content.getLastTxID("readKey"); + + if (!revelationTxID) { + throw new Error("No readKey transaction ID"); + } + + const revealer = agentIDfromSessionID(revelationTxID.sessionID); + + const revealerAgent = this.knownAgents[revealer]; + + if (!revealerAgent) { + throw new Error("Unknown revealer"); + } + + const secret = openAs( + currentRevelation.revelation, + this.agentCredential.recipientSecret, + revealerAgent.recipientID, + { + in: this.id, + tx: revelationTxID, + } + ); + + if (!secret) { + throw new Error("Couldn't decrypt readKey"); + } + + return secret as KeySecret; + } else if (this.header.ruleset.type === "ownedByTeam") { + return this.requiredMultiLogs[ + this.header.ruleset.team + ].getCurrentReadKey(); + } else { + throw new Error( + "Only teams or values owned by teams have read secrets" + ); + } + } } type MultilogKnownState = { diff --git a/src/permissions.test.ts b/src/permissions.test.ts index e2cb6b336..194097a29 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -1,15 +1,14 @@ import { test, expect } from "bun:test"; import { - AgentID, getAgent, getAgentID, newRandomAgentCredential, newRandomSessionID, } from "./multilog"; import { LocalNode } from "./node"; -import { CoJsonValue } from "."; -import { CoMap } from "./cojsonValue"; -import { Role } from "./permissions"; +import { expectMap } from "./cojsonValue"; +import { expectTeam } from "./permissions"; +import { getRecipientID, newRandomKeySecret, seal } from "./crypto"; function teamWithTwoAdmins() { const { team, admin, adminID } = newTeam(); @@ -56,22 +55,6 @@ function newTeam() { return { node, team, admin, adminID }; } -function expectTeam(content: CoJsonValue): CoMap<{[key: AgentID]: Role}, {}> { - if (content.type !== "comap") { - throw new Error("Expected map"); - } - - return content as CoMap<{[key: AgentID]: Role}, {}>; -} - -function expectMap(content: CoJsonValue): CoMap<{[key: string]: string}, {}> { - if (content.type !== "comap") { - throw new Error("Expected map"); - } - - return content as CoMap<{[key: string]: string}, {}>; -} - test("Initial admin can add another admin to a team", () => { teamWithTwoAdmins(); }); @@ -119,7 +102,9 @@ test("Admins can't demote other admins in a team", () => { newRandomSessionID(otherAdminID) ); - let teamContentAsOtherAdmin = expectTeam(teamAsOtherAdmin.getCurrentContent()); + let teamContentAsOtherAdmin = expectTeam( + teamAsOtherAdmin.getCurrentContent() + ); teamContentAsOtherAdmin.edit((editable) => { editable.set(adminID, "writer", "trusting"); @@ -304,4 +289,45 @@ test("Readers can not write to an object that is owned by their team", () => { childContent = expectMap(childObjectAsReader.getCurrentContent()); expect(childContent.get("foo")).toBeUndefined(); -}); \ No newline at end of file +}); + +test("Admins can set team read key and then use it to create and read private transactions in owned objects", () => { + const { node, team, admin, adminID } = newTeam(); + + const teamContent = expectTeam(team.getCurrentContent()); + + teamContent.edit((editable) => { + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); + const revelation = seal( + readKey, + admin.recipientSecret, + new Set([getRecipientID(admin.recipientSecret)]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID, revelation }); + expect(editable.get("readKey")).toEqual({ + keyID: readKeyID, + revelation, + }); + expect(team.getCurrentReadKey()).toEqual(readKey); + }); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }); + + let childContent = expectMap(childObject.getCurrentContent()); + + childContent.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + childContent = expectMap(childObject.getCurrentContent()); + expect(childContent.get("foo")).toEqual("bar"); +}); diff --git a/src/permissions.ts b/src/permissions.ts index a44427ef8..a45c117cc 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,5 +1,6 @@ -import { MapOpPayload } from "./cojsonValue"; -import { RecipientID, SignatoryID } from "./crypto"; +import { CoMap, CoValue, MapOpPayload } from "./cojsonValue"; +import { JsonValue } from "./jsonValue"; +import { KeyID, RecipientID, SealedSet, SignatoryID } from "./crypto"; import { AgentID, MultiLog, @@ -69,19 +70,32 @@ export function determineValidTransactions( // console.log("before", { memberState, validTransactions }); const transactor = agentIDfromSessionID(sessionID); - const change = tx.changes[0] as MapOpPayload; + const change = tx.changes[0] as + | MapOpPayload + | MapOpPayload<"readKey", JsonValue>; if (tx.changes.length !== 1) { console.warn("Team transaction must have exactly one change"); continue; } - const affectedMember = change.key; - if (change.op !== "insert") { - console.warn("Team transaction must set a role"); + console.warn("Team transaction must set a role or readKey"); continue; } + if (change.key === "readKey") { + if (memberState[transactor] !== "admin") { + console.warn("Only admins can set readKeys"); + continue; + } + + // TODO: check validity of agents who the key is revealed to? + + validTransactions.push({ txID: { sessionID, txIndex }, tx }); + continue; + } + + const affectedMember = change.key; const assignedRole = change.value; if ( @@ -169,3 +183,15 @@ export function determineValidTransactions( throw new Error("Unknown ruleset type " + multilog.header.ruleset.type); } } + +export type TeamContent = { [key: AgentID]: Role } & { + readKey: { keyID: KeyID; revelation: SealedSet }; +}; + +export function expectTeam(content: CoValue): CoMap { + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + return content as CoMap; +} From 1244fafd1f5a72ced8f4366817c6af584eae50f9 Mon Sep 17 00:00:00 2001 From: Anselm Date: Thu, 20 Jul 2023 16:47:53 +0100 Subject: [PATCH 06/47] Extra tests for single readKey --- src/permissions.test.ts | 114 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 8 deletions(-) diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 194097a29..6fa0a68fa 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -245,16 +245,16 @@ test("Writers can write to an object that is owned by their team", () => { newRandomSessionID(writerID) ); - let childContent = expectMap(childObjectAsWriter.getCurrentContent()); + let childContentAsWriter = expectMap(childObjectAsWriter.getCurrentContent()); - childContent.edit((editable) => { + childContentAsWriter.edit((editable) => { editable.set("foo", "bar", "trusting"); expect(editable.get("foo")).toEqual("bar"); }); - childContent = expectMap(childObjectAsWriter.getCurrentContent()); + childContentAsWriter = expectMap(childObjectAsWriter.getCurrentContent()); - expect(childContent.get("foo")).toEqual("bar"); + expect(childContentAsWriter.get("foo")).toEqual("bar"); }); test("Readers can not write to an object that is owned by their team", () => { @@ -279,16 +279,16 @@ test("Readers can not write to an object that is owned by their team", () => { newRandomSessionID(readerID) ); - let childContent = expectMap(childObjectAsReader.getCurrentContent()); + let childContentAsReader = expectMap(childObjectAsReader.getCurrentContent()); - childContent.edit((editable) => { + childContentAsReader.edit((editable) => { editable.set("foo", "bar", "trusting"); expect(editable.get("foo")).toBeUndefined(); }); - childContent = expectMap(childObjectAsReader.getCurrentContent()); + childContentAsReader = expectMap(childObjectAsReader.getCurrentContent()); - expect(childContent.get("foo")).toBeUndefined(); + expect(childContentAsReader.get("foo")).toBeUndefined(); }); test("Admins can set team read key and then use it to create and read private transactions in owned objects", () => { @@ -331,3 +331,101 @@ test("Admins can set team read key and then use it to create and read private tr childContent = expectMap(childObject.getCurrentContent()); expect(childContent.get("foo")).toEqual("bar"); }); + +test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects", () => { + const { node, team, admin } = newTeam(); + + const writer = newRandomAgentCredential(); + const writerID = getAgentID(getAgent(writer)); + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); + + const teamContent = expectTeam(team.getCurrentContent()); + + teamContent.edit((editable) => { + editable.set(writerID, "writer", "trusting"); + expect(editable.get(writerID)).toEqual("writer"); + + const revelation = seal( + readKey, + admin.recipientSecret, + new Set([getRecipientID(admin.recipientSecret), getRecipientID(writer.recipientSecret)]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID, revelation }); + }); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }); + + const childObjectAsWriter = childObject.testWithDifferentCredentials( + writer, + newRandomSessionID(writerID) + ); + + expect(childObject.getCurrentReadKey()).toEqual(readKey); + + let childContentAsWriter = expectMap(childObjectAsWriter.getCurrentContent()); + + childContentAsWriter.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + childContentAsWriter = expectMap(childObjectAsWriter.getCurrentContent()); + + expect(childContentAsWriter.get("foo")).toEqual("bar"); +}); + +test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read", () => { + const { node, team, admin } = newTeam(); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); + + const teamContent = expectTeam(team.getCurrentContent()); + + teamContent.edit((editable) => { + editable.set(readerID, "reader", "trusting"); + expect(editable.get(readerID)).toEqual("reader"); + + const revelation = seal( + readKey, + admin.recipientSecret, + new Set([getRecipientID(admin.recipientSecret), getRecipientID(reader.recipientSecret)]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID, revelation }); + }); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }); + + expectMap(childObject.getCurrentContent()).edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + const childObjectAsReader = childObject.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + expect(childObjectAsReader.getCurrentReadKey()).toEqual(readKey); + + const childContentAsReader = expectMap(childObjectAsReader.getCurrentContent()); + + expect(childContentAsReader.get("foo")).toEqual("bar"); +}); \ No newline at end of file From c5c997528ef4f7d8460d2691cf5d11a6542cab7f Mon Sep 17 00:00:00 2001 From: Anselm Date: Thu, 20 Jul 2023 17:25:18 +0100 Subject: [PATCH 07/47] Readme updates --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 24c0090fb..23145acbc 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ THIS IS WORK IN PROGRESS - boolean - number - string - - stringly-encoded CoJSON identifiers & data (`CoValueID`, `AgentID`, `SessionID`, `SignatoryID`, `SignatorySecret`, `Signature`, `RecipientID`, `RecipientSecret`, `Sealed`, `Hash`, `ShortHash`, `SecretKey`, `EncryptedChunk`, `Role`) + - stringly-encoded CoJSON identifiers & data (`CoValueID`, `AgentID`, `SessionID`, `SignatoryID`, `SignatorySecret`, `Signature`, `RecipientID`, `RecipientSecret`, `Sealed`, `Hash`, `ShortHash`, `KeySecret`, `KeyID`, `Encrypted`, `Role`) - array - object @@ -49,5 +49,5 @@ THIS IS WORK IN PROGRESS ## Extensions & higher-level protocols ### More complex datastructures -- MarCo: a clean way to collaboratively mark up rich text with CoJSON +- CoText: a clean way to collaboratively mark up rich text with CoJSON - CoJSON Tree: a clean way to represent collaborative tree structures with CoJSON \ No newline at end of file From e137f8ed3f5b9be10d2a6f812fd209459f1b20b7 Mon Sep 17 00:00:00 2001 From: Anselm Date: Thu, 20 Jul 2023 18:16:50 +0100 Subject: [PATCH 08/47] Simple readKey rotation logic --- src/cojsonValue.test.ts | 65 +++++++++++++++++++++-- src/cojsonValue.ts | 21 ++++++++ src/multilog.ts | 114 +++++++++++++++++++++++++++++++--------- src/permissions.test.ts | 81 ++++++++++++++++++++++++++-- 4 files changed, 248 insertions(+), 33 deletions(-) diff --git a/src/cojsonValue.test.ts b/src/cojsonValue.test.ts index 14652a2ae..bb7906df4 100644 --- a/src/cojsonValue.test.ts +++ b/src/cojsonValue.test.ts @@ -103,6 +103,63 @@ test("Can get map entry values at different points in time", () => { }); }); +test("Can get all historic values of key", () => { + const agentCredential = newRandomAgentCredential(); + const node = new LocalNode( + agentCredential, + newRandomSessionID(getAgentID(getAgent(agentCredential))) + ); + + const multilog = node.createMultiLog({ + type: "comap", + ruleset: { type: "unsafeAllowAll" }, + meta: null, + }); + + const content = multilog.getCurrentContent(); + + if (content.type !== "comap") { + throw new Error("Expected map"); + } + + expect(content.type).toEqual("comap"); + + content.edit((editable) => { + editable.set("hello", "A"); + const txA = editable.getLastTxID("hello"); + editable.set("hello", "B"); + const txB = editable.getLastTxID("hello"); + editable.delete("hello"); + const txDel = editable.getLastTxID("hello"); + editable.set("hello", "C"); + const txC = editable.getLastTxID("hello"); + expect( + editable.getHistory("hello") + ).toEqual([ + { + txID: txA, + value: "A", + at: txA && multilog.getTx(txA)?.madeAt, + }, + { + txID: txB, + value: "B", + at: txB && multilog.getTx(txB)?.madeAt, + }, + { + txID: txDel, + value: undefined, + at: txDel && multilog.getTx(txDel)?.madeAt, + }, + { + txID: txC, + value: "C", + at: txC && multilog.getTx(txC)?.madeAt, + }, + ]); + }); +}); + test("Can get last tx ID for a key", () => { const agentCredential = newRandomAgentCredential(); const node = new LocalNode( @@ -127,12 +184,14 @@ test("Can get last tx ID for a key", () => { content.edit((editable) => { expect(editable.getLastTxID("hello")).toEqual(undefined); editable.set("hello", "A"); - const sessionID = editable.getLastTxID("hello")?.sessionID - expect(sessionID && agentIDfromSessionID(sessionID)).toEqual(getAgentID(getAgent(agentCredential))); + const sessionID = editable.getLastTxID("hello")?.sessionID; + expect(sessionID && agentIDfromSessionID(sessionID)).toEqual( + getAgentID(getAgent(agentCredential)) + ); expect(editable.getLastTxID("hello")?.txIndex).toEqual(0); editable.set("hello", "B"); expect(editable.getLastTxID("hello")?.txIndex).toEqual(1); editable.set("hello", "C"); expect(editable.getLastTxID("hello")?.txIndex).toEqual(2); }); -}) \ No newline at end of file +}); diff --git a/src/cojsonValue.ts b/src/cojsonValue.ts index f6d31a49f..eb0b12bf7 100644 --- a/src/cojsonValue.ts +++ b/src/cojsonValue.ts @@ -50,6 +50,8 @@ export class CoMap< } protected fillOpsFromMultilog() { + this.ops = {}; + for (const { txID, changes, madeAt } of this.multiLog.getValidSortedTransactions()) { for (const [changeIdx, changeUntyped] of ( changes @@ -119,6 +121,25 @@ export class CoMap< 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 = {}; diff --git a/src/multilog.ts b/src/multilog.ts index 43678d875..1956ddd95 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -20,6 +20,7 @@ import { verify, encrypt, decrypt, + KeyID, } from "./crypto"; import { JsonValue } from "./jsonValue"; import { base58 } from "@scure/base"; @@ -62,6 +63,7 @@ type SessionLog = { export type PrivateTransaction = { privacy: "private"; madeAt: number; + keyUsed: KeyID; encryptedChanges: Encrypted; }; @@ -229,25 +231,27 @@ export class MultiLog { ): boolean { const madeAt = Date.now(); - const transaction: Transaction = - privacy === "private" - ? { - privacy: "private", - madeAt, - encryptedChanges: encrypt( - changes, - this.getCurrentReadKey(), - { - in: this.id, - tx: this.nextTransactionID(), - } - ), - } - : { - privacy: "trusting", - madeAt, - changes, - }; + let transaction: Transaction; + + if (privacy === "private") { + const { keySecret, keyID } = this.getCurrentReadKey(); + + transaction = { + privacy: "private", + madeAt, + keyUsed: keyID, + encryptedChanges: encrypt(changes, keySecret, { + in: this.id, + tx: this.nextTransactionID(), + }), + }; + } else { + transaction = { + privacy: "trusting", + madeAt, + changes, + }; + } const sessionID = this.ownSessionID; @@ -300,23 +304,30 @@ export class MultiLog { tx.privacy === "private" ? decrypt( tx.encryptedChanges, - this.getCurrentReadKey(), + this.getReadKey(tx.keyUsed), { in: this.id, tx: txID, } - ) || (() => {throw new Error("Couldn't decrypt changes")})() + ) || + (() => { + throw new Error("Couldn't decrypt changes"); + })() : tx.changes, }; } ); - // TODO: sort by timestamp, then by txID - allTransactions.sort((a, b) => a.madeAt - b.madeAt); + allTransactions.sort( + (a, b) => + a.madeAt - b.madeAt || + (a.txID.sessionID < b.txID.sessionID ? -1 : 1) || + a.txID.txIndex - b.txID.txIndex + ); return allTransactions; } - getCurrentReadKey(): KeySecret { + getCurrentReadKey(): { keySecret: KeySecret; keyID: KeyID } { if (this.header.ruleset.type === "team") { const content = expectTeam(this.getCurrentContent()); @@ -333,7 +344,6 @@ export class MultiLog { } const revealer = agentIDfromSessionID(revelationTxID.sessionID); - const revealerAgent = this.knownAgents[revealer]; if (!revealerAgent) { @@ -354,7 +364,10 @@ export class MultiLog { throw new Error("Couldn't decrypt readKey"); } - return secret as KeySecret; + return { + keySecret: secret as KeySecret, + keyID: currentRevelation.keyID, + }; } else if (this.header.ruleset.type === "ownedByTeam") { return this.requiredMultiLogs[ this.header.ruleset.team @@ -365,6 +378,55 @@ export class MultiLog { ); } } + + getReadKey(keyID: KeyID): KeySecret { + if (this.header.ruleset.type === "team") { + const content = expectTeam(this.getCurrentContent()); + + const readKeyHistory = content.getHistory("readKey"); + + const matchingEntry = readKeyHistory.find(entry => entry.value?.keyID === keyID); + + if (!matchingEntry || !matchingEntry.value) { + throw new Error("No matching readKey"); + } + + const revealer = agentIDfromSessionID(matchingEntry.txID.sessionID); + const revealerAgent = this.knownAgents[revealer]; + + if (!revealerAgent) { + throw new Error("Unknown revealer"); + } + + const secret = openAs( + matchingEntry.value.revelation, + this.agentCredential.recipientSecret, + revealerAgent.recipientID, + { + in: this.id, + tx: matchingEntry.txID, + } + ); + + if (!secret) { + throw new Error("Couldn't decrypt readKey"); + } + + return secret as KeySecret; + } else if (this.header.ruleset.type === "ownedByTeam") { + return this.requiredMultiLogs[ + this.header.ruleset.team + ].getReadKey(keyID); + } else { + throw new Error( + "Only teams or values owned by teams have read secrets" + ); + } + } + + getTx(txID: TransactionID): Transaction | undefined { + return this.sessions[txID.sessionID]?.transactions[txID.txIndex]; + } } type MultilogKnownState = { diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 6fa0a68fa..fbfa5fbff 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -312,7 +312,7 @@ test("Admins can set team read key and then use it to create and read private tr keyID: readKeyID, revelation, }); - expect(team.getCurrentReadKey()).toEqual(readKey); + expect(team.getCurrentReadKey().keySecret).toEqual(readKey); }); const childObject = node.createMultiLog({ @@ -368,7 +368,7 @@ test("Admins can set team read key and then writers can use it to create and rea newRandomSessionID(writerID) ); - expect(childObject.getCurrentReadKey()).toEqual(readKey); + expect(childObject.getCurrentReadKey().keySecret).toEqual(readKey); let childContentAsWriter = expectMap(childObjectAsWriter.getCurrentContent()); @@ -423,9 +423,82 @@ test("Admins can set team read key and then use it to create private transaction newRandomSessionID(readerID) ); - expect(childObjectAsReader.getCurrentReadKey()).toEqual(readKey); + expect(childObjectAsReader.getCurrentReadKey().keySecret).toEqual(readKey); const childContentAsReader = expectMap(childObjectAsReader.getCurrentContent()); expect(childContentAsReader.get("foo")).toEqual("bar"); -}); \ No newline at end of file +}); + +test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, make another private transaction, and both can be read by the admin", () => { + const { node, team, admin, adminID } = newTeam(); + + const teamContent = expectTeam(team.getCurrentContent()); + + teamContent.edit((editable) => { + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); + const revelation = seal( + readKey, + admin.recipientSecret, + new Set([getRecipientID(admin.recipientSecret)]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID, revelation }); + expect(editable.get("readKey")).toEqual({ + keyID: readKeyID, + revelation, + }); + expect(team.getCurrentReadKey().keySecret).toEqual(readKey); + }); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }); + + let childContent = expectMap(childObject.getCurrentContent()); + + childContent.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + childContent = expectMap(childObject.getCurrentContent()); + expect(childContent.get("foo")).toEqual("bar"); + + teamContent.edit((editable) => { + const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret(); + + const revelation = seal( + readKey2, + admin.recipientSecret, + new Set([getRecipientID(admin.recipientSecret)]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + + editable.set("readKey", { keyID: readKeyID2, revelation }); + expect(editable.get("readKey")).toEqual({ + keyID: readKeyID2, + revelation, + }); + expect(team.getCurrentReadKey().keySecret).toEqual(readKey2); + }); + + childContent = expectMap(childObject.getCurrentContent()); + expect(childContent.get("foo")).toEqual("bar"); + + childContent.edit((editable) => { + editable.set("foo2", "bar2", "private"); + expect(editable.get("foo2")).toEqual("bar2"); + }); + childContent = expectMap(childObject.getCurrentContent()); + expect(childContent.get("foo")).toEqual("bar"); + expect(childContent.get("foo2")).toEqual("bar2"); +}) \ No newline at end of file From 18723ff1d702067e4fe030a951442b6a083e081e Mon Sep 17 00:00:00 2001 From: Anselm Date: Thu, 20 Jul 2023 18:19:28 +0100 Subject: [PATCH 09/47] Rename --- src/{cojsonValue.test.ts => coValue.test.ts} | 0 src/{cojsonValue.ts => coValue.ts} | 0 src/index.ts | 2 +- src/jsonValue.ts | 2 +- src/multilog.ts | 2 +- src/permissions.test.ts | 2 +- src/permissions.ts | 2 +- 7 files changed, 5 insertions(+), 5 deletions(-) rename src/{cojsonValue.test.ts => coValue.test.ts} (100%) rename src/{cojsonValue.ts => coValue.ts} (100%) diff --git a/src/cojsonValue.test.ts b/src/coValue.test.ts similarity index 100% rename from src/cojsonValue.test.ts rename to src/coValue.test.ts diff --git a/src/cojsonValue.ts b/src/coValue.ts similarity index 100% rename from src/cojsonValue.ts rename to src/coValue.ts diff --git a/src/index.ts b/src/index.ts index 36d9a29d8..709cc2e15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { CoValue } from "./cojsonValue"; +import { CoValue } from "./coValue"; import { JsonValue } from "./jsonValue"; import { MultiLog } from "./multilog"; import { LocalNode } from "./node"; diff --git a/src/jsonValue.ts b/src/jsonValue.ts index b3bc130a0..fd37d8859 100644 --- a/src/jsonValue.ts +++ b/src/jsonValue.ts @@ -1,4 +1,4 @@ -import { CoValueID, CoValue } from "./cojsonValue"; +import { CoValueID, CoValue } from "./coValue"; export type JsonAtom = string | number | boolean | null; export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID; diff --git a/src/multilog.ts b/src/multilog.ts index 1956ddd95..d58a91f73 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -1,5 +1,5 @@ import { randomBytes } from "@noble/hashes/utils"; -import { CoList, CoMap, CoValue, Static, MultiStream } from "./cojsonValue"; +import { CoList, CoMap, CoValue, Static, MultiStream } from "./coValue"; import { Encrypted, Hash, diff --git a/src/permissions.test.ts b/src/permissions.test.ts index fbfa5fbff..748e10ada 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -6,7 +6,7 @@ import { newRandomSessionID, } from "./multilog"; import { LocalNode } from "./node"; -import { expectMap } from "./cojsonValue"; +import { expectMap } from "./coValue"; import { expectTeam } from "./permissions"; import { getRecipientID, newRandomKeySecret, seal } from "./crypto"; diff --git a/src/permissions.ts b/src/permissions.ts index a45c117cc..d9fd25441 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,4 +1,4 @@ -import { CoMap, CoValue, MapOpPayload } from "./cojsonValue"; +import { CoMap, CoValue, MapOpPayload } from "./coValue"; import { JsonValue } from "./jsonValue"; import { KeyID, RecipientID, SealedSet, SignatoryID } from "./crypto"; import { From 94d8fbf62d5c8fdb72bf7a2568d4603b6213e5fd Mon Sep 17 00:00:00 2001 From: Anselm Date: Thu, 20 Jul 2023 18:26:21 +0100 Subject: [PATCH 10/47] Change default set & delete privacy to "private" --- src/coValue.test.ts | 26 +++++++++++++------------- src/coValue.ts | 6 ++---- src/permissions.test.ts | 10 +++++----- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/src/coValue.test.ts b/src/coValue.test.ts index bb7906df4..a437a99c8 100644 --- a/src/coValue.test.ts +++ b/src/coValue.test.ts @@ -54,12 +54,12 @@ test("Can insert and delete Map entries in edit()", () => { expect(content.type).toEqual("comap"); content.edit((editable) => { - editable.set("hello", "world"); + editable.set("hello", "world", "trusting"); expect(editable.get("hello")).toEqual("world"); - editable.set("foo", "bar"); + editable.set("foo", "bar", "trusting"); expect(editable.get("foo")).toEqual("bar"); expect([...editable.keys()]).toEqual(["hello", "foo"]); - editable.delete("foo"); + editable.delete("foo", "trusting"); expect(editable.get("foo")).toEqual(undefined); }); }); @@ -88,13 +88,13 @@ test("Can get map entry values at different points in time", () => { content.edit((editable) => { const beforeA = Date.now(); Bun.sleepSync(1); - editable.set("hello", "A"); + editable.set("hello", "A", "trusting"); const beforeB = Date.now(); Bun.sleepSync(1); - editable.set("hello", "B"); + editable.set("hello", "B", "trusting"); const beforeC = Date.now(); Bun.sleepSync(1); - editable.set("hello", "C"); + editable.set("hello", "C", "trusting"); expect(editable.get("hello")).toEqual("C"); expect(editable.getAtTime("hello", Date.now())).toEqual("C"); expect(editable.getAtTime("hello", beforeA)).toEqual(undefined); @@ -125,13 +125,13 @@ test("Can get all historic values of key", () => { expect(content.type).toEqual("comap"); content.edit((editable) => { - editable.set("hello", "A"); + editable.set("hello", "A", "trusting"); const txA = editable.getLastTxID("hello"); - editable.set("hello", "B"); + editable.set("hello", "B", "trusting"); const txB = editable.getLastTxID("hello"); - editable.delete("hello"); + editable.delete("hello", "trusting"); const txDel = editable.getLastTxID("hello"); - editable.set("hello", "C"); + editable.set("hello", "C", "trusting"); const txC = editable.getLastTxID("hello"); expect( editable.getHistory("hello") @@ -183,15 +183,15 @@ test("Can get last tx ID for a key", () => { content.edit((editable) => { expect(editable.getLastTxID("hello")).toEqual(undefined); - editable.set("hello", "A"); + editable.set("hello", "A", "trusting"); const sessionID = editable.getLastTxID("hello")?.sessionID; expect(sessionID && agentIDfromSessionID(sessionID)).toEqual( getAgentID(getAgent(agentCredential)) ); expect(editable.getLastTxID("hello")?.txIndex).toEqual(0); - editable.set("hello", "B"); + editable.set("hello", "B", "trusting"); expect(editable.getLastTxID("hello")?.txIndex).toEqual(1); - editable.set("hello", "C"); + editable.set("hello", "C", "trusting"); expect(editable.getLastTxID("hello")?.txIndex).toEqual(2); }); }); diff --git a/src/coValue.ts b/src/coValue.ts index eb0b12bf7..c04d99801 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -166,8 +166,7 @@ export class WriteableCoMap< V extends JsonValue = M[K], MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]} > extends CoMap { - // TODO: change default to private - set(key: KK, value: M[KK], privacy: "private" | "trusting" = "trusting"): void { + set(key: KK, value: M[KK], privacy: "private" | "trusting" = "private"): void { this.multiLog.makeTransaction([ { op: "insert", @@ -179,8 +178,7 @@ export class WriteableCoMap< this.fillOpsFromMultilog(); } - // TODO: change default to private - delete(key: K, privacy: "private" | "trusting" = "trusting"): void { + delete(key: K, privacy: "private" | "trusting" = "private"): void { this.multiLog.makeTransaction([ { op: "delete", diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 748e10ada..6586892c4 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -307,7 +307,7 @@ test("Admins can set team read key and then use it to create and read private tr tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation }); + editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); expect(editable.get("readKey")).toEqual({ keyID: readKeyID, revelation, @@ -354,7 +354,7 @@ test("Admins can set team read key and then writers can use it to create and rea tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation }); + editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); }); const childObject = node.createMultiLog({ @@ -404,7 +404,7 @@ test("Admins can set team read key and then use it to create private transaction tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation }); + editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); }); const childObject = node.createMultiLog({ @@ -446,7 +446,7 @@ test("Admins can set team read key, make a private transaction in an owned objec tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation }); + editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); expect(editable.get("readKey")).toEqual({ keyID: readKeyID, revelation, @@ -483,7 +483,7 @@ test("Admins can set team read key, make a private transaction in an owned objec } ); - editable.set("readKey", { keyID: readKeyID2, revelation }); + editable.set("readKey", { keyID: readKeyID2, revelation }, "trusting"); expect(editable.get("readKey")).toEqual({ keyID: readKeyID2, revelation, From f1bb37161962c8a92abac48a3a1ca8d3c971a175 Mon Sep 17 00:00:00 2001 From: Anselm Date: Tue, 25 Jul 2023 16:22:19 +0100 Subject: [PATCH 11/47] Key rotation with previousKeys support --- src/crypto.test.ts | 55 +++++++++++++--- src/crypto.ts | 76 +++++++++++++++++----- src/multilog.ts | 50 +++++++++++---- src/permissions.test.ts | 139 +++++++++++++++++++++++++++++++++++++--- src/permissions.ts | 6 +- 5 files changed, 278 insertions(+), 48 deletions(-) diff --git a/src/crypto.test.ts b/src/crypto.test.ts index 5f6b2de92..f6714ce83 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -11,8 +11,10 @@ import { verify, shortHash, newRandomKeySecret, - encrypt, - decrypt, + encryptForTransaction, + decryptForTransaction, + sealKeySecret, + unsealKeySecret, } from "./crypto"; import { base58, base64url } from "@scure/base"; import { x25519 } from "@noble/curves/ed25519"; @@ -101,22 +103,22 @@ test("Hashing is deterministic", () => { ); }); -test("Encryption streams round-trip", () => { +test("Encryption for transactions round-trips", () => { const { secret } = newRandomKeySecret(); const encryptedChunks = [ - encrypt({ a: "hello" }, secret, { + encryptForTransaction({ a: "hello" }, secret, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, }), - encrypt({ b: "world" }, secret, { + encryptForTransaction({ b: "world" }, secret, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, }), ]; const decryptedChunks = encryptedChunks.map((chunk, i) => - decrypt(chunk, secret, { + decryptForTransaction(chunk, secret, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, }) @@ -125,23 +127,23 @@ test("Encryption streams round-trip", () => { expect(decryptedChunks).toEqual([{ a: "hello" }, { b: "world" }]); }); -test("Encryption streams don't decrypt with a wrong key", () => { +test("Encryption for transactions doesn't decrypt with a wrong key", () => { const { secret } = newRandomKeySecret(); const { secret: secret2 } = newRandomKeySecret(); const encryptedChunks = [ - encrypt({ a: "hello" }, secret, { + encryptForTransaction({ a: "hello" }, secret, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, }), - encrypt({ b: "world" }, secret, { + encryptForTransaction({ b: "world" }, secret, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, }), ]; const decryptedChunks = encryptedChunks.map((chunk, i) => - decrypt(chunk, secret2, { + decryptForTransaction(chunk, secret2, { in: "coval_zTEST", tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, }) @@ -149,3 +151,36 @@ test("Encryption streams don't decrypt with a wrong key", () => { expect(decryptedChunks).toEqual([undefined, undefined]); }); + +test("Encryption of keySecrets round-trips", () => { + const toSeal = newRandomKeySecret(); + const sealing = newRandomKeySecret(); + + const keys = { + toSeal, + sealing, + }; + + const sealed = sealKeySecret(keys); + + const unsealed = unsealKeySecret(sealed, sealing.secret); + + expect(unsealed).toEqual(toSeal.secret); +}); + +test("Encryption of keySecrets doesn't unseal with a wrong key", () => { + const toSeal = newRandomKeySecret(); + const sealing = newRandomKeySecret(); + const sealingWrong = newRandomKeySecret(); + + const keys = { + toSeal, + sealing, + }; + + const sealed = sealKeySecret(keys); + + const unsealed = unsealKeySecret(sealed, sealingWrong.secret); + + expect(unsealed).toBeUndefined(); +}); diff --git a/src/crypto.ts b/src/crypto.ts index 2c9a8140e..d472cbb23 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -181,8 +181,7 @@ export function shortHash(value: JsonValue): ShortHash { )}`; } -export type Encrypted = - `encrypted_U${string}`; +export type Encrypted = `encrypted_U${string}`; export type KeySecret = `keySecret_z${string}`; export type KeyID = `key_z${string}`; @@ -194,7 +193,11 @@ export function newRandomKeySecret(): { secret: KeySecret; id: KeyID } { }; } -export function encrypt(value: T, keySecret: KeySecret, nOnceMaterial: {in: MultiLogID, tx: TransactionID}): Encrypted { +function encrypt( + value: T, + keySecret: KeySecret, + nOnceMaterial: N +): Encrypted { const keySecretBytes = base58.decode( keySecret.substring("keySecret_z".length) ); @@ -203,15 +206,43 @@ export function encrypt(value: T, keySecret: KeySecret, nOn ).slice(0, 24); const plaintext = textEncoder.encode(stableStringify(value)); - const ciphertext = xsalsa20( - keySecretBytes, - nOnce, - plaintext, - ); + const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext); return `encrypted_U${base64url.encode(ciphertext)}`; -}; +} -export function decrypt(encrypted: Encrypted, keySecret: KeySecret, nOnceMaterial: {in: MultiLogID, tx: TransactionID}): T | undefined { +export function encryptForTransaction( + value: T, + keySecret: KeySecret, + nOnceMaterial: { in: MultiLogID; tx: TransactionID } +): Encrypted { + return encrypt(value, keySecret, nOnceMaterial); +} + +export function sealKeySecret(keys: { + toSeal: { id: KeyID; secret: KeySecret }; + sealing: { id: KeyID; secret: KeySecret }; +}): { sealed: KeyID; sealing: KeyID; encrypted: Encrypted } { + const nOnceMaterial = { + sealed: keys.toSeal.id, + sealing: keys.sealing.id, + }; + + return { + sealed: keys.toSeal.id, + sealing: keys.sealing.id, + encrypted: encrypt( + keys.toSeal.secret, + keys.sealing.secret, + nOnceMaterial + ), + }; +} + +function decrypt( + encrypted: Encrypted, + keySecret: KeySecret, + nOnceMaterial: N +): T | undefined { const keySecretBytes = base58.decode( keySecret.substring("keySecret_z".length) ); @@ -222,15 +253,28 @@ export function decrypt(encrypted: Encrypted, keySecret: const ciphertext = base64url.decode( encrypted.substring("encrypted_U".length) ); - const plaintext = xsalsa20( - keySecretBytes, - nOnce, - ciphertext, - ); + const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext); try { return JSON.parse(textDecoder.decode(plaintext)); } catch (e) { return undefined; } -} \ No newline at end of file +} + +export function decryptForTransaction( + encrypted: Encrypted, + keySecret: KeySecret, + nOnceMaterial: { in: MultiLogID; tx: TransactionID } +): T | undefined { + return decrypt(encrypted, keySecret, nOnceMaterial); +} + +export function unsealKeySecret( + sealedInfo: { sealed: KeyID; sealing: KeyID; encrypted: Encrypted }, + sealingSecret: KeySecret +): KeySecret | undefined { + const nOnceMaterial = { sealed: sealedInfo.sealed, sealing: sealedInfo.sealing }; + + return decrypt(sealedInfo.encrypted, sealingSecret, nOnceMaterial); +} diff --git a/src/multilog.ts b/src/multilog.ts index d58a91f73..786dc0f11 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -18,9 +18,10 @@ import { shortHash, sign, verify, - encrypt, - decrypt, + encryptForTransaction, + decryptForTransaction, KeyID, + unsealKeySecret, } from "./crypto"; import { JsonValue } from "./jsonValue"; import { base58 } from "@scure/base"; @@ -122,7 +123,15 @@ export class MultiLog { agentCredential, ownSessionID, knownAgents, - this.requiredMultiLogs + Object.fromEntries( + Object.entries(this.requiredMultiLogs).map(([id, multilog]) => [ + id, + multilog.testWithDifferentCredentials( + agentCredential, + ownSessionID + ), + ]) + ) ); cloned.sessions = JSON.parse(JSON.stringify(this.sessions)); @@ -240,7 +249,7 @@ export class MultiLog { privacy: "private", madeAt, keyUsed: keyID, - encryptedChanges: encrypt(changes, keySecret, { + encryptedChanges: encryptForTransaction(changes, keySecret, { in: this.id, tx: this.nextTransactionID(), }), @@ -302,7 +311,7 @@ export class MultiLog { madeAt: tx.madeAt, changes: tx.privacy === "private" - ? decrypt( + ? decryptForTransaction( tx.encryptedChanges, this.getReadKey(tx.keyUsed), { @@ -385,7 +394,9 @@ export class MultiLog { const readKeyHistory = content.getHistory("readKey"); - const matchingEntry = readKeyHistory.find(entry => entry.value?.keyID === keyID); + const matchingEntry = readKeyHistory.find( + (entry) => entry.value?.keyID === keyID + ); if (!matchingEntry || !matchingEntry.value) { throw new Error("No matching readKey"); @@ -408,15 +419,30 @@ export class MultiLog { } ); - if (!secret) { - throw new Error("Couldn't decrypt readKey"); + if (secret) return secret as KeySecret; + + for (const entry of readKeyHistory) { + if (entry.value?.previousKeys?.[keyID]) { + const sealingKeyID = entry.value.keyID; + const sealingKeySecret = this.getReadKey(sealingKeyID); + + if (!sealingKeySecret) { + continue; + } + + const secret = unsealKeySecret({ sealed: keyID, sealing: sealingKeyID, encrypted: entry.value.previousKeys[keyID] }, sealingKeySecret); + + if (secret) { + return secret; + } + } } - return secret as KeySecret; + throw new Error("readKey " + keyID + " not revealed for " + getAgentID(getAgent(this.agentCredential))); } else if (this.header.ruleset.type === "ownedByTeam") { - return this.requiredMultiLogs[ - this.header.ruleset.team - ].getReadKey(keyID); + return this.requiredMultiLogs[this.header.ruleset.team].getReadKey( + keyID + ); } else { throw new Error( "Only teams or values owned by teams have read secrets" diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 6586892c4..e17955c48 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -8,7 +8,12 @@ import { import { LocalNode } from "./node"; import { expectMap } from "./coValue"; import { expectTeam } from "./permissions"; -import { getRecipientID, newRandomKeySecret, seal } from "./crypto"; +import { + getRecipientID, + newRandomKeySecret, + seal, + sealKeySecret, +} from "./crypto"; function teamWithTwoAdmins() { const { team, admin, adminID } = newTeam(); @@ -245,7 +250,9 @@ test("Writers can write to an object that is owned by their team", () => { newRandomSessionID(writerID) ); - let childContentAsWriter = expectMap(childObjectAsWriter.getCurrentContent()); + let childContentAsWriter = expectMap( + childObjectAsWriter.getCurrentContent() + ); childContentAsWriter.edit((editable) => { editable.set("foo", "bar", "trusting"); @@ -279,7 +286,9 @@ test("Readers can not write to an object that is owned by their team", () => { newRandomSessionID(readerID) ); - let childContentAsReader = expectMap(childObjectAsReader.getCurrentContent()); + let childContentAsReader = expectMap( + childObjectAsReader.getCurrentContent() + ); childContentAsReader.edit((editable) => { editable.set("foo", "bar", "trusting"); @@ -348,7 +357,10 @@ test("Admins can set team read key and then writers can use it to create and rea const revelation = seal( readKey, admin.recipientSecret, - new Set([getRecipientID(admin.recipientSecret), getRecipientID(writer.recipientSecret)]), + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(writer.recipientSecret), + ]), { in: team.id, tx: team.nextTransactionID(), @@ -370,7 +382,9 @@ test("Admins can set team read key and then writers can use it to create and rea expect(childObject.getCurrentReadKey().keySecret).toEqual(readKey); - let childContentAsWriter = expectMap(childObjectAsWriter.getCurrentContent()); + let childContentAsWriter = expectMap( + childObjectAsWriter.getCurrentContent() + ); childContentAsWriter.edit((editable) => { editable.set("foo", "bar", "private"); @@ -398,7 +412,10 @@ test("Admins can set team read key and then use it to create private transaction const revelation = seal( readKey, admin.recipientSecret, - new Set([getRecipientID(admin.recipientSecret), getRecipientID(reader.recipientSecret)]), + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(reader.recipientSecret), + ]), { in: team.id, tx: team.nextTransactionID(), @@ -425,7 +442,9 @@ test("Admins can set team read key and then use it to create private transaction expect(childObjectAsReader.getCurrentReadKey().keySecret).toEqual(readKey); - const childContentAsReader = expectMap(childObjectAsReader.getCurrentContent()); + const childContentAsReader = expectMap( + childObjectAsReader.getCurrentContent() + ); expect(childContentAsReader.get("foo")).toEqual("bar"); }); @@ -501,4 +520,108 @@ test("Admins can set team read key, make a private transaction in an owned objec childContent = expectMap(childObject.getCurrentContent()); expect(childContent.get("foo")).toEqual("bar"); expect(childContent.get("foo2")).toEqual("bar2"); -}) \ No newline at end of file +}); + +test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, add a reader, make another private transaction in the owned object, and both can be read by the reader", () => { + const { node, team, admin, adminID } = newTeam(); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }); + + const teamContent = expectTeam(team.getCurrentContent()); + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); + + teamContent.edit((editable) => { + const revelation = seal( + readKey, + admin.recipientSecret, + new Set([getRecipientID(admin.recipientSecret)]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); + expect(editable.get("readKey")).toEqual({ + keyID: readKeyID, + revelation, + }); + expect(team.getCurrentReadKey().keySecret).toEqual(readKey); + }); + + let childContent = expectMap(childObject.getCurrentContent()); + + childContent.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + childContent = expectMap(childObject.getCurrentContent()); + expect(childContent.get("foo")).toEqual("bar"); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret(); + + teamContent.edit((editable) => { + const revelation = seal( + readKey2, + admin.recipientSecret, + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(reader.recipientSecret), + ]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + + editable.set( + "readKey", + { + keyID: readKeyID2, + revelation, + previousKeys: { + [readKeyID]: sealKeySecret({ + toSeal: { id: readKeyID, secret: readKey }, + sealing: { id: readKeyID2, secret: readKey2 }, + }).encrypted, + }, + }, + "trusting" + ); + expect(editable.get("readKey")).toMatchObject({ + keyID: readKeyID2, + revelation, + }); + expect(team.getCurrentReadKey().keySecret).toEqual(readKey2); + + editable.set(readerID, "reader", "trusting"); + expect(editable.get(readerID)).toEqual("reader"); + }); + + childContent.edit((editable) => { + editable.set("foo2", "bar2", "private"); + expect(editable.get("foo2")).toEqual("bar2"); + }); + + const childObjectAsReader = childObject.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + expect(childObjectAsReader.getCurrentReadKey().keySecret).toEqual(readKey2); + + console.log(readKeyID2); + + const childContentAsReader = expectMap( + childObjectAsReader.getCurrentContent() + ); + + expect(childContentAsReader.get("foo")).toEqual("bar"); + expect(childContentAsReader.get("foo2")).toEqual("bar2"); +}); diff --git a/src/permissions.ts b/src/permissions.ts index d9fd25441..474bb5b6f 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,6 +1,6 @@ import { CoMap, CoValue, MapOpPayload } from "./coValue"; import { JsonValue } from "./jsonValue"; -import { KeyID, RecipientID, SealedSet, SignatoryID } from "./crypto"; +import { Encrypted, KeyID, KeySecret, RecipientID, SealedSet, SignatoryID } from "./crypto"; import { AgentID, MultiLog, @@ -185,7 +185,9 @@ export function determineValidTransactions( } export type TeamContent = { [key: AgentID]: Role } & { - readKey: { keyID: KeyID; revelation: SealedSet }; + readKey: { keyID: KeyID; revelation: SealedSet, previousKeys?: { + [key: KeyID]: Encrypted + } }; }; export function expectTeam(content: CoValue): CoMap { From d60712e4ee8871cf90206ed704a2705d4392b171 Mon Sep 17 00:00:00 2001 From: Anselm Date: Tue, 25 Jul 2023 16:44:55 +0100 Subject: [PATCH 12/47] Add test for re-revealing same key --- src/permissions.test.ts | 81 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/permissions.test.ts b/src/permissions.test.ts index e17955c48..6d3978de3 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -449,6 +449,87 @@ test("Admins can set team read key and then use it to create private transaction expect(childContentAsReader.get("foo")).toEqual("bar"); }); +test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key", () => { + const { node, team, admin } = newTeam(); + + const reader1 = newRandomAgentCredential(); + const reader1ID = getAgentID(getAgent(reader1)); + const reader2 = newRandomAgentCredential(); + const reader2ID = getAgentID(getAgent(reader2)); + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); + + const teamContent = expectTeam(team.getCurrentContent()); + + teamContent.edit((editable) => { + editable.set(reader1ID, "reader", "trusting"); + expect(editable.get(reader1ID)).toEqual("reader"); + + const revelation1 = seal( + readKey, + admin.recipientSecret, + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(reader1.recipientSecret), + ]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID, revelation: revelation1 }, "trusting"); + + const revelation2 = seal( + readKey, + admin.recipientSecret, + new Set([ + getRecipientID(reader2.recipientSecret), + ]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID, revelation: revelation2 }, "trusting"); + }); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }); + + expectMap(childObject.getCurrentContent()).edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + const childObjectAsReader1 = childObject.testWithDifferentCredentials( + reader1, + newRandomSessionID(reader1ID) + ); + + expect(childObjectAsReader1.getCurrentReadKey().keySecret).toEqual(readKey); + + const childContentAsReader1 = expectMap( + childObjectAsReader1.getCurrentContent() + ); + + expect(childContentAsReader1.get("foo")).toEqual("bar"); + + const childObjectAsReader2 = childObject.testWithDifferentCredentials( + reader2, + newRandomSessionID(reader2ID) + ); + + expect(childObjectAsReader2.getCurrentReadKey().keySecret).toEqual(readKey); + + const childContentAsReader2 = expectMap( + childObjectAsReader2.getCurrentContent() + ); + + expect(childContentAsReader2.get("foo")).toEqual("bar"); +}) + test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, make another private transaction, and both can be read by the admin", () => { const { node, team, admin, adminID } = newTeam(); From 07504692a2cce5a28984f813bcc973931edd1af1 Mon Sep 17 00:00:00 2001 From: Anselm Date: Tue, 25 Jul 2023 16:55:06 +0100 Subject: [PATCH 13/47] Fix readKey lookup for multiple revelations of same key --- src/multilog.ts | 93 +++++++++++++++++++++---------------------------- 1 file changed, 40 insertions(+), 53 deletions(-) diff --git a/src/multilog.ts b/src/multilog.ts index 786dc0f11..ee007b91f 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -340,42 +340,17 @@ export class MultiLog { if (this.header.ruleset.type === "team") { const content = expectTeam(this.getCurrentContent()); - const currentRevelation = content.get("readKey"); + const currentKeyId = content.get("readKey")?.keyID; - if (!currentRevelation) { - throw new Error("No readKey"); + if (!currentKeyId) { + throw new Error("No readKey set"); } - const revelationTxID = content.getLastTxID("readKey"); - - if (!revelationTxID) { - throw new Error("No readKey transaction ID"); - } - - const revealer = agentIDfromSessionID(revelationTxID.sessionID); - const revealerAgent = this.knownAgents[revealer]; - - if (!revealerAgent) { - throw new Error("Unknown revealer"); - } - - const secret = openAs( - currentRevelation.revelation, - this.agentCredential.recipientSecret, - revealerAgent.recipientID, - { - in: this.id, - tx: revelationTxID, - } - ); - - if (!secret) { - throw new Error("Couldn't decrypt readKey"); - } + const secret = this.getReadKey(currentKeyId); return { - keySecret: secret as KeySecret, - keyID: currentRevelation.keyID, + keySecret: secret, + keyID: currentKeyId, }; } else if (this.header.ruleset.type === "ownedByTeam") { return this.requiredMultiLogs[ @@ -394,32 +369,32 @@ export class MultiLog { const readKeyHistory = content.getHistory("readKey"); - const matchingEntry = readKeyHistory.find( - (entry) => entry.value?.keyID === keyID - ); + // Try to find direct relevation of key for us - if (!matchingEntry || !matchingEntry.value) { - throw new Error("No matching readKey"); - } + for (const entry of readKeyHistory) { + if (entry.value?.keyID === keyID) { + const revealer = agentIDfromSessionID(entry.txID.sessionID); + const revealerAgent = this.knownAgents[revealer]; - const revealer = agentIDfromSessionID(matchingEntry.txID.sessionID); - const revealerAgent = this.knownAgents[revealer]; + if (!revealerAgent) { + throw new Error("Unknown revealer"); + } - if (!revealerAgent) { - throw new Error("Unknown revealer"); - } + const secret = openAs( + entry.value.revelation, + this.agentCredential.recipientSecret, + revealerAgent.recipientID, + { + in: this.id, + tx: entry.txID, + } + ); - const secret = openAs( - matchingEntry.value.revelation, - this.agentCredential.recipientSecret, - revealerAgent.recipientID, - { - in: this.id, - tx: matchingEntry.txID, + if (secret) return secret as KeySecret; } - ); + } - if (secret) return secret as KeySecret; + // Try to find indirect revelation through previousKeys for (const entry of readKeyHistory) { if (entry.value?.previousKeys?.[keyID]) { @@ -430,7 +405,14 @@ export class MultiLog { continue; } - const secret = unsealKeySecret({ sealed: keyID, sealing: sealingKeyID, encrypted: entry.value.previousKeys[keyID] }, sealingKeySecret); + const secret = unsealKeySecret( + { + sealed: keyID, + sealing: sealingKeyID, + encrypted: entry.value.previousKeys[keyID], + }, + sealingKeySecret + ); if (secret) { return secret; @@ -438,7 +420,12 @@ export class MultiLog { } } - throw new Error("readKey " + keyID + " not revealed for " + getAgentID(getAgent(this.agentCredential))); + throw new Error( + "readKey " + + keyID + + " not revealed for " + + getAgentID(getAgent(this.agentCredential)) + ); } else if (this.header.ruleset.type === "ownedByTeam") { return this.requiredMultiLogs[this.header.ruleset.team].getReadKey( keyID From 82b861d21713933b25fc87712c7568b0e84fa273 Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 26 Jul 2023 13:01:03 +0100 Subject: [PATCH 14/47] Test that kicking reader out works --- src/multilog.ts | 8 +-- src/permissions.test.ts | 132 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/src/multilog.ts b/src/multilog.ts index ee007b91f..f7af010f4 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -243,7 +243,7 @@ export class MultiLog { let transaction: Transaction; if (privacy === "private") { - const { keySecret, keyID } = this.getCurrentReadKey(); + const { secret: keySecret, id: keyID } = this.getCurrentReadKey(); transaction = { privacy: "private", @@ -336,7 +336,7 @@ export class MultiLog { return allTransactions; } - getCurrentReadKey(): { keySecret: KeySecret; keyID: KeyID } { + getCurrentReadKey(): { secret: KeySecret; id: KeyID } { if (this.header.ruleset.type === "team") { const content = expectTeam(this.getCurrentContent()); @@ -349,8 +349,8 @@ export class MultiLog { const secret = this.getReadKey(currentKeyId); return { - keySecret: secret, - keyID: currentKeyId, + secret: secret, + id: currentKeyId, }; } else if (this.header.ruleset.type === "ownedByTeam") { return this.requiredMultiLogs[ diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 6d3978de3..2a3100641 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -321,7 +321,7 @@ test("Admins can set team read key and then use it to create and read private tr keyID: readKeyID, revelation, }); - expect(team.getCurrentReadKey().keySecret).toEqual(readKey); + expect(team.getCurrentReadKey().secret).toEqual(readKey); }); const childObject = node.createMultiLog({ @@ -380,7 +380,7 @@ test("Admins can set team read key and then writers can use it to create and rea newRandomSessionID(writerID) ); - expect(childObject.getCurrentReadKey().keySecret).toEqual(readKey); + expect(childObject.getCurrentReadKey().secret).toEqual(readKey); let childContentAsWriter = expectMap( childObjectAsWriter.getCurrentContent() @@ -440,7 +440,7 @@ test("Admins can set team read key and then use it to create private transaction newRandomSessionID(readerID) ); - expect(childObjectAsReader.getCurrentReadKey().keySecret).toEqual(readKey); + expect(childObjectAsReader.getCurrentReadKey().secret).toEqual(readKey); const childContentAsReader = expectMap( childObjectAsReader.getCurrentContent() @@ -508,7 +508,7 @@ test("Admins can set team read key and then use it to create private transaction newRandomSessionID(reader1ID) ); - expect(childObjectAsReader1.getCurrentReadKey().keySecret).toEqual(readKey); + expect(childObjectAsReader1.getCurrentReadKey().secret).toEqual(readKey); const childContentAsReader1 = expectMap( childObjectAsReader1.getCurrentContent() @@ -521,7 +521,7 @@ test("Admins can set team read key and then use it to create private transaction newRandomSessionID(reader2ID) ); - expect(childObjectAsReader2.getCurrentReadKey().keySecret).toEqual(readKey); + expect(childObjectAsReader2.getCurrentReadKey().secret).toEqual(readKey); const childContentAsReader2 = expectMap( childObjectAsReader2.getCurrentContent() @@ -551,7 +551,7 @@ test("Admins can set team read key, make a private transaction in an owned objec keyID: readKeyID, revelation, }); - expect(team.getCurrentReadKey().keySecret).toEqual(readKey); + expect(team.getCurrentReadKey().secret).toEqual(readKey); }); const childObject = node.createMultiLog({ @@ -588,7 +588,7 @@ test("Admins can set team read key, make a private transaction in an owned objec keyID: readKeyID2, revelation, }); - expect(team.getCurrentReadKey().keySecret).toEqual(readKey2); + expect(team.getCurrentReadKey().secret).toEqual(readKey2); }); childContent = expectMap(childObject.getCurrentContent()); @@ -630,7 +630,7 @@ test("Admins can set team read key, make a private transaction in an owned objec keyID: readKeyID, revelation, }); - expect(team.getCurrentReadKey().keySecret).toEqual(readKey); + expect(team.getCurrentReadKey().secret).toEqual(readKey); }); let childContent = expectMap(childObject.getCurrentContent()); @@ -679,7 +679,7 @@ test("Admins can set team read key, make a private transaction in an owned objec keyID: readKeyID2, revelation, }); - expect(team.getCurrentReadKey().keySecret).toEqual(readKey2); + expect(team.getCurrentReadKey().secret).toEqual(readKey2); editable.set(readerID, "reader", "trusting"); expect(editable.get(readerID)).toEqual("reader"); @@ -695,9 +695,7 @@ test("Admins can set team read key, make a private transaction in an owned objec newRandomSessionID(readerID) ); - expect(childObjectAsReader.getCurrentReadKey().keySecret).toEqual(readKey2); - - console.log(readKeyID2); + expect(childObjectAsReader.getCurrentReadKey().secret).toEqual(readKey2); const childContentAsReader = expectMap( childObjectAsReader.getCurrentContent() @@ -706,3 +704,113 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(childContentAsReader.get("foo")).toEqual("bar"); expect(childContentAsReader.get("foo2")).toEqual("bar2"); }); + +test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions", () => { + const { node, team, admin, adminID } = newTeam(); + + const childObject = node.createMultiLog({ + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }); + + const teamContent = expectTeam(team.getCurrentContent()); + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + const reader2 = newRandomAgentCredential(); + const reader2ID = getAgentID(getAgent(reader)); + + teamContent.edit((editable) => { + const revelation = seal( + readKey, + admin.recipientSecret, + new Set([getRecipientID(admin.recipientSecret), getRecipientID(reader.recipientSecret), getRecipientID(reader2.recipientSecret)]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); + expect(editable.get("readKey")).toEqual({ + keyID: readKeyID, + revelation, + }); + expect(team.getCurrentReadKey().secret).toEqual(readKey); + + editable.set(readerID, "reader", "trusting"); + expect(editable.get(readerID)).toEqual("reader"); + editable.set(reader2ID, "reader", "trusting"); + expect(editable.get(reader2ID)).toEqual("reader"); + }); + + let childContent = expectMap(childObject.getCurrentContent()); + + childContent.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + childContent = expectMap(childObject.getCurrentContent()); + expect(childContent.get("foo")).toEqual("bar"); + + let childObjectAsReader = childObject.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + expect(expectMap(childObjectAsReader.getCurrentContent()).get("foo")).toEqual("bar"); + + let childObjectAsReader2 = childObject.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + expect(expectMap(childObjectAsReader2.getCurrentContent()).get("foo")).toEqual("bar"); + + const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret(); + + teamContent.edit((editable) => { + const revelation = seal( + readKey2, + admin.recipientSecret, + new Set([getRecipientID(admin.recipientSecret), getRecipientID(reader2.recipientSecret)]), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + editable.set("readKey", { keyID: readKeyID2, revelation }, "trusting"); + expect(editable.get("readKey")).toEqual({ + keyID: readKeyID2, + revelation, + }); + expect(team.getCurrentReadKey().secret).toEqual(readKey2); + + editable.set(readerID, "revoked", "trusting"); + // expect(editable.get(readerID)).toEqual("revoked"); + }); + + + expect(childObject.getCurrentReadKey().secret).toEqual(readKey2); + + childContent = expectMap(childObject.getCurrentContent()); + childContent.edit((editable) => { + editable.set("foo2", "bar2", "private"); + expect(editable.get("foo2")).toEqual("bar2"); + }); + + // TODO: make sure these instances of multilogs sync between each other so this isn't necessary? + childObjectAsReader = childObject.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + childObjectAsReader2 = childObject.testWithDifferentCredentials( + reader2, + newRandomSessionID(reader2ID) + ); + + expect(() => expectMap(childObjectAsReader.getCurrentContent())).toThrow(/readKey (.+?) not revealed for (.+?)/); + expect(expectMap(childObjectAsReader2.getCurrentContent()).get("foo2")).toEqual("bar2"); + expect(() => {childObjectAsReader.getCurrentContent()}).toThrow(); +}) From fde29ac74fd6912ac9bee1b5c2c4d21326f0e746 Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 26 Jul 2023 19:05:01 +0100 Subject: [PATCH 15/47] Implement high level versions of permission tests --- src/coValue.ts | 3 +- src/crypto.test.ts | 72 +++--- src/crypto.ts | 57 +++-- src/multilog.ts | 10 +- src/node.ts | 52 +++- src/permissions.test.ts | 542 ++++++++++++++++++++++++++++++++++++---- src/permissions.ts | 161 +++++++++++- 7 files changed, 787 insertions(+), 110 deletions(-) diff --git a/src/coValue.ts b/src/coValue.ts index c04d99801..b498f77c9 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -153,9 +153,10 @@ export class CoMap< return json; } - edit(changer: (editable: WriteableCoMap) => void): void { + edit(changer: (editable: WriteableCoMap) => void): CoMap { const editable = new WriteableCoMap(this.multiLog); changer(editable); + return new CoMap(this.multiLog); } } diff --git a/src/crypto.test.ts b/src/crypto.test.ts index f6714ce83..fc4e0b1a2 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -106,50 +106,54 @@ test("Hashing is deterministic", () => { test("Encryption for transactions round-trips", () => { const { secret } = newRandomKeySecret(); - const encryptedChunks = [ - encryptForTransaction({ a: "hello" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, - }), - encryptForTransaction({ b: "world" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, - }), - ]; + const encrypted1 = encryptForTransaction({ a: "hello" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }); - const decryptedChunks = encryptedChunks.map((chunk, i) => - decryptForTransaction(chunk, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, - }) - ); + const encrypted2 = encryptForTransaction({ b: "world" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + }); - expect(decryptedChunks).toEqual([{ a: "hello" }, { b: "world" }]); + const decrypted1 = decryptForTransaction(encrypted1, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }); + + const decrypted2 = decryptForTransaction(encrypted2, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + }); + + expect([decrypted1, decrypted2]).toEqual([{ a: "hello" }, { b: "world" }]); }); test("Encryption for transactions doesn't decrypt with a wrong key", () => { const { secret } = newRandomKeySecret(); const { secret: secret2 } = newRandomKeySecret(); - const encryptedChunks = [ - encryptForTransaction({ a: "hello" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, - }), - encryptForTransaction({ b: "world" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, - }), - ]; + const encrypted1 = encryptForTransaction({ a: "hello" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }); - const decryptedChunks = encryptedChunks.map((chunk, i) => - decryptForTransaction(chunk, secret2, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: i }, - }) - ); + const encrypted2 = encryptForTransaction({ b: "world" }, secret, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + }); - expect(decryptedChunks).toEqual([undefined, undefined]); + const decrypted1 = decryptForTransaction(encrypted1, secret2, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + }); + + const decrypted2 = decryptForTransaction(encrypted2, secret2, { + in: "coval_zTEST", + tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + }); + + expect([decrypted1, decrypted2]).toEqual([undefined, undefined]); }); test("Encryption of keySecrets round-trips", () => { diff --git a/src/crypto.ts b/src/crypto.ts index d472cbb23..b5ba53ece 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -13,7 +13,7 @@ export type Signature = `signature_z${string}`; export type RecipientSecret = `recipientSecret_z${string}`; export type RecipientID = `recipient_z${string}`; -export type Sealed = `sealed_U${string}`; +export type Sealed = `sealed_U${string}` & { __type: T }; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -64,16 +64,16 @@ export function getRecipientID(secret: RecipientSecret): RecipientID { )}`; } -export type SealedSet = { - [recipient: RecipientID]: Sealed; +export type SealedSet = { + [recipient: RecipientID]: Sealed; }; -export function seal( - message: JsonValue, +export function seal( + message: T, from: RecipientSecret, to: Set, nOnceMaterial: { in: MultiLogID; tx: TransactionID } -): SealedSet { +): SealedSet { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) ).slice(0, 24); @@ -88,7 +88,7 @@ export function seal( const plaintext = textEncoder.encode(stableStringify(message)); - const sealedSet: SealedSet = {}; + const sealedSet: SealedSet = {}; for (let i = 0; i < recipientsSorted.length; i++) { const recipient = recipientsSorted[i]; @@ -101,18 +101,20 @@ export function seal( plaintext ); - sealedSet[recipient] = `sealed_U${base64url.encode(sealedBytes)}`; + sealedSet[recipient] = `sealed_U${base64url.encode( + sealedBytes + )}` as Sealed; } return sealedSet; } -export function openAs( - sealedSet: SealedSet, +export function openAs( + sealedSet: SealedSet, recipient: RecipientSecret, from: RecipientID, nOnceMaterial: { in: MultiLogID; tx: TransactionID } -): JsonValue | undefined { +): T | undefined { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) ).slice(0, 24); @@ -140,6 +142,7 @@ export function openAs( try { return JSON.parse(textDecoder.decode(plaintext)); } catch (e) { + console.error("Failed to decrypt/parse sealed message", e); return undefined; } } @@ -181,7 +184,10 @@ export function shortHash(value: JsonValue): ShortHash { )}`; } -export type Encrypted = `encrypted_U${string}`; +export type Encrypted< + T extends JsonValue, + N extends JsonValue +> = `encrypted_U${string}` & { __type: T; __nOnceMaterial: N }; export type KeySecret = `keySecret_z${string}`; export type KeyID = `key_z${string}`; @@ -197,7 +203,7 @@ function encrypt( value: T, keySecret: KeySecret, nOnceMaterial: N -): Encrypted { +): Encrypted { const keySecretBytes = base58.decode( keySecret.substring("keySecret_z".length) ); @@ -207,21 +213,25 @@ function encrypt( const plaintext = textEncoder.encode(stableStringify(value)); const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext); - return `encrypted_U${base64url.encode(ciphertext)}`; + return `encrypted_U${base64url.encode(ciphertext)}` as Encrypted; } export function encryptForTransaction( value: T, keySecret: KeySecret, nOnceMaterial: { in: MultiLogID; tx: TransactionID } -): Encrypted { +): Encrypted { return encrypt(value, keySecret, nOnceMaterial); } export function sealKeySecret(keys: { toSeal: { id: KeyID; secret: KeySecret }; sealing: { id: KeyID; secret: KeySecret }; -}): { sealed: KeyID; sealing: KeyID; encrypted: Encrypted } { +}): { + sealed: KeyID; + sealing: KeyID; + encrypted: Encrypted; +} { const nOnceMaterial = { sealed: keys.toSeal.id, sealing: keys.sealing.id, @@ -239,7 +249,7 @@ export function sealKeySecret(keys: { } function decrypt( - encrypted: Encrypted, + encrypted: Encrypted, keySecret: KeySecret, nOnceMaterial: N ): T | undefined { @@ -263,7 +273,7 @@ function decrypt( } export function decryptForTransaction( - encrypted: Encrypted, + encrypted: Encrypted, keySecret: KeySecret, nOnceMaterial: { in: MultiLogID; tx: TransactionID } ): T | undefined { @@ -271,10 +281,17 @@ export function decryptForTransaction( } export function unsealKeySecret( - sealedInfo: { sealed: KeyID; sealing: KeyID; encrypted: Encrypted }, + sealedInfo: { + sealed: KeyID; + sealing: KeyID; + encrypted: Encrypted; + }, sealingSecret: KeySecret ): KeySecret | undefined { - const nOnceMaterial = { sealed: sealedInfo.sealed, sealing: sealedInfo.sealing }; + const nOnceMaterial = { + sealed: sealedInfo.sealed, + sealing: sealedInfo.sealing, + }; return decrypt(sealedInfo.encrypted, sealingSecret, nOnceMaterial); } diff --git a/src/multilog.ts b/src/multilog.ts index f7af010f4..221fe66ff 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -28,7 +28,7 @@ import { base58 } from "@scure/base"; import { PermissionsDef as RulesetDef, determineValidTransactions, - expectTeam, + expectTeamContent, } from "./permissions"; export type MultiLogID = `coval_${string}`; @@ -65,7 +65,7 @@ export type PrivateTransaction = { privacy: "private"; madeAt: number; keyUsed: KeyID; - encryptedChanges: Encrypted; + encryptedChanges: Encrypted; }; export type TrustingTransaction = { @@ -338,7 +338,7 @@ export class MultiLog { getCurrentReadKey(): { secret: KeySecret; id: KeyID } { if (this.header.ruleset.type === "team") { - const content = expectTeam(this.getCurrentContent()); + const content = expectTeamContent(this.getCurrentContent()); const currentKeyId = content.get("readKey")?.keyID; @@ -365,7 +365,7 @@ export class MultiLog { getReadKey(keyID: KeyID): KeySecret { if (this.header.ruleset.type === "team") { - const content = expectTeam(this.getCurrentContent()); + const content = expectTeamContent(this.getCurrentContent()); const readKeyHistory = content.getHistory("readKey"); @@ -416,6 +416,8 @@ export class MultiLog { if (secret) { return secret; + } else { + console.error(`Sealing ${sealingKeyID} key didn't unseal ${keyID}`); } } } diff --git a/src/node.ts b/src/node.ts index ce34db012..9e37ad48e 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,3 +1,5 @@ +import { CoMap } from "./coValue"; +import { newRandomKeySecret, seal } from "./crypto"; import { MultiLogID, MultiLog, @@ -10,6 +12,7 @@ import { getAgentMultilogHeader, MultiLogHeader, } from "./multilog"; +import { Team, expectTeamContent } from "./permissions"; export class LocalNode { multilogs: { [key: MultiLogID]: Promise | MultiLog } = {}; @@ -38,9 +41,14 @@ export class LocalNode { } createMultiLog(header: MultiLogHeader): MultiLog { - const requiredMultiLogs = header.ruleset.type === "ownedByTeam" ? { - [header.ruleset.team]: this.expectMultiLogLoaded(header.ruleset.team) - } : {}; + const requiredMultiLogs = + header.ruleset.type === "ownedByTeam" + ? { + [header.ruleset.team]: this.expectMultiLogLoaded( + header.ruleset.team + ), + } + : {}; const multilog = new MultiLog( header, @@ -63,6 +71,44 @@ export class LocalNode { } return multilog; } + + addKnownAgent(agent: Agent) { + const agentID = getAgentID(agent); + this.knownAgents[agentID] = agent; + } + + createTeam(): Team { + const teamMultilog = this.createMultiLog({ + type: "comap", + ruleset: { type: "team", initialAdmin: this.agentID }, + meta: null, + }); + + let teamContent = expectTeamContent(teamMultilog.getCurrentContent()); + + teamContent = teamContent.edit((editable) => { + editable.set(this.agentID, "admin", "trusting"); + + const readKey = newRandomKeySecret(); + const revelation = seal( + readKey.secret, + this.agentCredential.recipientSecret, + new Set([getAgent(this.agentCredential).recipientID]), + { + in: teamMultilog.id, + tx: teamMultilog.nextTransactionID(), + } + ); + + editable.set( + "readKey", + { keyID: readKey.id, revelation }, + "trusting" + ); + }); + + return new Team(teamContent, this); + } } // type Hostname = string; diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 2a3100641..041e5a0ac 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -7,7 +7,7 @@ import { } from "./multilog"; import { LocalNode } from "./node"; import { expectMap } from "./coValue"; -import { expectTeam } from "./permissions"; +import { expectTeamContent } from "./permissions"; import { getRecipientID, newRandomKeySecret, @@ -21,14 +21,14 @@ function teamWithTwoAdmins() { const otherAdmin = newRandomAgentCredential(); const otherAdminID = getAgentID(getAgent(otherAdmin)); - let content = expectTeam(team.getCurrentContent()); + let content = expectTeamContent(team.getCurrentContent()); content.edit((editable) => { editable.set(otherAdminID, "admin", "trusting"); expect(editable.get(otherAdminID)).toEqual("admin"); }); - content = expectTeam(team.getCurrentContent()); + content = expectTeamContent(team.getCurrentContent()); if (content.type !== "comap") { throw new Error("Expected map"); @@ -50,7 +50,7 @@ function newTeam() { meta: null, }); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(adminID, "admin", "trusting"); @@ -64,6 +64,34 @@ test("Initial admin can add another admin to a team", () => { teamWithTwoAdmins(); }); +function newTeamHighLevel() { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + return { admin, adminID, node, team }; +} + +function teamWithTwoAdminsHighLevel() { + const { admin, adminID, node, team } = newTeamHighLevel(); + + const otherAdmin = newRandomAgentCredential(); + const otherAdminID = getAgentID(getAgent(otherAdmin)); + + node.addKnownAgent(getAgent(otherAdmin)); + + team.addMember(otherAdminID, "admin"); + + return { admin, adminID, node, team, otherAdmin, otherAdminID }; +} + +test("Initial admin can add another admin to a team (high level)", () => { + teamWithTwoAdminsHighLevel(); +}); + test("Added admin can add a third admin to a team", () => { const { team, otherAdmin, otherAdminID } = teamWithTwoAdmins(); @@ -72,7 +100,7 @@ test("Added admin can add a third admin to a team", () => { newRandomSessionID(otherAdminID) ); - let otherContent = expectTeam(teamAsOtherAdmin.getCurrentContent()); + let otherContent = expectTeamContent(teamAsOtherAdmin.getCurrentContent()); expect(otherContent.get(otherAdminID)).toEqual("admin"); @@ -84,22 +112,41 @@ test("Added admin can add a third admin to a team", () => { expect(editable.get(thirdAdminID)).toEqual("admin"); }); - otherContent = expectTeam(teamAsOtherAdmin.getCurrentContent()); + otherContent = expectTeamContent(teamAsOtherAdmin.getCurrentContent()); expect(otherContent.get(thirdAdminID)).toEqual("admin"); }); +test("Added adming can add a third admin to a team (high level)", () => { + const { team, otherAdmin, otherAdminID, node } = + teamWithTwoAdminsHighLevel(); + + const teamAsOtherAdmin = team.testWithDifferentCredentials( + otherAdmin, + newRandomSessionID(otherAdminID) + ); + + const thirdAdmin = newRandomAgentCredential(); + const thirdAdminID = getAgentID(getAgent(thirdAdmin)); + + node.addKnownAgent(getAgent(thirdAdmin)); + + teamAsOtherAdmin.addMember(thirdAdminID, "admin"); + + expect(teamAsOtherAdmin.teamMap.get(thirdAdminID)).toEqual("admin"); +}); + test("Admins can't demote other admins in a team", () => { const { team, adminID, otherAdmin, otherAdminID } = teamWithTwoAdmins(); - let teamContent = expectTeam(team.getCurrentContent()); + let teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(otherAdminID, "writer", "trusting"); expect(editable.get(otherAdminID)).toEqual("admin"); }); - teamContent = expectTeam(team.getCurrentContent()); + teamContent = expectTeamContent(team.getCurrentContent()); expect(teamContent.get(otherAdminID)).toEqual("admin"); const teamAsOtherAdmin = team.testWithDifferentCredentials( @@ -107,7 +154,7 @@ test("Admins can't demote other admins in a team", () => { newRandomSessionID(otherAdminID) ); - let teamContentAsOtherAdmin = expectTeam( + let teamContentAsOtherAdmin = expectTeamContent( teamAsOtherAdmin.getCurrentContent() ); @@ -116,24 +163,42 @@ test("Admins can't demote other admins in a team", () => { expect(editable.get(adminID)).toEqual("admin"); }); - teamContentAsOtherAdmin = expectTeam(teamAsOtherAdmin.getCurrentContent()); + teamContentAsOtherAdmin = expectTeamContent( + teamAsOtherAdmin.getCurrentContent() + ); expect(teamContentAsOtherAdmin.get(adminID)).toEqual("admin"); }); +test("Admins can't demote other admins in a team (high level)", () => { + const { team, adminID, otherAdmin, otherAdminID } = + teamWithTwoAdminsHighLevel(); + + const teamAsOtherAdmin = team.testWithDifferentCredentials( + otherAdmin, + newRandomSessionID(otherAdminID) + ); + + expect(() => teamAsOtherAdmin.addMember(adminID, "writer")).toThrow( + "Failed to set role" + ); + + expect(teamAsOtherAdmin.teamMap.get(adminID)).toEqual("admin"); +}); + test("Admins an add writers to a team, who can't add admins, writers, or readers", () => { const { team } = newTeam(); const writer = newRandomAgentCredential(); const writerID = getAgentID(getAgent(writer)); - let teamContent = expectTeam(team.getCurrentContent()); + let teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(writerID, "writer", "trusting"); expect(editable.get(writerID)).toEqual("writer"); }); - teamContent = expectTeam(team.getCurrentContent()); + teamContent = expectTeamContent(team.getCurrentContent()); expect(teamContent.get(writerID)).toEqual("writer"); const teamAsWriter = team.testWithDifferentCredentials( @@ -141,7 +206,9 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers newRandomSessionID(writerID) ); - let teamContentAsWriter = expectTeam(teamAsWriter.getCurrentContent()); + let teamContentAsWriter = expectTeamContent( + teamAsWriter.getCurrentContent() + ); expect(teamContentAsWriter.get(writerID)).toEqual("writer"); @@ -159,24 +226,60 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers expect(editable.get(otherAgentID)).toBeUndefined(); }); - teamContentAsWriter = expectTeam(teamAsWriter.getCurrentContent()); + teamContentAsWriter = expectTeamContent(teamAsWriter.getCurrentContent()); expect(teamContentAsWriter.get(otherAgentID)).toBeUndefined(); }); +test("Admins an add writers to a team, who can't add admins, writers, or readers (high level)", () => { + const { team, node } = newTeamHighLevel(); + + const writer = newRandomAgentCredential(); + const writerID = getAgentID(getAgent(writer)); + + node.addKnownAgent(getAgent(writer)); + + team.addMember(writerID, "writer"); + expect(team.teamMap.get(writerID)).toEqual("writer"); + + const teamAsWriter = team.testWithDifferentCredentials( + writer, + newRandomSessionID(writerID) + ); + + expect(teamAsWriter.teamMap.get(writerID)).toEqual("writer"); + + const otherAgent = newRandomAgentCredential(); + const otherAgentID = getAgentID(getAgent(otherAgent)); + + node.addKnownAgent(getAgent(otherAgent)); + + expect(() => teamAsWriter.addMember(otherAgentID, "admin")).toThrow( + "Failed to set role" + ); + expect(() => teamAsWriter.addMember(otherAgentID, "writer")).toThrow( + "Failed to set role" + ); + expect(() => teamAsWriter.addMember(otherAgentID, "reader")).toThrow( + "Failed to set role" + ); + + expect(teamAsWriter.teamMap.get(otherAgentID)).toBeUndefined(); +}); + test("Admins can add readers to a team, who can't add admins, writers, or readers", () => { const { team } = newTeam(); const reader = newRandomAgentCredential(); const readerID = getAgentID(getAgent(reader)); - let teamContent = expectTeam(team.getCurrentContent()); + let teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(readerID, "reader", "trusting"); expect(editable.get(readerID)).toEqual("reader"); }); - teamContent = expectTeam(team.getCurrentContent()); + teamContent = expectTeamContent(team.getCurrentContent()); expect(teamContent.get(readerID)).toEqual("reader"); const teamAsReader = team.testWithDifferentCredentials( @@ -184,7 +287,9 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader newRandomSessionID(readerID) ); - let teamContentAsReader = expectTeam(teamAsReader.getCurrentContent()); + let teamContentAsReader = expectTeamContent( + teamAsReader.getCurrentContent() + ); expect(teamContentAsReader.get(readerID)).toEqual("reader"); @@ -202,11 +307,47 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader expect(editable.get(otherAgentID)).toBeUndefined(); }); - teamContentAsReader = expectTeam(teamAsReader.getCurrentContent()); + teamContentAsReader = expectTeamContent(teamAsReader.getCurrentContent()); expect(teamContentAsReader.get(otherAgentID)).toBeUndefined(); }); +test("Admins can add readers to a team, who can't add admins, writers, or readers (high level)", () => { + const { team, node } = newTeamHighLevel(); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + node.addKnownAgent(getAgent(reader)); + + team.addMember(readerID, "reader"); + expect(team.teamMap.get(readerID)).toEqual("reader"); + + const teamAsReader = team.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ); + + expect(teamAsReader.teamMap.get(readerID)).toEqual("reader"); + + const otherAgent = newRandomAgentCredential(); + const otherAgentID = getAgentID(getAgent(otherAgent)); + + node.addKnownAgent(getAgent(otherAgent)); + + expect(() => teamAsReader.addMember(otherAgentID, "admin")).toThrow( + "Failed to set role" + ); + expect(() => teamAsReader.addMember(otherAgentID, "writer")).toThrow( + "Failed to set role" + ); + expect(() => teamAsReader.addMember(otherAgentID, "reader")).toThrow( + "Failed to set role" + ); + + expect(teamAsReader.teamMap.get(otherAgentID)).toBeUndefined(); +}); + test("Admins can write to an object that is owned by their team", () => { const { node, team } = newTeam(); @@ -228,13 +369,26 @@ test("Admins can write to an object that is owned by their team", () => { expect(childContent.get("foo")).toEqual("bar"); }); +test("Admins can write to an object that is owned by their team (high level)", () => { + const { node, team } = newTeamHighLevel(); + + let childObject = team.createMap(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObject.get("foo")).toEqual("bar"); +}); + test("Writers can write to an object that is owned by their team", () => { const { node, team } = newTeam(); const writer = newRandomAgentCredential(); const writerID = getAgentID(getAgent(writer)); - expectTeam(team.getCurrentContent()).edit((editable) => { + expectTeamContent(team.getCurrentContent()).edit((editable) => { editable.set(writerID, "writer", "trusting"); expect(editable.get(writerID)).toEqual("writer"); }); @@ -264,13 +418,39 @@ test("Writers can write to an object that is owned by their team", () => { expect(childContentAsWriter.get("foo")).toEqual("bar"); }); +test("Writers can write to an object that is owned by their team (high level)", () => { + const { node, team } = newTeamHighLevel(); + + const writer = newRandomAgentCredential(); + const writerID = getAgentID(getAgent(writer)); + + node.addKnownAgent(getAgent(writer)); + + team.addMember(writerID, "writer"); + + const childObject = team.createMap(); + + let childObjectAsWriter = expectMap( + childObject.multiLog + .testWithDifferentCredentials(writer, newRandomSessionID(writerID)) + .getCurrentContent() + ); + + childObjectAsWriter = childObjectAsWriter.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObjectAsWriter.get("foo")).toEqual("bar"); +}); + test("Readers can not write to an object that is owned by their team", () => { const { node, team } = newTeam(); const reader = newRandomAgentCredential(); const readerID = getAgentID(getAgent(reader)); - expectTeam(team.getCurrentContent()).edit((editable) => { + expectTeamContent(team.getCurrentContent()).edit((editable) => { editable.set(readerID, "reader", "trusting"); expect(editable.get(readerID)).toEqual("reader"); }); @@ -300,10 +480,36 @@ test("Readers can not write to an object that is owned by their team", () => { expect(childContentAsReader.get("foo")).toBeUndefined(); }); +test("Readers can not write to an object that is owned by their team (high level)", () => { + const { node, team } = newTeamHighLevel(); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + node.addKnownAgent(getAgent(reader)); + + team.addMember(readerID, "reader"); + + const childObject = team.createMap(); + + let childObjectAsReader = expectMap( + childObject.multiLog + .testWithDifferentCredentials(reader, newRandomSessionID(readerID)) + .getCurrentContent() + ); + + childObjectAsReader = childObjectAsReader.edit((editable) => { + editable.set("foo", "bar", "trusting"); + expect(editable.get("foo")).toBeUndefined(); + }); + + expect(childObjectAsReader.get("foo")).toBeUndefined(); +}); + test("Admins can set team read key and then use it to create and read private transactions in owned objects", () => { const { node, team, admin, adminID } = newTeam(); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { const { secret: readKey, id: readKeyID } = newRandomKeySecret(); @@ -341,6 +547,19 @@ test("Admins can set team read key and then use it to create and read private tr expect(childContent.get("foo")).toEqual("bar"); }); +test("Admins can set team read key and then use it to create and read private transactions in owned objects (high level)", () => { + const { node, team, admin } = newTeamHighLevel(); + + let childObject = team.createMap(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObject.get("foo")).toEqual("bar"); +}); + test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects", () => { const { node, team, admin } = newTeam(); @@ -348,7 +567,7 @@ test("Admins can set team read key and then writers can use it to create and rea const writerID = getAgentID(getAgent(writer)); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(writerID, "writer", "trusting"); @@ -396,6 +615,32 @@ test("Admins can set team read key and then writers can use it to create and rea expect(childContentAsWriter.get("foo")).toEqual("bar"); }); +test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects (high level)", () => { + const { node, team, admin } = newTeamHighLevel(); + + const writer = newRandomAgentCredential(); + const writerID = getAgentID(getAgent(writer)); + + node.addKnownAgent(getAgent(writer)); + + team.addMember(writerID, "writer"); + + const childObject = team.createMap(); + + let childObjectAsWriter = expectMap( + childObject.multiLog + .testWithDifferentCredentials(writer, newRandomSessionID(writerID)) + .getCurrentContent() + ); + + childObjectAsWriter = childObjectAsWriter.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObjectAsWriter.get("foo")).toEqual("bar"); +}); + test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read", () => { const { node, team, admin } = newTeam(); @@ -403,7 +648,7 @@ test("Admins can set team read key and then use it to create private transaction const readerID = getAgentID(getAgent(reader)); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(readerID, "reader", "trusting"); @@ -449,6 +694,32 @@ test("Admins can set team read key and then use it to create private transaction expect(childContentAsReader.get("foo")).toEqual("bar"); }); +test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read (high level)", () => { + const { node, team, admin } = newTeamHighLevel(); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + node.addKnownAgent(getAgent(reader)); + + team.addMember(readerID, "reader"); + + let childObject = team.createMap(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + const childContentAsReader = expectMap(childObject.multiLog.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ).getCurrentContent()); + + expect(childContentAsReader.get("foo")).toEqual("bar"); +}); + + test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key", () => { const { node, team, admin } = newTeam(); @@ -458,7 +729,7 @@ test("Admins can set team read key and then use it to create private transaction const reader2ID = getAgentID(getAgent(reader2)); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { editable.set(reader1ID, "reader", "trusting"); @@ -476,20 +747,26 @@ test("Admins can set team read key and then use it to create private transaction tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation: revelation1 }, "trusting"); + editable.set( + "readKey", + { keyID: readKeyID, revelation: revelation1 }, + "trusting" + ); const revelation2 = seal( readKey, admin.recipientSecret, - new Set([ - getRecipientID(reader2.recipientSecret), - ]), + new Set([getRecipientID(reader2.recipientSecret)]), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation: revelation2 }, "trusting"); + editable.set( + "readKey", + { keyID: readKeyID, revelation: revelation2 }, + "trusting" + ); }); const childObject = node.createMultiLog({ @@ -528,12 +805,50 @@ test("Admins can set team read key and then use it to create private transaction ); expect(childContentAsReader2.get("foo")).toEqual("bar"); -}) +}); + +test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key (high level)", () => { + const { node, team, admin } = newTeamHighLevel(); + + const reader1 = newRandomAgentCredential(); + const reader1ID = getAgentID(getAgent(reader1)); + const reader2 = newRandomAgentCredential(); + const reader2ID = getAgentID(getAgent(reader2)); + + node.addKnownAgent(getAgent(reader1)); + node.addKnownAgent(getAgent(reader2)); + + team.addMember(reader1ID, "reader"); + + let childObject = team.createMap(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + const childContentAsReader1 = expectMap(childObject.multiLog.testWithDifferentCredentials( + reader1, + newRandomSessionID(reader1ID) + ).getCurrentContent()); + + expect(childContentAsReader1.get("foo")).toEqual("bar"); + + team.addMember(reader2ID, "reader"); + + const childContentAsReader2 = expectMap(childObject.multiLog.testWithDifferentCredentials( + reader2, + newRandomSessionID(reader2ID) + ).getCurrentContent()); + + expect(childContentAsReader2.get("foo")).toEqual("bar"); +}); + test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, make another private transaction, and both can be read by the admin", () => { const { node, team, admin, adminID } = newTeam(); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { const { secret: readKey, id: readKeyID } = newRandomKeySecret(); @@ -603,6 +918,33 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(childContent.get("foo2")).toEqual("bar2"); }); +test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, make another private transaction, and both can be read by the admin (high level)", () => { + const { node, team, admin, adminID } = newTeamHighLevel(); + + let childObject = team.createMap(); + + const firstReadKey = childObject.multiLog.getCurrentReadKey(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObject.get("foo")).toEqual("bar"); + + team.rotateReadKey(); + + expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(firstReadKey); + + childObject = childObject.edit((editable) => { + editable.set("foo2", "bar2", "private"); + expect(editable.get("foo2")).toEqual("bar2"); + }); + + expect(childObject.get("foo")).toEqual("bar"); + expect(childObject.get("foo2")).toEqual("bar2"); +}); + test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, add a reader, make another private transaction in the owned object, and both can be read by the reader", () => { const { node, team, admin, adminID } = newTeam(); @@ -612,7 +954,7 @@ test("Admins can set team read key, make a private transaction in an owned objec meta: null, }); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); teamContent.edit((editable) => { @@ -705,6 +1047,46 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(childContentAsReader.get("foo2")).toEqual("bar2"); }); +test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, add a reader, make another private transaction in the owned object, and both can be read by the reader (high level)", () => { + const { node, team, admin, adminID } = newTeamHighLevel(); + + let childObject = team.createMap(); + + const firstReadKey = childObject.multiLog.getCurrentReadKey(); + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObject.get("foo")).toEqual("bar"); + + team.rotateReadKey(); + + expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(firstReadKey); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + + node.addKnownAgent(getAgent(reader)); + + team.addMember(readerID, "reader"); + + childObject = childObject.edit((editable) => { + editable.set("foo2", "bar2", "private"); + expect(editable.get("foo2")).toEqual("bar2"); + }); + + const childContentAsReader = expectMap(childObject.multiLog.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ).getCurrentContent()); + + expect(childContentAsReader.get("foo")).toEqual("bar"); + expect(childContentAsReader.get("foo2")).toEqual("bar2"); +}) + + test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions", () => { const { node, team, admin, adminID } = newTeam(); @@ -714,7 +1096,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec meta: null, }); - const teamContent = expectTeam(team.getCurrentContent()); + const teamContent = expectTeamContent(team.getCurrentContent()); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); const reader = newRandomAgentCredential(); const readerID = getAgentID(getAgent(reader)); @@ -725,7 +1107,11 @@ test("Admins can set team read rey, make a private transaction in an owned objec const revelation = seal( readKey, admin.recipientSecret, - new Set([getRecipientID(admin.recipientSecret), getRecipientID(reader.recipientSecret), getRecipientID(reader2.recipientSecret)]), + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(reader.recipientSecret), + getRecipientID(reader2.recipientSecret), + ]), { in: team.id, tx: team.nextTransactionID(), @@ -759,14 +1145,18 @@ test("Admins can set team read rey, make a private transaction in an owned objec newRandomSessionID(readerID) ); - expect(expectMap(childObjectAsReader.getCurrentContent()).get("foo")).toEqual("bar"); + expect( + expectMap(childObjectAsReader.getCurrentContent()).get("foo") + ).toEqual("bar"); let childObjectAsReader2 = childObject.testWithDifferentCredentials( reader, newRandomSessionID(readerID) ); - expect(expectMap(childObjectAsReader2.getCurrentContent()).get("foo")).toEqual("bar"); + expect( + expectMap(childObjectAsReader2.getCurrentContent()).get("foo") + ).toEqual("bar"); const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret(); @@ -774,7 +1164,10 @@ test("Admins can set team read rey, make a private transaction in an owned objec const revelation = seal( readKey2, admin.recipientSecret, - new Set([getRecipientID(admin.recipientSecret), getRecipientID(reader2.recipientSecret)]), + new Set([ + getRecipientID(admin.recipientSecret), + getRecipientID(reader2.recipientSecret), + ]), { in: team.id, tx: team.nextTransactionID(), @@ -791,7 +1184,6 @@ test("Admins can set team read rey, make a private transaction in an owned objec // expect(editable.get(readerID)).toEqual("revoked"); }); - expect(childObject.getCurrentReadKey().secret).toEqual(readKey2); childContent = expectMap(childObject.getCurrentContent()); @@ -810,7 +1202,73 @@ test("Admins can set team read rey, make a private transaction in an owned objec newRandomSessionID(reader2ID) ); - expect(() => expectMap(childObjectAsReader.getCurrentContent())).toThrow(/readKey (.+?) not revealed for (.+?)/); - expect(expectMap(childObjectAsReader2.getCurrentContent()).get("foo2")).toEqual("bar2"); - expect(() => {childObjectAsReader.getCurrentContent()}).toThrow(); -}) + expect(() => expectMap(childObjectAsReader.getCurrentContent())).toThrow( + /readKey (.+?) not revealed for (.+?)/ + ); + expect( + expectMap(childObjectAsReader2.getCurrentContent()).get("foo2") + ).toEqual("bar2"); + expect(() => { + childObjectAsReader.getCurrentContent(); + }).toThrow(); +}); + +test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions (high level)", () => { + const { node, team, admin, adminID } = newTeamHighLevel(); + + let childObject = team.createMap(); + + + childObject = childObject.edit((editable) => { + editable.set("foo", "bar", "private"); + expect(editable.get("foo")).toEqual("bar"); + }); + + expect(childObject.get("foo")).toEqual("bar"); + + team.rotateReadKey(); + + const secondReadKey = childObject.multiLog.getCurrentReadKey(); + + const reader = newRandomAgentCredential(); + const readerID = getAgentID(getAgent(reader)); + const reader2 = newRandomAgentCredential(); + const reader2ID = getAgentID(getAgent(reader2)); + + node.addKnownAgent(getAgent(reader)); + node.addKnownAgent(getAgent(reader2)); + + team.addMember(readerID, "reader"); + team.addMember(reader2ID, "reader"); + + childObject = childObject.edit((editable) => { + editable.set("foo2", "bar2", "private"); + expect(editable.get("foo2")).toEqual("bar2"); + }); + + expect(childObject.get("foo")).toEqual("bar"); + expect(childObject.get("foo2")).toEqual("bar2"); + + team.removeMember(readerID); + + expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(secondReadKey); + + childObject = childObject.edit((editable) => { + editable.set("foo3", "bar3", "private"); + expect(editable.get("foo3")).toEqual("bar3"); + }); + + const childContentAsReader2 = expectMap(childObject.multiLog.testWithDifferentCredentials( + reader2, + newRandomSessionID(reader2ID) + ).getCurrentContent()); + + expect(childContentAsReader2.get("foo")).toEqual("bar"); + expect(childContentAsReader2.get("foo2")).toEqual("bar2"); + expect(childContentAsReader2.get("foo3")).toEqual("bar3"); + + expect(() => childObject.multiLog.testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ).getCurrentContent()).toThrow(/readKey (.+?) not revealed for (.+?)/); +}); diff --git a/src/permissions.ts b/src/permissions.ts index 474bb5b6f..a2f4f1ce8 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,7 +1,19 @@ import { CoMap, CoValue, MapOpPayload } from "./coValue"; import { JsonValue } from "./jsonValue"; -import { Encrypted, KeyID, KeySecret, RecipientID, SealedSet, SignatoryID } from "./crypto"; import { + Encrypted, + KeyID, + KeySecret, + RecipientID, + SealedSet, + SignatoryID, + encryptForTransaction, + newRandomKeySecret, + seal, + sealKeySecret, +} from "./crypto"; +import { + AgentCredential, AgentID, MultiLog, MultiLogID, @@ -11,6 +23,7 @@ import { TrustingTransaction, agentIDfromSessionID, } from "./multilog"; +import { LocalNode } from "."; export type PermissionsDef = | { type: "team"; initialAdmin: AgentID; parentTeams?: MultiLogID[] } @@ -101,7 +114,8 @@ export function determineValidTransactions( if ( change.value !== "admin" && change.value !== "writer" && - change.value !== "reader" + change.value !== "reader" && + change.value !== "revoked" ) { console.warn("Team transaction must set a valid role"); continue; @@ -185,15 +199,150 @@ export function determineValidTransactions( } export type TeamContent = { [key: AgentID]: Role } & { - readKey: { keyID: KeyID; revelation: SealedSet, previousKeys?: { - [key: KeyID]: Encrypted - } }; + readKey: { + keyID: KeyID; + revelation: SealedSet; + previousKeys?: { + [key: KeyID]: Encrypted< + KeySecret, + { sealed: KeyID; sealing: KeyID } + >; + }; + }; }; -export function expectTeam(content: CoValue): CoMap { +export function expectTeamContent(content: CoValue): CoMap { if (content.type !== "comap") { throw new Error("Expected map"); } return content as CoMap; } + +export class Team { + teamMap: CoMap; + node: LocalNode; + + constructor(teamMap: CoMap, node: LocalNode) { + this.teamMap = teamMap; + this.node = node; + } + + addMember(agentID: AgentID, role: Role) { + this.teamMap = this.teamMap.edit((map) => { + const agent = this.node.knownAgents[agentID]; + + if (!agent) { + throw new Error("Unknown agent " + agentID); + } + + map.set(agentID, role, "trusting"); + if (map.get(agentID) !== role) { + throw new Error("Failed to set role"); + } + + const currentReadKey = this.teamMap.multiLog.getCurrentReadKey(); + + const revelation = seal( + currentReadKey.secret, + this.teamMap.multiLog.agentCredential.recipientSecret, + new Set([agent.recipientID]), + { + in: this.teamMap.multiLog.id, + tx: this.teamMap.multiLog.nextTransactionID(), + } + ); + + map.set( + "readKey", + { keyID: currentReadKey.id, revelation }, + "trusting" + ); + }); + } + + rotateReadKey() { + const currentlyPermittedReaders = this.teamMap.keys().filter((key) => { + if (key.startsWith("agent_")) { + const role = this.teamMap.get(key); + return ( + role === "admin" || role === "writer" || role === "reader" + ); + } else { + return false; + } + }) as AgentID[]; + + const currentReadKey = this.teamMap.multiLog.getCurrentReadKey(); + + const newReadKey = newRandomKeySecret(); + + const newReadKeyRevelation = seal( + newReadKey.secret, + this.teamMap.multiLog.agentCredential.recipientSecret, + new Set( + currentlyPermittedReaders.map( + (reader) => this.node.knownAgents[reader].recipientID + ) + ), + { + in: this.teamMap.multiLog.id, + tx: this.teamMap.multiLog.nextTransactionID(), + } + ); + + this.teamMap = this.teamMap.edit((map) => { + map.set( + "readKey", + { + keyID: newReadKey.id, + revelation: newReadKeyRevelation, + previousKeys: { + [currentReadKey.id]: sealKeySecret({ + sealing: newReadKey, + toSeal: currentReadKey, + }).encrypted, + }, + }, + "trusting" + ); + }); + } + + removeMember(agentID: AgentID) { + this.teamMap = this.teamMap.edit((map) => { + map.set(agentID, "revoked", "trusting"); + }); + + this.rotateReadKey(); + } + + createMap( + meta?: M + ): CoMap { + return this.node + .createMultiLog({ + type: "comap", + ruleset: { + type: "ownedByTeam", + team: this.teamMap.id, + }, + meta: meta || null, + }) + .getCurrentContent() as CoMap; + } + + testWithDifferentCredentials( + credential: AgentCredential, + sessionId: SessionID + ): Team { + return new Team( + expectTeamContent( + this.teamMap.multiLog + .testWithDifferentCredentials(credential, sessionId) + .getCurrentContent() + ), + this.node + ); + } +} From 362a83c2bc02a1cb0c017a16af1f0142b0224c95 Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 31 Jul 2023 16:52:20 +0100 Subject: [PATCH 16/47] First test and implementation for sync subscribe --- src/node.ts | 97 +++++++++++++++++++++++++++++++++---- src/permissions.ts | 4 ++ src/sync.test.ts | 118 +++++++++++++++++++++++++++++++++++++++++++++ src/sync.ts | 55 +++++++++++++++++++++ 4 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 src/sync.test.ts create mode 100644 src/sync.ts diff --git a/src/node.ts b/src/node.ts index 9e37ad48e..b61bf43d7 100644 --- a/src/node.ts +++ b/src/node.ts @@ -13,10 +13,20 @@ import { MultiLogHeader, } from "./multilog"; import { Team, expectTeamContent } from "./permissions"; +import { + NewContentMessage, + Peer, + PeerID, + SessionNewContent, + SubscribeMessage, + SyncMessage, + UnsubscribeMessage, + WrongAssumedKnownStateMessage, +} from "./sync"; export class LocalNode { multilogs: { [key: MultiLogID]: Promise | MultiLog } = {}; - // peers: {[key: Hostname]: Peer} = {}; + peers: { [key: PeerID]: Peer } = {}; agentCredential: AgentCredential; agentID: AgentID; ownSessionID: SessionID; @@ -109,13 +119,80 @@ export class LocalNode { return new Team(teamContent, this); } + + async addPeer(peer: Peer) { + this.peers[peer.id] = peer; + + const writer = peer.outgoing.getWriter(); + + for await (const msg of peer.incoming) { + const response = this.handleSyncMessage(msg); + + if (response) { + await writer.write(response); + } + } + } + + handleSyncMessage(msg: SyncMessage): SyncMessage | undefined { + // TODO: validate + switch (msg.type) { + case "subscribe": + return this.handleSubscribe(msg); + case "newContent": + return this.handleNewContent(msg); + case "wrongAssumedKnownState": + return this.handleWrongAssumedKnownState(msg); + case "unsubscribe": + return this.handleUnsubscribe(msg); + } + } + + handleSubscribe(msg: SubscribeMessage): SyncMessage | undefined { + const multilog = this.expectMultiLogLoaded(msg.knownState.multilogID); + + return { + type: "newContent", + multilogID: multilog.id, + header: multilog.header, + newContent: Object.fromEntries( + Object.entries(multilog.sessions) + .map(([sessionID, log]) => { + const newTransactions = log.transactions.slice( + msg.knownState.sessions[sessionID as SessionID] || 0 + ); + + if ( + newTransactions.length === 0 || + !log.lastHash || + !log.lastSignature + ) { + return undefined; + } + + return [ + sessionID, + { + after: + msg.knownState.sessions[ + sessionID as SessionID + ] || 0, + newTransactions, + lastHash: log.lastHash, + lastSignature: log.lastSignature, + }, + ]; + }) + .filter((x): x is Exclude => !!x) + ), + }; + } + + handleNewContent(msg: NewContentMessage): SyncMessage | undefined {} + + handleWrongAssumedKnownState( + msg: WrongAssumedKnownStateMessage + ): SyncMessage | undefined {} + + handleUnsubscribe(msg: UnsubscribeMessage): SyncMessage | undefined {} } - -// type Hostname = string; - -// interface Peer { -// hostname: Hostname; -// incoming: ReadableStream; -// outgoing: WritableStream; -// optimisticKnownStates: {[multilogID: MultiLogID]: MultilogKnownState}; -// } diff --git a/src/permissions.ts b/src/permissions.ts index a2f4f1ce8..e5a6ee12d 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -228,6 +228,10 @@ export class Team { this.node = node; } + get id(): MultiLogID { + return this.teamMap.id; + } + addMember(agentID: AgentID, role: Role) { this.teamMap = this.teamMap.edit((map) => { const agent = this.node.knownAgents[agentID]; diff --git a/src/sync.test.ts b/src/sync.test.ts new file mode 100644 index 000000000..5132f9f6e --- /dev/null +++ b/src/sync.test.ts @@ -0,0 +1,118 @@ +import { test, expect } from "bun:test"; +import { + getAgent, + getAgentID, + newRandomAgentCredential, + newRandomSessionID, +} from "./multilog"; +import { LocalNode } from "./node"; +import { SyncMessage } from "./sync"; +import { MapOpPayload } from "./coValue"; + +test( + "Node replies with initial tx and header to empty subscribe", + async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + const map = team.createMap(); + + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + optimisticKnownStates: {}, + }); + + const writer = inTx.getWriter(); + + await writer.write({ + type: "subscribe", + knownState: { + multilogID: map.multiLog.id, + header: false, + sessions: {}, + }, + }); + + const reader = outRx.getReader(); + + const firstMessage = await reader.read(); + + expect(firstMessage.value).toEqual({ + type: "newContent", + multilogID: map.multiLog.id, + header: { + type: "comap", + ruleset: { type: "ownedByTeam", team: team.id }, + meta: null, + }, + newContent: { + [node.ownSessionID]: { + after: 0, + newTransactions: [ + { + privacy: "trusting", + madeAt: map.multiLog.sessions[node.ownSessionID] + .transactions[0].madeAt, + changes: [ + { + op: "insert", + key: "hello", + value: "world", + } satisfies MapOpPayload, + ], + }, + ], + lastHash: + map.multiLog.sessions[node.ownSessionID].lastHash!, + lastSignature: + map.multiLog.sessions[node.ownSessionID].lastSignature!, + }, + }, + } satisfies SyncMessage); + }, + { timeout: 100 } +); + +function newStreamPair(): [ReadableStream, WritableStream] { + const queue: T[] = []; + let resolveNextItemReady: () => void = () => {}; + let nextItemReady: Promise = new Promise((resolve) => { + resolveNextItemReady = resolve; + }); + + const readable = new ReadableStream({ + async pull(controller) { + if (queue.length > 0) { + controller.enqueue(queue.shift()); + } else { + await nextItemReady; + nextItemReady = new Promise((resolve) => { + resolveNextItemReady = resolve; + }); + controller.enqueue(queue.shift()); + } + }, + }); + + const writable = new WritableStream({ + write(chunk) { + queue.push(chunk); + resolveNextItemReady(); + }, + }); + + return [readable, writable]; +} diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 000000000..992cc2766 --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,55 @@ +import { Hash } from "./crypto"; +import { MultiLogHeader, MultiLogID, SessionID, Transaction } from "./multilog"; + +type MultiLogKnownState = { + multilogID: MultiLogID; + header: boolean; + sessions: { [sessionID: SessionID]: number }; +}; + +export type SyncMessage = + | SubscribeMessage + | NewContentMessage + | WrongAssumedKnownStateMessage + | UnsubscribeMessage; + +export type SubscribeMessage = { + type: "subscribe"; + knownState: MultiLogKnownState; +}; + +export type NewContentMessage = { + type: "newContent"; + multilogID: MultiLogID; + header?: MultiLogHeader; + newContent: { + [sessionID: SessionID]: SessionNewContent; + }; +}; + +export type SessionNewContent = { + after: number; + newTransactions: Transaction[]; + lastHash: Hash; + lastSignature: string; +} + +export type WrongAssumedKnownStateMessage = { + type: "wrongAssumedKnownState"; + knownState: MultiLogKnownState; +}; + +export type UnsubscribeMessage = { + type: "unsubscribe"; + multilogID: MultiLogID; +}; + +export type PeerID = string; + +export interface Peer { + id: PeerID; + incoming: ReadableStream; + outgoing: WritableStream; + optimisticKnownStates: {[multilogID: MultiLogID]: MultiLogKnownState}; +} + From ad8e51c66c5b963af1b67012978fe01d121c8a5c Mon Sep 17 00:00:00 2001 From: Anselm Date: Tue, 1 Aug 2023 11:19:54 +0100 Subject: [PATCH 17/47] Test and implementation for syncing new transactions --- src/multilog.ts | 132 +++++++++++++++++++-------------- src/node.ts | 141 ++++++++++++++++++++--------------- src/permissions.ts | 8 +- src/sync.test.ts | 181 ++++++++++++++++++++++++++++++++++++++++++++- src/sync.ts | 9 ++- 5 files changed, 347 insertions(+), 124 deletions(-) diff --git a/src/multilog.ts b/src/multilog.ts index 221fe66ff..62d274649 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -30,6 +30,8 @@ import { determineValidTransactions, expectTeamContent, } from "./permissions"; +import { LocalNode } from "./node"; +import { MultiLogKnownState, NewContentMessage } from "./sync"; export type MultiLogID = `coval_${string}`; @@ -65,7 +67,10 @@ export type PrivateTransaction = { privacy: "private"; madeAt: number; keyUsed: KeyID; - encryptedChanges: Encrypted; + encryptedChanges: Encrypted< + JsonValue[], + { in: MultiLogID; tx: TransactionID } + >; }; export type TrustingTransaction = { @@ -86,61 +91,33 @@ export type TransactionID = { sessionID: SessionID; txIndex: number }; export class MultiLog { id: MultiLogID; + node: LocalNode; header: MultiLogHeader; sessions: { [key: SessionID]: SessionLog }; - agentCredential: AgentCredential; - ownSessionID: SessionID; - knownAgents: { [key: AgentID]: Agent }; - requiredMultiLogs: { [key: MultiLogID]: MultiLog }; content?: CoValue; - constructor( - header: MultiLogHeader, - agentCredential: AgentCredential, - ownSessionID: SessionID, - knownAgents: { [key: AgentID]: Agent }, - requiredMultiLogs: { [key: MultiLogID]: MultiLog } - ) { + constructor(header: MultiLogHeader, node: LocalNode) { this.id = multilogIDforHeader(header); this.header = header; this.sessions = {}; - this.agentCredential = agentCredential; - this.ownSessionID = ownSessionID; - this.knownAgents = knownAgents; - this.requiredMultiLogs = requiredMultiLogs; + this.node = node; } testWithDifferentCredentials( agentCredential: AgentCredential, ownSessionID: SessionID ): MultiLog { - const knownAgents = { - ...this.knownAgents, - [agentIDfromSessionID(ownSessionID)]: getAgent(agentCredential), - }; - const cloned = new MultiLog( - this.header, + const newNode = this.node.testWithDifferentCredentials( agentCredential, - ownSessionID, - knownAgents, - Object.fromEntries( - Object.entries(this.requiredMultiLogs).map(([id, multilog]) => [ - id, - multilog.testWithDifferentCredentials( - agentCredential, - ownSessionID - ), - ]) - ) + ownSessionID ); - cloned.sessions = JSON.parse(JSON.stringify(this.sessions)); - - return cloned; + return newNode.expectMultiLogLoaded(this.id); } - knownState(): MultilogKnownState { + knownState(): MultiLogKnownState { return { + multilogID: this.id, header: true, sessions: Object.fromEntries( Object.entries(this.sessions).map(([k, v]) => [ @@ -156,7 +133,7 @@ export class MultiLog { } nextTransactionID(): TransactionID { - const sessionID = this.ownSessionID; + const sessionID = this.node.ownSessionID; return { sessionID, txIndex: this.sessions[sessionID]?.transactions.length || 0, @@ -170,7 +147,7 @@ export class MultiLog { newSignature: Signature ): boolean { const signatoryID = - this.knownAgents[agentIDfromSessionID(sessionID)]?.signatoryID; + this.node.knownAgents[agentIDfromSessionID(sessionID)]?.signatoryID; if (!signatoryID) { console.warn("Unknown agent", agentIDfromSessionID(sessionID)); @@ -210,6 +187,8 @@ export class MultiLog { this.content = undefined; + this.node.syncMultiLog(this); + const _ = this.getCurrentContent(); return true; @@ -262,14 +241,14 @@ export class MultiLog { }; } - const sessionID = this.ownSessionID; + const sessionID = this.node.ownSessionID; const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [ transaction, ]); const signature = sign( - this.agentCredential.signatorySecret, + this.node.agentCredential.signatorySecret, expectedNewHash ); @@ -353,9 +332,9 @@ export class MultiLog { id: currentKeyId, }; } else if (this.header.ruleset.type === "ownedByTeam") { - return this.requiredMultiLogs[ - this.header.ruleset.team - ].getCurrentReadKey(); + return this.node + .expectMultiLogLoaded(this.header.ruleset.team) + .getCurrentReadKey(); } else { throw new Error( "Only teams or values owned by teams have read secrets" @@ -374,7 +353,7 @@ export class MultiLog { for (const entry of readKeyHistory) { if (entry.value?.keyID === keyID) { const revealer = agentIDfromSessionID(entry.txID.sessionID); - const revealerAgent = this.knownAgents[revealer]; + const revealerAgent = this.node.knownAgents[revealer]; if (!revealerAgent) { throw new Error("Unknown revealer"); @@ -382,7 +361,7 @@ export class MultiLog { const secret = openAs( entry.value.revelation, - this.agentCredential.recipientSecret, + this.node.agentCredential.recipientSecret, revealerAgent.recipientID, { in: this.id, @@ -417,7 +396,9 @@ export class MultiLog { if (secret) { return secret; } else { - console.error(`Sealing ${sealingKeyID} key didn't unseal ${keyID}`); + console.error( + `Sealing ${sealingKeyID} key didn't unseal ${keyID}` + ); } } } @@ -426,12 +407,12 @@ export class MultiLog { "readKey " + keyID + " not revealed for " + - getAgentID(getAgent(this.agentCredential)) + getAgentID(getAgent(this.node.agentCredential)) ); } else if (this.header.ruleset.type === "ownedByTeam") { - return this.requiredMultiLogs[this.header.ruleset.team].getReadKey( - keyID - ); + return this.node + .expectMultiLogLoaded(this.header.ruleset.team) + .getReadKey(keyID); } else { throw new Error( "Only teams or values owned by teams have read secrets" @@ -442,12 +423,51 @@ export class MultiLog { getTx(txID: TransactionID): Transaction | undefined { return this.sessions[txID.sessionID]?.transactions[txID.txIndex]; } -} -type MultilogKnownState = { - header: boolean; - sessions: { [key: SessionID]: number }; -}; + newContentSince(knownState: MultiLogKnownState | undefined): NewContentMessage | undefined { + const newContent: NewContentMessage = { + type: "newContent", + multilogID: this.id, + header: knownState?.header ? undefined : this.header, + newContent: Object.fromEntries( + Object.entries(this.sessions) + .map(([sessionID, log]) => { + const newTransactions = log.transactions.slice( + knownState?.sessions[sessionID as SessionID] || 0 + ); + + if ( + newTransactions.length === 0 || + !log.lastHash || + !log.lastSignature + ) { + return undefined; + } + + return [ + sessionID, + { + after: + knownState?.sessions[ + sessionID as SessionID + ] || 0, + newTransactions, + lastHash: log.lastHash, + lastSignature: log.lastSignature, + }, + ]; + }) + .filter((x): x is Exclude => !!x) + ), + } + + if (!newContent.header && Object.keys(newContent.newContent).length === 0) { + return undefined; + } + + return newContent; + } +} export type AgentID = `agent_${string}`; diff --git a/src/node.ts b/src/node.ts index b61bf43d7..8ff1f6cf7 100644 --- a/src/node.ts +++ b/src/node.ts @@ -11,12 +11,14 @@ import { getAgentID, getAgentMultilogHeader, MultiLogHeader, + agentIDfromSessionID, } from "./multilog"; import { Team, expectTeamContent } from "./permissions"; import { NewContentMessage, Peer, PeerID, + PeerState, SessionNewContent, SubscribeMessage, SyncMessage, @@ -26,7 +28,7 @@ import { export class LocalNode { multilogs: { [key: MultiLogID]: Promise | MultiLog } = {}; - peers: { [key: PeerID]: Peer } = {}; + peers: { [key: PeerID]: PeerState } = {}; agentCredential: AgentCredential; agentID: AgentID; ownSessionID: SessionID; @@ -40,13 +42,7 @@ export class LocalNode { this.knownAgents[agentID] = agent; this.ownSessionID = ownSessionID; - const agentMultilog = new MultiLog( - getAgentMultilogHeader(agent), - agentCredential, - ownSessionID, - this.knownAgents, - {} - ); + const agentMultilog = new MultiLog(getAgentMultilogHeader(agent), this); this.multilogs[agentMultilog.id] = Promise.resolve(agentMultilog); } @@ -60,13 +56,7 @@ export class LocalNode { } : {}; - const multilog = new MultiLog( - header, - this.agentCredential, - this.ownSessionID, - this.knownAgents, - requiredMultiLogs - ); + const multilog = new MultiLog(header, this); this.multilogs[multilog.id] = multilog; return multilog; } @@ -121,24 +111,31 @@ export class LocalNode { } async addPeer(peer: Peer) { - this.peers[peer.id] = peer; + const peerState = { + id: peer.id, + optimisticKnownStates: {}, + incoming: peer.incoming, + outgoing: peer.outgoing.getWriter(), + }; + this.peers[peer.id] = peerState; - const writer = peer.outgoing.getWriter(); - - for await (const msg of peer.incoming) { - const response = this.handleSyncMessage(msg); + for await (const msg of peerState.incoming) { + const response = this.handleSyncMessage(msg, peerState); if (response) { - await writer.write(response); + await peerState.outgoing.write(response); } } } - handleSyncMessage(msg: SyncMessage): SyncMessage | undefined { + handleSyncMessage( + msg: SyncMessage, + peer: PeerState + ): SyncMessage | undefined { // TODO: validate switch (msg.type) { case "subscribe": - return this.handleSubscribe(msg); + return this.handleSubscribe(msg, peer); case "newContent": return this.handleNewContent(msg); case "wrongAssumedKnownState": @@ -148,51 +145,73 @@ export class LocalNode { } } - handleSubscribe(msg: SubscribeMessage): SyncMessage | undefined { + handleSubscribe( + msg: SubscribeMessage, + peer: PeerState + ): SyncMessage | undefined { const multilog = this.expectMultiLogLoaded(msg.knownState.multilogID); - return { - type: "newContent", - multilogID: multilog.id, - header: multilog.header, - newContent: Object.fromEntries( - Object.entries(multilog.sessions) - .map(([sessionID, log]) => { - const newTransactions = log.transactions.slice( - msg.knownState.sessions[sessionID as SessionID] || 0 - ); + peer.optimisticKnownStates[multilog.id] = multilog.knownState(); - if ( - newTransactions.length === 0 || - !log.lastHash || - !log.lastSignature - ) { - return undefined; - } - - return [ - sessionID, - { - after: - msg.knownState.sessions[ - sessionID as SessionID - ] || 0, - newTransactions, - lastHash: log.lastHash, - lastSignature: log.lastSignature, - }, - ]; - }) - .filter((x): x is Exclude => !!x) - ), - }; + return multilog.newContentSince(msg.knownState); } - handleNewContent(msg: NewContentMessage): SyncMessage | undefined {} + handleNewContent(msg: NewContentMessage): SyncMessage | undefined { + return undefined; + } handleWrongAssumedKnownState( msg: WrongAssumedKnownStateMessage - ): SyncMessage | undefined {} + ): SyncMessage | undefined { + return undefined; + } - handleUnsubscribe(msg: UnsubscribeMessage): SyncMessage | undefined {} + handleUnsubscribe(msg: UnsubscribeMessage): SyncMessage | undefined { + return undefined; + } + + async syncMultiLog(multilog: MultiLog) { + for (const peer of Object.values(this.peers)) { + const optimisticKnownState = + peer.optimisticKnownStates[multilog.id]; + + const newContent = multilog.newContentSince(optimisticKnownState); + + peer.optimisticKnownStates[multilog.id] = multilog.knownState(); + + if (newContent) { + await peer.outgoing.write(newContent); + } + } + } + + testWithDifferentCredentials( + agentCredential: AgentCredential, + ownSessionID: SessionID + ): LocalNode { + const newNode = new LocalNode(agentCredential, ownSessionID); + + newNode.multilogs = Object.fromEntries( + Object.entries(this.multilogs) + .map(([id, multilog]) => { + if (multilog instanceof Promise) { + return [id, undefined]; + } + + const newMultilog = new MultiLog(multilog.header, newNode); + + newMultilog.sessions = multilog.sessions; + + return [id, newMultilog]; + }) + .filter((x): x is Exclude => !!x) + ); + + newNode.knownAgents = { + ...this.knownAgents, + [agentIDfromSessionID(ownSessionID)]: getAgent(agentCredential), + }; + + return newNode; + } } diff --git a/src/permissions.ts b/src/permissions.ts index e5a6ee12d..1f3253255 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -155,9 +155,9 @@ export function determineValidTransactions( return validTransactions; } else if (multilog.header.ruleset.type === "ownedByTeam") { const teamContent = - multilog.requiredMultiLogs[ + multilog.node.expectMultiLogLoaded( multilog.header.ruleset.team - ].getCurrentContent(); + ).getCurrentContent(); if (teamContent.type !== "comap") { throw new Error("Team must be a map"); @@ -249,7 +249,7 @@ export class Team { const revelation = seal( currentReadKey.secret, - this.teamMap.multiLog.agentCredential.recipientSecret, + this.teamMap.multiLog.node.agentCredential.recipientSecret, new Set([agent.recipientID]), { in: this.teamMap.multiLog.id, @@ -283,7 +283,7 @@ export class Team { const newReadKeyRevelation = seal( newReadKey.secret, - this.teamMap.multiLog.agentCredential.recipientSecret, + this.teamMap.multiLog.node.agentCredential.recipientSecret, new Set( currentlyPermittedReaders.map( (reader) => this.node.knownAgents[reader].recipientID diff --git a/src/sync.test.ts b/src/sync.test.ts index 5132f9f6e..352a3affc 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -32,7 +32,6 @@ test( id: "test", incoming: inRx, outgoing: outTx, - optimisticKnownStates: {}, }); const writer = inTx.getWriter(); @@ -86,6 +85,186 @@ test( { timeout: 100 } ); +test("Node replies with only new tx to subscribe with some known state", async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + const map = team.createMap(); + + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + editable.set("goodbye", "world", "trusting"); + }); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + }); + + const writer = inTx.getWriter(); + + await writer.write({ + type: "subscribe", + knownState: { + multilogID: map.multiLog.id, + header: true, + sessions: { + [node.ownSessionID]: 1, + }, + }, + }); + + const reader = outRx.getReader(); + + const firstMessage = await reader.read(); + + expect(firstMessage.value).toEqual({ + type: "newContent", + multilogID: map.multiLog.id, + header: undefined, + newContent: { + [node.ownSessionID]: { + after: 1, + newTransactions: [ + { + privacy: "trusting", + madeAt: map.multiLog.sessions[node.ownSessionID] + .transactions[1].madeAt, + changes: [ + { + op: "insert", + key: "goodbye", + value: "world", + } satisfies MapOpPayload, + ], + }, + ], + lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastSignature: + map.multiLog.sessions[node.ownSessionID].lastSignature!, + }, + }, + } satisfies SyncMessage); +}); + +test("After subscribing, node sends new txs to peer", async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + const map = team.createMap(); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + }); + + const writer = inTx.getWriter(); + + await writer.write({ + type: "subscribe", + knownState: { + multilogID: map.multiLog.id, + header: false, + sessions: { + [node.ownSessionID]: 0, + }, + }, + }); + + const reader = outRx.getReader(); + + const firstMessage = await reader.read(); + + expect(firstMessage.value).toEqual({ + type: "newContent", + multilogID: map.multiLog.id, + header: map.multiLog.header, + newContent: {}, + } satisfies SyncMessage); + + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + const secondMessage = await reader.read(); + + expect(secondMessage.value).toEqual({ + type: "newContent", + multilogID: map.multiLog.id, + newContent: { + [node.ownSessionID]: { + after: 0, + newTransactions: [ + { + privacy: "trusting", + madeAt: map.multiLog.sessions[node.ownSessionID] + .transactions[0].madeAt, + changes: [ + { + op: "insert", + key: "hello", + value: "world", + } satisfies MapOpPayload, + ], + }, + ], + lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastSignature: + map.multiLog.sessions[node.ownSessionID].lastSignature!, + }, + }, + } satisfies SyncMessage); + + map.edit((editable) => { + editable.set("goodbye", "world", "trusting"); + }); + + const thirdMessage = await reader.read(); + + expect(thirdMessage.value).toEqual({ + type: "newContent", + multilogID: map.multiLog.id, + newContent: { + [node.ownSessionID]: { + after: 1, + newTransactions: [ + { + privacy: "trusting", + madeAt: map.multiLog.sessions[node.ownSessionID] + .transactions[1].madeAt, + changes: [ + { + op: "insert", + key: "goodbye", + value: "world", + } satisfies MapOpPayload, + ], + }, + ], + lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastSignature: + map.multiLog.sessions[node.ownSessionID].lastSignature!, + }, + }, + } satisfies SyncMessage); +}); + function newStreamPair(): [ReadableStream, WritableStream] { const queue: T[] = []; let resolveNextItemReady: () => void = () => {}; diff --git a/src/sync.ts b/src/sync.ts index 992cc2766..9c7c89a7c 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,7 +1,7 @@ import { Hash } from "./crypto"; import { MultiLogHeader, MultiLogID, SessionID, Transaction } from "./multilog"; -type MultiLogKnownState = { +export type MultiLogKnownState = { multilogID: MultiLogID; header: boolean; sessions: { [sessionID: SessionID]: number }; @@ -50,6 +50,11 @@ export interface Peer { id: PeerID; incoming: ReadableStream; outgoing: WritableStream; - optimisticKnownStates: {[multilogID: MultiLogID]: MultiLogKnownState}; } +export interface PeerState { + id: PeerID; + optimisticKnownStates: {[multilogID: MultiLogID]: MultiLogKnownState}; + incoming: ReadableStream; + outgoing: WritableStreamDefaultWriter; +} \ No newline at end of file From c5bc519fbe203169b69195416cf44e421c499799 Mon Sep 17 00:00:00 2001 From: Anselm Date: Tue, 1 Aug 2023 13:23:25 +0100 Subject: [PATCH 18/47] Implement invalid known state handling --- src/node.ts | 11 +++-- src/sync.test.ts | 113 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/node.ts b/src/node.ts index 8ff1f6cf7..326f80e22 100644 --- a/src/node.ts +++ b/src/node.ts @@ -139,7 +139,7 @@ export class LocalNode { case "newContent": return this.handleNewContent(msg); case "wrongAssumedKnownState": - return this.handleWrongAssumedKnownState(msg); + return this.handleWrongAssumedKnownState(msg, peer); case "unsubscribe": return this.handleUnsubscribe(msg); } @@ -161,9 +161,14 @@ export class LocalNode { } handleWrongAssumedKnownState( - msg: WrongAssumedKnownStateMessage + msg: WrongAssumedKnownStateMessage, + peer: PeerState ): SyncMessage | undefined { - return undefined; + const multilog = this.expectMultiLogLoaded(msg.knownState.multilogID); + + peer.optimisticKnownStates[msg.knownState.multilogID] = msg.knownState; + + return multilog.newContentSince(msg.knownState); } handleUnsubscribe(msg: UnsubscribeMessage): SyncMessage | undefined { diff --git a/src/sync.test.ts b/src/sync.test.ts index 352a3affc..1ddf187d2 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -265,6 +265,95 @@ test("After subscribing, node sends new txs to peer", async () => { } satisfies SyncMessage); }); +test("No matter the optimistic known state, node respects invalid known state messages and resyncs", async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + const map = team.createMap(); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + }); + + const writer = inTx.getWriter(); + + await writer.write({ + type: "subscribe", + knownState: { + multilogID: map.multiLog.id, + header: false, + sessions: { + [node.ownSessionID]: 0, + }, + }, + }); + + const reader = outRx.getReader(); + + const _firstMessage = await reader.read(); + + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + map.edit((editable) => { + editable.set("goodbye", "world", "trusting"); + }); + + const _secondMessage = await reader.read(); + const _thirdMessage = await reader.read(); + + await writer.write({ + type: "wrongAssumedKnownState", + knownState: { + multilogID: map.multiLog.id, + header: true, + sessions: { + [node.ownSessionID]: 1, + }, + }, + } satisfies SyncMessage); + + const fourthMessage = await reader.read(); + + expect(fourthMessage.value).toEqual({ + type: "newContent", + multilogID: map.multiLog.id, + header: undefined, + newContent: { + [node.ownSessionID]: { + after: 1, + newTransactions: [ + { + privacy: "trusting", + madeAt: map.multiLog.sessions[node.ownSessionID] + .transactions[1].madeAt, + changes: [ + { + op: "insert", + key: "goodbye", + value: "world", + } satisfies MapOpPayload, + ], + }, + ], + lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastSignature: + map.multiLog.sessions[node.ownSessionID].lastSignature!, + }, + }, + } satisfies SyncMessage); +}); + function newStreamPair(): [ReadableStream, WritableStream] { const queue: T[] = []; let resolveNextItemReady: () => void = () => {}; @@ -274,14 +363,18 @@ function newStreamPair(): [ReadableStream, WritableStream] { const readable = new ReadableStream({ async pull(controller) { - if (queue.length > 0) { - controller.enqueue(queue.shift()); - } else { - await nextItemReady; - nextItemReady = new Promise((resolve) => { - resolveNextItemReady = resolve; - }); - controller.enqueue(queue.shift()); + while(true) { + if (queue.length > 0) { + controller.enqueue(queue.shift()); + if (queue.length === 0) { + nextItemReady = new Promise((resolve) => { + resolveNextItemReady = resolve; + }); + } + return; + } else { + await nextItemReady; + } } }, }); @@ -289,7 +382,9 @@ function newStreamPair(): [ReadableStream, WritableStream] { const writable = new WritableStream({ write(chunk) { queue.push(chunk); - resolveNextItemReady(); + if (queue.length === 1) { + resolveNextItemReady(); + } }, }); From b31bf7c9e95d5784401d31111cba4c2cad7dbe35 Mon Sep 17 00:00:00 2001 From: Anselm Date: Tue, 1 Aug 2023 15:23:55 +0100 Subject: [PATCH 19/47] Add tests and implementation for peer roles --- src/node.ts | 15 +++-- src/sync.test.ts | 140 ++++++++++++++++++++++++++++++++++++++++++++++- src/sync.ts | 2 + 3 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/node.ts b/src/node.ts index 326f80e22..afa6c68c5 100644 --- a/src/node.ts +++ b/src/node.ts @@ -58,6 +58,9 @@ export class LocalNode { const multilog = new MultiLog(header, this); this.multilogs[multilog.id] = multilog; + + this.syncMultiLog(multilog); + return multilog; } @@ -116,6 +119,7 @@ export class LocalNode { optimisticKnownStates: {}, incoming: peer.incoming, outgoing: peer.outgoing.getWriter(), + role: peer.role, }; this.peers[peer.id] = peerState; @@ -180,12 +184,15 @@ export class LocalNode { const optimisticKnownState = peer.optimisticKnownStates[multilog.id]; - const newContent = multilog.newContentSince(optimisticKnownState); + if (optimisticKnownState || peer.role === "server") { + const newContent = + multilog.newContentSince(optimisticKnownState); - peer.optimisticKnownStates[multilog.id] = multilog.knownState(); + peer.optimisticKnownStates[multilog.id] = multilog.knownState(); - if (newContent) { - await peer.outgoing.write(newContent); + if (newContent) { + await peer.outgoing.write(newContent); + } } } } diff --git a/src/sync.test.ts b/src/sync.test.ts index 1ddf187d2..a4c934b5d 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -32,6 +32,7 @@ test( id: "test", incoming: inRx, outgoing: outTx, + role: "peer", }); const writer = inTx.getWriter(); @@ -107,6 +108,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( id: "test", incoming: inRx, outgoing: outTx, + role: "peer", }); const writer = inTx.getWriter(); @@ -172,6 +174,7 @@ test("After subscribing, node sends new txs to peer", async () => { id: "test", incoming: inRx, outgoing: outTx, + role: "peer", }); const writer = inTx.getWriter(); @@ -282,6 +285,7 @@ test("No matter the optimistic known state, node respects invalid known state me id: "test", incoming: inRx, outgoing: outTx, + role: "peer", }); const writer = inTx.getWriter(); @@ -354,6 +358,126 @@ test("No matter the optimistic known state, node respects invalid known state me } satisfies SyncMessage); }); +test("If we add a peer, but it never subscribes to a multilog, it won't get any messages", async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + const map = team.createMap(); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + role: "peer", + }); + + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + const reader = outRx.getReader(); + + await shouldNotResolve(reader.read(), { timeout: 50 }); +}); + +test("If we add a server peer, all updates to all multilogs are sent to it, even if it doesn't subscribe", async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + const map = team.createMap(); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + role: "server", + }); + + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + const reader = outRx.getReader(); + + const firstMessage = await reader.read(); + + expect(firstMessage.value).toEqual({ + type: "newContent", + multilogID: map.multiLog.id, + header: map.multiLog.header, + newContent: { + [node.ownSessionID]: { + after: 0, + newTransactions: [ + { + privacy: "trusting", + madeAt: map.multiLog.sessions[node.ownSessionID] + .transactions[0].madeAt, + changes: [ + { + op: "insert", + key: "hello", + value: "world", + } satisfies MapOpPayload, + ], + }, + ], + lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastSignature: + map.multiLog.sessions[node.ownSessionID].lastSignature!, + }, + }, + } satisfies SyncMessage); +}); + +test("If we add a server peer, it even receives just headers of newly created multilogs", async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + team.createMap(); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + role: "server", + }); + + const map = team.createMap(); + + const reader = outRx.getReader(); + + const firstMessage = await reader.read(); + + expect(firstMessage.value).toEqual({ + type: "newContent", + multilogID: map.multiLog.id, + header: map.multiLog.header, + newContent: {}, + } satisfies SyncMessage); +}) + function newStreamPair(): [ReadableStream, WritableStream] { const queue: T[] = []; let resolveNextItemReady: () => void = () => {}; @@ -363,7 +487,7 @@ function newStreamPair(): [ReadableStream, WritableStream] { const readable = new ReadableStream({ async pull(controller) { - while(true) { + while (true) { if (queue.length > 0) { controller.enqueue(queue.shift()); if (queue.length === 0) { @@ -390,3 +514,17 @@ function newStreamPair(): [ReadableStream, WritableStream] { return [readable, writable]; } + +function shouldNotResolve(promise: Promise, ops: { timeout: number }) { + return new Promise((resolve, reject) => { + promise.then((v) => + reject( + new Error( + "Should not have resolved, but resolved to " + + JSON.stringify(v) + ) + ) + ); + setTimeout(resolve, ops.timeout); + }); +} diff --git a/src/sync.ts b/src/sync.ts index 9c7c89a7c..ae187d36f 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -50,6 +50,7 @@ export interface Peer { id: PeerID; incoming: ReadableStream; outgoing: WritableStream; + role: 'peer' | 'server' | 'client'; } export interface PeerState { @@ -57,4 +58,5 @@ export interface PeerState { optimisticKnownStates: {[multilogID: MultiLogID]: MultiLogKnownState}; incoming: ReadableStream; outgoing: WritableStreamDefaultWriter; + role: 'peer' | 'server' | 'client'; } \ No newline at end of file From d7682d73d8eeb74a5d04a9b5e4a79a532e2f607b Mon Sep 17 00:00:00 2001 From: Anselm Date: Tue, 1 Aug 2023 15:38:39 +0100 Subject: [PATCH 20/47] Initial sync to server --- src/node.ts | 15 +++++++++++++++ src/sync.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/node.ts b/src/node.ts index afa6c68c5..b6ae7d238 100644 --- a/src/node.ts +++ b/src/node.ts @@ -123,6 +123,21 @@ export class LocalNode { }; this.peers[peer.id] = peerState; + if (peer.role === "server") { + for (const multilog of Object.values(this.multilogs)) { + if (multilog instanceof Promise) { + continue; + } + + await peerState.outgoing.write( + { + type: "subscribe", + knownState: multilog.knownState(), + } + ); + } + } + for await (const msg of peerState.incoming) { const response = this.handleSyncMessage(msg, peerState); diff --git a/src/sync.test.ts b/src/sync.test.ts index a4c934b5d..530b8368f 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -412,6 +412,7 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even }); const reader = outRx.getReader(); + const _initialSyncMessage = await reader.read(); const firstMessage = await reader.read(); @@ -464,9 +465,10 @@ test("If we add a server peer, it even receives just headers of newly created mu role: "server", }); - const map = team.createMap(); - const reader = outRx.getReader(); + const _initialSyncMessage = await reader.read(); + + const map = team.createMap(); const firstMessage = await reader.read(); @@ -476,7 +478,44 @@ test("If we add a server peer, it even receives just headers of newly created mu header: map.multiLog.header, newContent: {}, } satisfies SyncMessage); -}) +}); + +test("When we connect a new server peer, we try to sync all existing multilogs to it", async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + const map = team.createMap(); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + role: "server", + }); + + const reader = outRx.getReader(); + + const firstMessage = await reader.read(); + + expect(firstMessage.value).toEqual({ + type: "subscribe", + knownState: team.teamMap.multiLog.knownState(), + } satisfies SyncMessage); + + const secondMessage = await reader.read(); + + expect(secondMessage.value).toEqual({ + type: "subscribe", + knownState: map.multiLog.knownState(), + } satisfies SyncMessage); +}); function newStreamPair(): [ReadableStream, WritableStream] { const queue: T[] = []; From c04d2797d21687fbd8cacdabf0a84fa8b51cf6f7 Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 2 Aug 2023 15:27:27 +0100 Subject: [PATCH 21/47] Several further tests and improvements for syncing --- src/coValue.ts | 12 ++ src/multilog.ts | 4 +- src/node.ts | 293 +++++++++++++++++++++++++++++++++--------- src/permissions.ts | 8 +- src/sync.test.ts | 314 +++++++++++++++++++++++++++++++++++++++------ src/sync.ts | 71 ++++++++-- tsconfig.json | 4 +- 7 files changed, 592 insertions(+), 114 deletions(-) diff --git a/src/coValue.ts b/src/coValue.ts index b498f77c9..51c6f4007 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -198,6 +198,10 @@ export class CoList { constructor(multilog: MultiLog) { this.id = multilog.id as CoValueID>; } + + toJSON(): JsonObject { + throw new Error("Method not implemented."); + } } export class MultiStream { @@ -207,6 +211,10 @@ export class MultiStream { constructor(multilog: MultiLog) { this.id = multilog.id as CoValueID>; } + + toJSON(): JsonObject { + throw new Error("Method not implemented."); + } } export class Static { @@ -216,6 +224,10 @@ export class Static { constructor(multilog: MultiLog) { this.id = multilog.id as CoValueID>; } + + toJSON(): JsonObject { + throw new Error("Method not implemented."); + } } export function expectMap(content: CoValue): CoMap<{ [key: string]: string }, {}> { diff --git a/src/multilog.ts b/src/multilog.ts index 62d274649..99cf430d0 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -60,7 +60,7 @@ type SessionLog = { transactions: Transaction[]; lastHash?: Hash; streamingHash: StreamingHash; - lastSignature: string; + lastSignature: Signature; }; export type PrivateTransaction = { @@ -426,7 +426,7 @@ export class MultiLog { newContentSince(knownState: MultiLogKnownState | undefined): NewContentMessage | undefined { const newContent: NewContentMessage = { - type: "newContent", + action: "newContent", multilogID: this.id, header: knownState?.header ? undefined : this.header, newContent: Object.fromEntries( diff --git a/src/node.ts b/src/node.ts index b6ae7d238..5cc179978 100644 --- a/src/node.ts +++ b/src/node.ts @@ -21,13 +21,16 @@ import { PeerState, SessionNewContent, SubscribeMessage, + SubscribeResponseMessage, SyncMessage, UnsubscribeMessage, WrongAssumedKnownStateMessage, + combinedKnownStates, + weAreStrictlyAhead, } from "./sync"; export class LocalNode { - multilogs: { [key: MultiLogID]: Promise | MultiLog } = {}; + multilogs: { [key: MultiLogID]: MultilogState } = {}; peers: { [key: PeerID]: PeerState } = {}; agentCredential: AgentCredential; agentID: AgentID; @@ -43,36 +46,34 @@ export class LocalNode { this.ownSessionID = ownSessionID; const agentMultilog = new MultiLog(getAgentMultilogHeader(agent), this); - this.multilogs[agentMultilog.id] = Promise.resolve(agentMultilog); + this.multilogs[agentMultilog.id] = { + state: "loaded", + multilog: agentMultilog, + }; } createMultiLog(header: MultiLogHeader): MultiLog { - const requiredMultiLogs = - header.ruleset.type === "ownedByTeam" - ? { - [header.ruleset.team]: this.expectMultiLogLoaded( - header.ruleset.team - ), - } - : {}; - const multilog = new MultiLog(header, this); - this.multilogs[multilog.id] = multilog; + this.multilogs[multilog.id] = { state: "loaded", multilog }; this.syncMultiLog(multilog); return multilog; } - expectMultiLogLoaded(id: MultiLogID): MultiLog { - const multilog = this.multilogs[id]; - if (!multilog) { + expectMultiLogLoaded(id: MultiLogID, expectation?: string): MultiLog { + const entry = this.multilogs[id]; + if (!entry) { throw new Error(`Unknown multilog ${id}`); } - if (multilog instanceof Promise) { - throw new Error(`Multilog ${id} not yet loaded`); + if (entry.state === "loading") { + throw new Error( + `${ + expectation ? expectation + ": " : "" + }Multilog ${id} not yet loaded` + ); } - return multilog; + return entry.multilog; } addKnownAgent(agent: Agent) { @@ -113,8 +114,8 @@ export class LocalNode { return new Team(teamContent, this); } - async addPeer(peer: Peer) { - const peerState = { + addPeer(peer: Peer) { + const peerState: PeerState = { id: peer.id, optimisticKnownStates: {}, incoming: peer.incoming, @@ -124,74 +125,221 @@ export class LocalNode { this.peers[peer.id] = peerState; if (peer.role === "server") { - for (const multilog of Object.values(this.multilogs)) { - if (multilog instanceof Promise) { + for (const entry of Object.values(this.multilogs)) { + if (entry.state === "loading") { continue; } - await peerState.outgoing.write( - { - type: "subscribe", - knownState: multilog.knownState(), - } - ); + peerState.outgoing + .write({ + action: "subscribe", + knownState: entry.multilog.knownState(), + }) + .catch((e) => { + // TODO: handle error + console.error("Error writing to peer", e); + }); + + peerState.optimisticKnownStates[entry.multilog.id] = { + multilogID: entry.multilog.id, + header: false, + sessions: {}, + }; } } - for await (const msg of peerState.incoming) { - const response = this.handleSyncMessage(msg, peerState); - - if (response) { - await peerState.outgoing.write(response); + const readIncoming = async () => { + for await (const msg of peerState.incoming) { + for (const responseMsg of this.handleSyncMessage( + msg, + peerState + )) { + await peerState.outgoing.write(responseMsg); + } } - } + }; + + readIncoming().catch((e) => { + // TODO: handle error + console.error("Error reading from peer", e); + }); } - handleSyncMessage( - msg: SyncMessage, - peer: PeerState - ): SyncMessage | undefined { + handleSyncMessage(msg: SyncMessage, peer: PeerState): SyncMessage[] { // TODO: validate - switch (msg.type) { + switch (msg.action) { case "subscribe": return this.handleSubscribe(msg, peer); + case "subscribeResponse": + return this.handleSubscribeResponse(msg, peer); case "newContent": return this.handleNewContent(msg); case "wrongAssumedKnownState": return this.handleWrongAssumedKnownState(msg, peer); case "unsubscribe": return this.handleUnsubscribe(msg); + default: + throw new Error(`Unknown message type ${(msg as any).action}`); } } - handleSubscribe( - msg: SubscribeMessage, - peer: PeerState - ): SyncMessage | undefined { - const multilog = this.expectMultiLogLoaded(msg.knownState.multilogID); + handleSubscribe(msg: SubscribeMessage, peer: PeerState): SyncMessage[] { + const entry = this.multilogs[msg.knownState.multilogID]; - peer.optimisticKnownStates[multilog.id] = multilog.knownState(); + if (!entry || entry.state === "loading") { + if (!entry) { + let resolve: (multilog: MultiLog) => void; - return multilog.newContentSince(msg.knownState); + const promise = new Promise((r) => { + resolve = r; + }); + + this.multilogs[msg.knownState.multilogID] = { + state: "loading", + done: promise, + resolve: resolve!, + }; + } + + return [ + { + action: "subscribeResponse", + knownState: { + multilogID: msg.knownState.multilogID, + header: false, + sessions: {}, + }, + }, + ]; + } + + peer.optimisticKnownStates[entry.multilog.id] = + entry.multilog.knownState(); + + const newContent = entry.multilog.newContentSince(msg.knownState); + + return [ + { + action: "subscribeResponse", + knownState: entry.multilog.knownState(), + }, + ...(newContent ? [newContent] : []), + ]; } - handleNewContent(msg: NewContentMessage): SyncMessage | undefined { - return undefined; + handleSubscribeResponse( + msg: SubscribeResponseMessage, + peer: PeerState + ): SyncMessage[] { + const entry = this.multilogs[msg.knownState.multilogID]; + + if (!entry || entry.state === "loading") { + throw new Error( + "Expected multilog entry to be created, missing subscribe?" + ); + } + + const newContent = entry.multilog.newContentSince(msg.knownState); + peer.optimisticKnownStates[msg.knownState.multilogID] = + combinedKnownStates(msg.knownState, entry.multilog.knownState()); + + return newContent ? [newContent] : []; + } + + handleNewContent(msg: NewContentMessage): SyncMessage[] { + let entry = this.multilogs[msg.multilogID]; + + if (!entry) { + throw new Error( + "Expected multilog entry to be created, missing subscribe?" + ); + } + + let resolveAfterDone: ((multilog: MultiLog) => void) | undefined; + + if (entry.state === "loading") { + if (!msg.header) { + throw new Error("Expected header to be sent in first message"); + } + + const multilog = new MultiLog(msg.header, this); + + resolveAfterDone = entry.resolve; + + entry = { + state: "loaded", + multilog, + }; + + this.multilogs[msg.multilogID] = entry; + } + + const multilog = entry.multilog; + + let invalidStateAssumed = false; + + for (const sessionID of Object.keys(msg.newContent) as SessionID[]) { + const ourKnownTxIdx = + multilog.sessions[sessionID]?.transactions.length; + const theirFirstNewTxIdx = msg.newContent[sessionID].after; + + if ((ourKnownTxIdx || 0) < theirFirstNewTxIdx) { + invalidStateAssumed = true; + continue; + } + + const alreadyKnownOffset = ourKnownTxIdx + ? ourKnownTxIdx - theirFirstNewTxIdx + : 0; + + const newTransactions = + msg.newContent[sessionID].newTransactions.slice( + alreadyKnownOffset + ); + + const success = multilog.tryAddTransactions( + sessionID, + newTransactions, + msg.newContent[sessionID].lastHash, + msg.newContent[sessionID].lastSignature + ); + + if (!success) { + console.error("Failed to add transactions", newTransactions); + continue; + } + } + + if (resolveAfterDone) { + resolveAfterDone(multilog); + } + + return invalidStateAssumed + ? [ + { + action: "wrongAssumedKnownState", + knownState: multilog.knownState(), + }, + ] + : []; } handleWrongAssumedKnownState( msg: WrongAssumedKnownStateMessage, peer: PeerState - ): SyncMessage | undefined { + ): SyncMessage[] { const multilog = this.expectMultiLogLoaded(msg.knownState.multilogID); - peer.optimisticKnownStates[msg.knownState.multilogID] = msg.knownState; + peer.optimisticKnownStates[msg.knownState.multilogID] = + combinedKnownStates(msg.knownState, multilog.knownState()); - return multilog.newContentSince(msg.knownState); + const newContent = multilog.newContentSince(msg.knownState); + + return newContent ? [newContent] : []; } - handleUnsubscribe(msg: UnsubscribeMessage): SyncMessage | undefined { - return undefined; + handleUnsubscribe(msg: UnsubscribeMessage): SyncMessage[] { + throw new Error("Method not implemented."); } async syncMultiLog(multilog: MultiLog) { @@ -203,7 +351,21 @@ export class LocalNode { const newContent = multilog.newContentSince(optimisticKnownState); - peer.optimisticKnownStates[multilog.id] = multilog.knownState(); + peer.optimisticKnownStates[multilog.id] = peer + .optimisticKnownStates[multilog.id] + ? combinedKnownStates( + peer.optimisticKnownStates[multilog.id], + multilog.knownState() + ) + : multilog.knownState(); + + if (!optimisticKnownState && peer.role === "server") { + // auto-subscribe + await peer.outgoing.write({ + action: "subscribe", + knownState: multilog.knownState(), + }); + } if (newContent) { await peer.outgoing.write(newContent); @@ -220,16 +382,19 @@ export class LocalNode { newNode.multilogs = Object.fromEntries( Object.entries(this.multilogs) - .map(([id, multilog]) => { - if (multilog instanceof Promise) { - return [id, undefined]; + .map(([id, entry]) => { + if (entry.state === "loading") { + return undefined; } - const newMultilog = new MultiLog(multilog.header, newNode); + const newMultilog = new MultiLog( + entry.multilog.header, + newNode + ); - newMultilog.sessions = multilog.sessions; + newMultilog.sessions = entry.multilog.sessions; - return [id, newMultilog]; + return [id, { state: "loaded", multilog: newMultilog }]; }) .filter((x): x is Exclude => !!x) ); @@ -242,3 +407,11 @@ export class LocalNode { return newNode; } } + +type MultilogState = + | { + state: "loading"; + done: Promise; + resolve: (multilog: MultiLog) => void; + } + | { state: "loaded"; multilog: MultiLog }; diff --git a/src/permissions.ts b/src/permissions.ts index 1f3253255..9d1ce83b7 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -156,7 +156,8 @@ export function determineValidTransactions( } else if (multilog.header.ruleset.type === "ownedByTeam") { const teamContent = multilog.node.expectMultiLogLoaded( - multilog.header.ruleset.team + multilog.header.ruleset.team, + "Determining valid transaction in owned object but its team wasn't loaded" ).getCurrentContent(); if (teamContent.type !== "comap") { @@ -193,8 +194,11 @@ export function determineValidTransactions( })); } ); + } else if (multilog.header.ruleset.type === "agent") { + // TODO + return []; } else { - throw new Error("Unknown ruleset type " + multilog.header.ruleset.type); + throw new Error("Unknown ruleset type " + (multilog.header.ruleset as any).type); } } diff --git a/src/sync.test.ts b/src/sync.test.ts index 530b8368f..f386f87ad 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -7,7 +7,7 @@ import { } from "./multilog"; import { LocalNode } from "./node"; import { SyncMessage } from "./sync"; -import { MapOpPayload } from "./coValue"; +import { MapOpPayload, expectMap } from "./coValue"; test( "Node replies with initial tx and header to empty subscribe", @@ -38,7 +38,7 @@ test( const writer = inTx.getWriter(); await writer.write({ - type: "subscribe", + action: "subscribe", knownState: { multilogID: map.multiLog.id, header: false, @@ -51,7 +51,14 @@ test( const firstMessage = await reader.read(); expect(firstMessage.value).toEqual({ - type: "newContent", + action: "subscribeResponse", + knownState: map.multiLog.knownState(), + } satisfies SyncMessage); + + const secondMessage = await reader.read(); + + expect(secondMessage.value).toEqual({ + action: "newContent", multilogID: map.multiLog.id, header: { type: "comap", @@ -114,7 +121,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( const writer = inTx.getWriter(); await writer.write({ - type: "subscribe", + action: "subscribe", knownState: { multilogID: map.multiLog.id, header: true, @@ -126,10 +133,17 @@ test("Node replies with only new tx to subscribe with some known state", async ( const reader = outRx.getReader(); - const firstMessage = await reader.read(); + const msg1 = await reader.read(); - expect(firstMessage.value).toEqual({ - type: "newContent", + expect(msg1.value).toEqual({ + action: "subscribeResponse", + knownState: map.multiLog.knownState(), + } satisfies SyncMessage); + + const msg2 = await reader.read(); + + expect(msg2.value).toEqual({ + action: "newContent", multilogID: map.multiLog.id, header: undefined, newContent: { @@ -157,7 +171,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( } satisfies SyncMessage); }); -test("After subscribing, node sends new txs to peer", async () => { +test("After subscribing, node sends own known state and new txs to peer", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -180,7 +194,7 @@ test("After subscribing, node sends new txs to peer", async () => { const writer = inTx.getWriter(); await writer.write({ - type: "subscribe", + action: "subscribe", knownState: { multilogID: map.multiLog.id, header: false, @@ -192,10 +206,17 @@ test("After subscribing, node sends new txs to peer", async () => { const reader = outRx.getReader(); - const firstMessage = await reader.read(); + const msg1 = await reader.read(); - expect(firstMessage.value).toEqual({ - type: "newContent", + expect(msg1.value).toEqual({ + action: "subscribeResponse", + knownState: map.multiLog.knownState(), + } satisfies SyncMessage); + + const msg2 = await reader.read(); + + expect(msg2.value).toEqual({ + action: "newContent", multilogID: map.multiLog.id, header: map.multiLog.header, newContent: {}, @@ -205,10 +226,10 @@ test("After subscribing, node sends new txs to peer", async () => { editable.set("hello", "world", "trusting"); }); - const secondMessage = await reader.read(); + const msg3 = await reader.read(); - expect(secondMessage.value).toEqual({ - type: "newContent", + expect(msg3.value).toEqual({ + action: "newContent", multilogID: map.multiLog.id, newContent: { [node.ownSessionID]: { @@ -238,10 +259,10 @@ test("After subscribing, node sends new txs to peer", async () => { editable.set("goodbye", "world", "trusting"); }); - const thirdMessage = await reader.read(); + const msg4 = await reader.read(); - expect(thirdMessage.value).toEqual({ - type: "newContent", + expect(msg4.value).toEqual({ + action: "newContent", multilogID: map.multiLog.id, newContent: { [node.ownSessionID]: { @@ -268,6 +289,76 @@ test("After subscribing, node sends new txs to peer", async () => { } satisfies SyncMessage); }); +test("Client replies with known new content to subscribeResponse from server", async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + const map = team.createMap(); + + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + role: "peer", + }); + + const writer = inTx.getWriter(); + + await writer.write({ + action: "subscribeResponse", + knownState: { + multilogID: map.multiLog.id, + header: false, + sessions: { + [node.ownSessionID]: 0, + }, + }, + }); + + const reader = outRx.getReader(); + + const msg1 = await reader.read(); + + expect(msg1.value).toEqual({ + action: "newContent", + multilogID: map.multiLog.id, + header: map.multiLog.header, + newContent: { + [node.ownSessionID]: { + after: 0, + newTransactions: [ + { + privacy: "trusting", + madeAt: map.multiLog.sessions[node.ownSessionID] + .transactions[0].madeAt, + changes: [ + { + op: "insert", + key: "hello", + value: "world", + } satisfies MapOpPayload, + ], + }, + ], + lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastSignature: + map.multiLog.sessions[node.ownSessionID].lastSignature!, + }, + }, + } satisfies SyncMessage); +}); + test("No matter the optimistic known state, node respects invalid known state messages and resyncs", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -291,7 +382,7 @@ test("No matter the optimistic known state, node respects invalid known state me const writer = inTx.getWriter(); await writer.write({ - type: "subscribe", + action: "subscribe", knownState: { multilogID: map.multiLog.id, header: false, @@ -303,7 +394,8 @@ test("No matter the optimistic known state, node respects invalid known state me const reader = outRx.getReader(); - const _firstMessage = await reader.read(); + const _msg1 = await reader.read(); + const _msg2 = await reader.read(); map.edit((editable) => { editable.set("hello", "world", "trusting"); @@ -313,11 +405,11 @@ test("No matter the optimistic known state, node respects invalid known state me editable.set("goodbye", "world", "trusting"); }); - const _secondMessage = await reader.read(); - const _thirdMessage = await reader.read(); + const _msg3 = await reader.read(); + const _msg4 = await reader.read(); await writer.write({ - type: "wrongAssumedKnownState", + action: "wrongAssumedKnownState", knownState: { multilogID: map.multiLog.id, header: true, @@ -327,10 +419,10 @@ test("No matter the optimistic known state, node respects invalid known state me }, } satisfies SyncMessage); - const fourthMessage = await reader.read(); + const msg5 = await reader.read(); - expect(fourthMessage.value).toEqual({ - type: "newContent", + expect(msg5.value).toEqual({ + action: "newContent", multilogID: map.multiLog.id, header: undefined, newContent: { @@ -412,12 +504,24 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even }); const reader = outRx.getReader(); - const _initialSyncMessage = await reader.read(); + const _adminSubscribeMsg = await reader.read(); + const _teamSubscribeMsg = await reader.read(); - const firstMessage = await reader.read(); + const subscribeMsg = await reader.read(); - expect(firstMessage.value).toEqual({ - type: "newContent", + expect(subscribeMsg.value).toEqual({ + action: "subscribe", + knownState: { + multilogID: map.multiLog.id, + header: true, + sessions: {}, + }, + } satisfies SyncMessage); + + const newContentMsg = await reader.read(); + + expect(newContentMsg.value).toEqual({ + action: "newContent", multilogID: map.multiLog.id, header: map.multiLog.header, newContent: { @@ -445,7 +549,7 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even } satisfies SyncMessage); }); -test("If we add a server peer, it even receives just headers of newly created multilogs", async () => { +test("If we add a server peer, newly created multilogs are auto-subscribed to", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -466,20 +570,30 @@ test("If we add a server peer, it even receives just headers of newly created mu }); const reader = outRx.getReader(); - const _initialSyncMessage = await reader.read(); + const _initialMsg1 = await reader.read(); + const _initialMsg2 = await reader.read(); const map = team.createMap(); - const firstMessage = await reader.read(); + const msg1 = await reader.read(); - expect(firstMessage.value).toEqual({ - type: "newContent", + expect(msg1.value).toEqual({ + action: "subscribe", + knownState: map.multiLog.knownState(), + } satisfies SyncMessage); + + const msg2 = await reader.read(); + + expect(msg2.value).toEqual({ + action: "newContent", multilogID: map.multiLog.id, header: map.multiLog.header, newContent: {}, } satisfies SyncMessage); }); +test.skip("TODO: when receiving a subscribe response that is behind our optimistic state (due to already sent content), we ignore it", () => {}); + test("When we connect a new server peer, we try to sync all existing multilogs to it", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -502,21 +616,143 @@ test("When we connect a new server peer, we try to sync all existing multilogs t const reader = outRx.getReader(); - const firstMessage = await reader.read(); + const _adminSubscribeMessage = await reader.read(); + const teamSubscribeMessage = await reader.read(); - expect(firstMessage.value).toEqual({ - type: "subscribe", + expect(teamSubscribeMessage.value).toEqual({ + action: "subscribe", knownState: team.teamMap.multiLog.knownState(), } satisfies SyncMessage); const secondMessage = await reader.read(); expect(secondMessage.value).toEqual({ - type: "subscribe", + action: "subscribe", knownState: map.multiLog.knownState(), } satisfies SyncMessage); }); +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(); + const adminID = getAgentID(getAgent(admin)); + + const node = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node.createTeam(); + + const map = team.createMap(); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + role: "peer", + }); + + const writer = inTx.getWriter(); + + await writer.write({ + action: "subscribe", + knownState: { + multilogID: map.multiLog.id, + header: true, + sessions: { + [node.ownSessionID]: 1, + }, + }, + }); + + const reader = outRx.getReader(); + + const firstMessage = await reader.read(); + + expect(firstMessage.value).toEqual({ + action: "subscribeResponse", + knownState: map.multiLog.knownState(), + } satisfies SyncMessage); +}); + +test("When replaying creation and transactions of a multilog as new content, the receiving peer integrates this information", async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node1 = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node1.createTeam(); + + const [inRx1, inTx1] = newStreamPair(); + const [outRx1, outTx1] = newStreamPair(); + + node1.addPeer({ + id: "test2", + incoming: inRx1, + outgoing: outTx1, + role: "server", + }); + + const reader1 = outRx1.getReader(); + + const _adminSubscriptionMsg = await reader1.read(); + const teamSubscribeMsg = await reader1.read(); + + const map = team.createMap(); + + const mapSubscriptionMsg = await reader1.read(); + const mapNewContentMsg = await reader1.read(); + + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + const mapEditMsg = await reader1.read(); + + const node2 = new LocalNode(admin, newRandomSessionID(adminID)); + + const [inRx2, inTx2] = newStreamPair(); + const [outRx2, outTx2] = newStreamPair(); + + node2.addPeer({ + id: "test1", + incoming: inRx2, + outgoing: outTx2, + role: "client", + }); + + const writer2 = inTx2.getWriter(); + const reader2 = outRx2.getReader(); + + await writer2.write(teamSubscribeMsg.value); + const teamSubscribeResponseMsg = await reader2.read(); + + expect(node2.multilogs[team.teamMap.multiLog.id]?.state).toEqual("loading"); + + const writer1 = inTx1.getWriter(); + + await writer1.write(teamSubscribeResponseMsg.value); + const teamContentMsg = await reader1.read(); + + await writer2.write(teamContentMsg.value); + + await writer2.write(mapSubscriptionMsg.value); + const _mapSubscribeResponseMsg = await reader2.read(); + await writer2.write(mapNewContentMsg.value); + + expect(node2.multilogs[map.multiLog.id]?.state).toEqual("loading"); + + await writer2.write(mapEditMsg.value); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect( + expectMap( + node2.expectMultiLogLoaded(map.multiLog.id).getCurrentContent() + ).get("hello") + ).toEqual("world"); +}); + function newStreamPair(): [ReadableStream, WritableStream] { const queue: T[] = []; let resolveNextItemReady: () => void = () => {}; diff --git a/src/sync.ts b/src/sync.ts index ae187d36f..cf8367f01 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,4 +1,4 @@ -import { Hash } from "./crypto"; +import { Hash, Signature } from "./crypto"; import { MultiLogHeader, MultiLogID, SessionID, Transaction } from "./multilog"; export type MultiLogKnownState = { @@ -9,17 +9,23 @@ export type MultiLogKnownState = { export type SyncMessage = | SubscribeMessage + | SubscribeResponseMessage | NewContentMessage | WrongAssumedKnownStateMessage | UnsubscribeMessage; export type SubscribeMessage = { - type: "subscribe"; + action: "subscribe"; + knownState: MultiLogKnownState; +}; + +export type SubscribeResponseMessage = { + action: "subscribeResponse"; knownState: MultiLogKnownState; }; export type NewContentMessage = { - type: "newContent"; + action: "newContent"; multilogID: MultiLogID; header?: MultiLogHeader; newContent: { @@ -30,17 +36,18 @@ export type NewContentMessage = { export type SessionNewContent = { after: number; newTransactions: Transaction[]; + // TODO: is lastHash needed here? lastHash: Hash; - lastSignature: string; -} + lastSignature: Signature; +}; export type WrongAssumedKnownStateMessage = { - type: "wrongAssumedKnownState"; + action: "wrongAssumedKnownState"; knownState: MultiLogKnownState; }; export type UnsubscribeMessage = { - type: "unsubscribe"; + action: "unsubscribe"; multilogID: MultiLogID; }; @@ -50,13 +57,57 @@ export interface Peer { id: PeerID; incoming: ReadableStream; outgoing: WritableStream; - role: 'peer' | 'server' | 'client'; + role: "peer" | "server" | "client"; } export interface PeerState { id: PeerID; - optimisticKnownStates: {[multilogID: MultiLogID]: MultiLogKnownState}; + optimisticKnownStates: { [multilogID: MultiLogID]: MultiLogKnownState }; incoming: ReadableStream; outgoing: WritableStreamDefaultWriter; - role: 'peer' | 'server' | 'client'; + role: "peer" | "server" | "client"; +} + +export function weAreStrictlyAhead( + ourKnownState: MultiLogKnownState, + theirKnownState: MultiLogKnownState +): 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: MultiLogKnownState, stateB: MultiLogKnownState): MultiLogKnownState { + const sessionStates: MultiLogKnownState["sessions"] = {}; + + const allSessions = new Set([...Object.keys(stateA.sessions), ...Object.keys(stateB.sessions)] as SessionID[]); + + for (const sessionID of allSessions) { + const stateAValue = stateA.sessions[sessionID]; + const stateBValue = stateB.sessions[sessionID]; + + sessionStates[sessionID] = Math.max(stateAValue || 0, stateBValue || 0); + } + + return { + multilogID: stateA.multilogID, + header: stateA.header || stateB.header, + sessions: sessionStates, + }; } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 29f8aa003..91aa71fb6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,8 @@ "noEmit": true, "types": [ "bun-types" // add Bun global - ] + ], + + // "noUncheckedIndexedAccess": true } } From a5f4bbf3dc3883a8f84556f1376ae38d7119cebb Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 2 Aug 2023 16:13:42 +0100 Subject: [PATCH 22/47] Ensure depended on multilogs are loaded/sent first --- src/multilog.ts | 4 ++ src/node.ts | 114 +++++++++++++++++++++++++++++------ src/sync.test.ts | 150 +++++++++++++++++++++++++++++++++++++++-------- src/sync.ts | 1 + 4 files changed, 226 insertions(+), 43 deletions(-) diff --git a/src/multilog.ts b/src/multilog.ts index 99cf430d0..a7282aa76 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -501,6 +501,10 @@ export function getAgentID(agent: Agent): AgentID { )}`; } +export function agentIDasMultiLogID(agentID: AgentID): MultiLogID { + return `coval_${agentID.substring("agent_".length)}`; +} + export type AgentCredential = { signatorySecret: SignatorySecret; recipientSecret: RecipientSecret; diff --git a/src/node.ts b/src/node.ts index 5cc179978..c824702c5 100644 --- a/src/node.ts +++ b/src/node.ts @@ -12,6 +12,7 @@ import { getAgentMultilogHeader, MultiLogHeader, agentIDfromSessionID, + agentIDasMultiLogID, } from "./multilog"; import { Team, expectTeamContent } from "./permissions"; import { @@ -61,10 +62,40 @@ export class LocalNode { return multilog; } + loadMultiLog(id: MultiLogID): Promise { + let entry = this.multilogs[id]; + if (!entry) { + entry = newLoadingState(); + + this.multilogs[id] = entry; + + for (const peer of Object.values(this.peers)) { + peer.outgoing + .write({ + action: "subscribe", + knownState: { + multilogID: id, + header: false, + sessions: {}, + }, + }) + .catch((e) => { + console.error("Error writing to peer", e); + }); + } + } + if (entry.state === "loaded") { + return Promise.resolve(entry.multilog); + } + return entry.done; + } + expectMultiLogLoaded(id: MultiLogID, expectation?: string): MultiLog { const entry = this.multilogs[id]; if (!entry) { - throw new Error(`Unknown multilog ${id}`); + throw new Error( + `${expectation ? expectation + ": " : ""}Unknown multilog ${id}` + ); } if (entry.state === "loading") { throw new Error( @@ -183,22 +214,16 @@ export class LocalNode { } } - handleSubscribe(msg: SubscribeMessage, peer: PeerState): SyncMessage[] { + handleSubscribe( + msg: SubscribeMessage, + peer: PeerState, + asDependencyOf?: MultiLogID + ): SyncMessage[] { const entry = this.multilogs[msg.knownState.multilogID]; if (!entry || entry.state === "loading") { if (!entry) { - let resolve: (multilog: MultiLog) => void; - - const promise = new Promise((r) => { - resolve = r; - }); - - this.multilogs[msg.knownState.multilogID] = { - state: "loading", - done: promise, - resolve: resolve!, - }; + this.multilogs[msg.knownState.multilogID] = newLoadingState(); } return [ @@ -218,10 +243,35 @@ export class LocalNode { const newContent = entry.multilog.newContentSince(msg.knownState); + const dependedOnMultilogs = + entry.multilog.header.ruleset.type === "team" + ? expectTeamContent(entry.multilog.getCurrentContent()) + .keys() + .filter((k): k is AgentID => k.startsWith("agent_")) + .map((agent) => agentIDasMultiLogID(agent)) + : entry.multilog.header.ruleset.type === "ownedByTeam" + ? [entry.multilog.header.ruleset.team] + : []; + return [ + ...dependedOnMultilogs.flatMap((multilogID) => + this.handleSubscribe( + { + action: "subscribe", + knownState: { + multilogID, + header: false, + sessions: {}, + }, + }, + peer, + asDependencyOf || msg.knownState.multilogID + ) + ), { action: "subscribeResponse", knownState: entry.multilog.knownState(), + asDependencyOf, }, ...(newContent ? [newContent] : []), ]; @@ -231,12 +281,26 @@ export class LocalNode { msg: SubscribeResponseMessage, peer: PeerState ): SyncMessage[] { - const entry = this.multilogs[msg.knownState.multilogID]; + let entry = this.multilogs[msg.knownState.multilogID]; - if (!entry || entry.state === "loading") { - throw new Error( - "Expected multilog entry to be created, missing subscribe?" - ); + if (!entry) { + if (msg.asDependencyOf) { + if (this.multilogs[msg.asDependencyOf]) { + entry = newLoadingState(); + + this.multilogs[msg.knownState.multilogID] = entry; + } + } else { + throw new Error( + "Expected multilog entry to be created, missing subscribe?" + ); + } + } + + if (entry.state === "loading") { + peer.optimisticKnownStates[msg.knownState.multilogID] = + msg.knownState; + return []; } const newContent = entry.multilog.newContentSince(msg.knownState); @@ -415,3 +479,17 @@ type MultilogState = resolve: (multilog: MultiLog) => void; } | { state: "loaded"; multilog: MultiLog }; + +function newLoadingState(): MultilogState { + let resolve: (multilog: MultiLog) => void; + + const promise = new Promise((r) => { + resolve = r; + }); + + return { + state: "loading", + done: promise, + resolve: resolve!, + }; +} diff --git a/src/sync.test.ts b/src/sync.test.ts index f386f87ad..1c7edbea3 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -6,7 +6,7 @@ import { newRandomSessionID, } from "./multilog"; import { LocalNode } from "./node"; -import { SyncMessage } from "./sync"; +import { Peer, SyncMessage } from "./sync"; import { MapOpPayload, expectMap } from "./coValue"; test( @@ -48,16 +48,21 @@ test( const reader = outRx.getReader(); - const firstMessage = await reader.read(); + const _adminSubscribeResponseMsg = await reader.read(); + const _adminNewContentMsg = await reader.read(); + const _teamSubscribeResponseMsg = await reader.read(); + const _teamNewContentMsg = await reader.read(); - expect(firstMessage.value).toEqual({ + const subscribeResponseMsg = await reader.read(); + + expect(subscribeResponseMsg.value).toEqual({ action: "subscribeResponse", knownState: map.multiLog.knownState(), } satisfies SyncMessage); - const secondMessage = await reader.read(); + const newContentMsg = await reader.read(); - expect(secondMessage.value).toEqual({ + expect(newContentMsg.value).toEqual({ action: "newContent", multilogID: map.multiLog.id, header: { @@ -133,16 +138,21 @@ test("Node replies with only new tx to subscribe with some known state", async ( const reader = outRx.getReader(); - const msg1 = await reader.read(); + const _adminSubscribeResponseMsg = await reader.read(); + const _adminNewContentMsg = await reader.read(); + const _teamSubscribeResponseMsg = await reader.read(); + const _teamNewContentMsg = await reader.read(); - expect(msg1.value).toEqual({ + const mapSubscribeResponseMsg = await reader.read(); + + expect(mapSubscribeResponseMsg.value).toEqual({ action: "subscribeResponse", knownState: map.multiLog.knownState(), } satisfies SyncMessage); - const msg2 = await reader.read(); + const mapNewContentMsg = await reader.read(); - expect(msg2.value).toEqual({ + expect(mapNewContentMsg.value).toEqual({ action: "newContent", multilogID: map.multiLog.id, header: undefined, @@ -171,6 +181,8 @@ test("Node replies with only new tx to subscribe with some known state", async ( } satisfies SyncMessage); }); +test.skip("TODO: node only replies with new tx to subscribe with some known state, even in the depended on multilogs", () => {}); + test("After subscribing, node sends own known state and new txs to peer", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -206,16 +218,21 @@ test("After subscribing, node sends own known state and new txs to peer", async const reader = outRx.getReader(); - const msg1 = await reader.read(); + const _adminSubscribeResponseMsg = await reader.read(); + const _adminNewContentMsg = await reader.read(); + const _teamSubscribeResponseMsg = await reader.read(); + const _teamNewContentMsg = await reader.read(); - expect(msg1.value).toEqual({ + const mapSubscribeResponseMsg = await reader.read(); + + expect(mapSubscribeResponseMsg.value).toEqual({ action: "subscribeResponse", knownState: map.multiLog.knownState(), } satisfies SyncMessage); - const msg2 = await reader.read(); + const mapNewContentHeaderOnlyMsg = await reader.read(); - expect(msg2.value).toEqual({ + expect(mapNewContentHeaderOnlyMsg.value).toEqual({ action: "newContent", multilogID: map.multiLog.id, header: map.multiLog.header, @@ -226,9 +243,9 @@ test("After subscribing, node sends own known state and new txs to peer", async editable.set("hello", "world", "trusting"); }); - const msg3 = await reader.read(); + const mapEditMsg1 = await reader.read(); - expect(msg3.value).toEqual({ + expect(mapEditMsg1.value).toEqual({ action: "newContent", multilogID: map.multiLog.id, newContent: { @@ -259,9 +276,9 @@ test("After subscribing, node sends own known state and new txs to peer", async editable.set("goodbye", "world", "trusting"); }); - const msg4 = await reader.read(); + const mapEditMsg2 = await reader.read(); - expect(msg4.value).toEqual({ + expect(mapEditMsg2.value).toEqual({ action: "newContent", multilogID: map.multiLog.id, newContent: { @@ -394,8 +411,12 @@ test("No matter the optimistic known state, node respects invalid known state me const reader = outRx.getReader(); - const _msg1 = await reader.read(); - const _msg2 = await reader.read(); + const _adminSubscribeResponseMsg = await reader.read(); + const _adminNewContentMsg = await reader.read(); + const _teamSubscribeResponseMsg = await reader.read(); + const _teamNewContentMsg = await reader.read(); + const _mapSubscribeResponseMsg = await reader.read(); + const _mapNewContentHeaderOnlyMsg = await reader.read(); map.edit((editable) => { editable.set("hello", "world", "trusting"); @@ -405,8 +426,8 @@ test("No matter the optimistic known state, node respects invalid known state me editable.set("goodbye", "world", "trusting"); }); - const _msg3 = await reader.read(); - const _msg4 = await reader.read(); + const _mapEditMsg1 = await reader.read(); + const _mapEditMsg2 = await reader.read(); await writer.write({ action: "wrongAssumedKnownState", @@ -419,9 +440,9 @@ test("No matter the optimistic known state, node respects invalid known state me }, } satisfies SyncMessage); - const msg5 = await reader.read(); + const newContentAfterWrongAssumedState = await reader.read(); - expect(msg5.value).toEqual({ + expect(newContentAfterWrongAssumedState.value).toEqual({ action: "newContent", multilogID: map.multiLog.id, header: undefined, @@ -667,9 +688,13 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe const reader = outRx.getReader(); - const firstMessage = await reader.read(); + const _adminSubscribeResponseMsg = await reader.read(); + const _adminNewContentMsg = await reader.read(); + const _teamSubscribeResponseMsg = await reader.read(); + const _teamNewContentMsg = await reader.read(); + const mapSubscribeResponse = await reader.read(); - expect(firstMessage.value).toEqual({ + expect(mapSubscribeResponse.value).toEqual({ action: "subscribeResponse", knownState: map.multiLog.knownState(), } satisfies SyncMessage); @@ -753,6 +778,35 @@ test("When replaying creation and transactions of a multilog as new content, the ).toEqual("world"); }); +test("When loading a multilog on one node, the server node it is requested from replies with all the necessary depended on multilogs to make it work", async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const node1 = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node1.createTeam(); + + const map = team.createMap(); + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + const node2 = new LocalNode(admin, newRandomSessionID(adminID)); + + const [node2asPeer, node1asPeer] = connectedPeers(); + + node1.addPeer(node2asPeer); + node2.addPeer(node1asPeer); + + await node2.loadMultiLog(map.multiLog.id); + + expect( + expectMap( + node2.expectMultiLogLoaded(map.multiLog.id).getCurrentContent() + ).get("hello") + ).toEqual("world"); +}); + function newStreamPair(): [ReadableStream, WritableStream] { const queue: T[] = []; let resolveNextItemReady: () => void = () => {}; @@ -803,3 +857,49 @@ function shouldNotResolve(promise: Promise, ops: { timeout: number }) { setTimeout(resolve, ops.timeout); }); } + +function connectedPeers(trace?: boolean): [Peer, Peer] { + const [inRx1, inTx1] = newStreamPair(); + const [outRx1, outTx1] = newStreamPair(); + + const [inRx2, inTx2] = newStreamPair(); + const [outRx2, outTx2] = newStreamPair(); + + outRx2 + .pipeThrough( + new TransformStream({ + transform(chunk, controller) { + trace && console.log("peer 2 -> peer 1", chunk); + controller.enqueue(chunk); + }, + }) + ) + .pipeTo(inTx1); + + outRx1 + .pipeThrough( + new TransformStream({ + transform(chunk, controller) { + trace && console.log("peer 1 -> peer 2", chunk); + controller.enqueue(chunk); + }, + }) + ) + .pipeTo(inTx2); + + const peer2AsPeer: Peer = { + id: "test2", + incoming: inRx1, + outgoing: outTx1, + role: "peer", + }; + + const peer1AsPeer: Peer = { + id: "test1", + incoming: inRx2, + outgoing: outTx2, + role: "peer", + }; + + return [peer2AsPeer, peer1AsPeer]; +} diff --git a/src/sync.ts b/src/sync.ts index cf8367f01..276d84bbe 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -22,6 +22,7 @@ export type SubscribeMessage = { export type SubscribeResponseMessage = { action: "subscribeResponse"; knownState: MultiLogKnownState; + asDependencyOf?: MultiLogID; }; export type NewContentMessage = { From 1bd27f848ecec63fdcf7e4df805c26c7540aa03b Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 2 Aug 2023 16:25:04 +0100 Subject: [PATCH 23/47] Rename CoValue -> CoValueContent --- src/coValue.ts | 6 +++--- src/index.ts | 6 +++--- src/jsonValue.ts | 4 ++-- src/multilog.ts | 8 ++++---- src/permissions.ts | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/coValue.ts b/src/coValue.ts index 51c6f4007..ebe9e9808 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -1,11 +1,11 @@ import { JsonAtom, JsonObject, JsonValue } from "./jsonValue"; import { MultiLog, MultiLogID, TransactionID } from "./multilog"; -export type CoValueID = MultiLogID & { +export type CoValueID = MultiLogID & { readonly __type: T; }; -export type CoValue = +export type CoValueContent = | CoMap<{[key: string]: JsonValue}, JsonValue> | CoList | MultiStream @@ -230,7 +230,7 @@ export class Static { } } -export function expectMap(content: CoValue): CoMap<{ [key: string]: string }, {}> { +export function expectMap(content: CoValueContent): CoMap<{ [key: string]: string }, {}> { if (content.type !== "comap") { throw new Error("Expected map"); } diff --git a/src/index.ts b/src/index.ts index 709cc2e15..453614bc3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,13 @@ -import { CoValue } from "./coValue"; +import { CoValueContent } from "./coValue"; import { JsonValue } from "./jsonValue"; import { MultiLog } from "./multilog"; import { LocalNode } from "./node"; -type Value = JsonValue | CoValue; +type Value = JsonValue | CoValueContent; export { JsonValue, - CoValue, + CoValueContent as CoValue, Value, LocalNode, MultiLog diff --git a/src/jsonValue.ts b/src/jsonValue.ts index fd37d8859..9a535263c 100644 --- a/src/jsonValue.ts +++ b/src/jsonValue.ts @@ -1,6 +1,6 @@ -import { CoValueID, CoValue } from "./coValue"; +import { CoValueID, CoValueContent } from "./coValue"; export type JsonAtom = string | number | boolean | null; -export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID; +export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID; export type JsonArray = JsonValue[]; export type JsonObject = { [key: string]: JsonValue; }; diff --git a/src/multilog.ts b/src/multilog.ts index a7282aa76..105017178 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -1,5 +1,5 @@ import { randomBytes } from "@noble/hashes/utils"; -import { CoList, CoMap, CoValue, Static, MultiStream } from "./coValue"; +import { CoList, CoMap, CoValueContent, Static, MultiStream } from "./coValue"; import { Encrypted, Hash, @@ -36,7 +36,7 @@ import { MultiLogKnownState, NewContentMessage } from "./sync"; export type MultiLogID = `coval_${string}`; export type MultiLogHeader = { - type: CoValue["type"]; + type: CoValueContent["type"]; ruleset: RulesetDef; meta: JsonValue; }; @@ -94,7 +94,7 @@ export class MultiLog { node: LocalNode; header: MultiLogHeader; sessions: { [key: SessionID]: SessionLog }; - content?: CoValue; + content?: CoValueContent; constructor(header: MultiLogHeader, node: LocalNode) { this.id = multilogIDforHeader(header); @@ -260,7 +260,7 @@ export class MultiLog { ); } - getCurrentContent(): CoValue { + getCurrentContent(): CoValueContent { if (this.content) { return this.content; } diff --git a/src/permissions.ts b/src/permissions.ts index 9d1ce83b7..85ab519bc 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,4 +1,4 @@ -import { CoMap, CoValue, MapOpPayload } from "./coValue"; +import { CoMap, CoValueContent, MapOpPayload } from "./coValue"; import { JsonValue } from "./jsonValue"; import { Encrypted, @@ -215,7 +215,7 @@ export type TeamContent = { [key: AgentID]: Role } & { }; }; -export function expectTeamContent(content: CoValue): CoMap { +export function expectTeamContent(content: CoValueContent): CoMap { if (content.type !== "comap") { throw new Error("Expected map"); } From 8a145941dbbfa8e979e894ca84d75ef220b917dc Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 2 Aug 2023 16:27:59 +0100 Subject: [PATCH 24/47] Rename CoValueContent to ContentType --- README.md | 2 +- src/{coValue.test.ts => contentType.test.ts} | 0 src/{coValue.ts => contentType.ts} | 16 ++++++++-------- src/index.ts | 6 +++--- src/jsonValue.ts | 4 ++-- src/multilog.test.ts | 6 +++--- src/multilog.ts | 12 ++++++------ src/node.ts | 2 +- src/permissions.test.ts | 2 +- src/permissions.ts | 4 ++-- src/sync.test.ts | 2 +- 11 files changed, 28 insertions(+), 28 deletions(-) rename src/{coValue.test.ts => contentType.test.ts} (100%) rename src/{coValue.ts => contentType.ts} (93%) diff --git a/README.md b/README.md index 23145acbc..6b38c12ad 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ THIS IS WORK IN PROGRESS - Team (`AgentID` → `Role`) - CoList (`Immutable[]`, addressable positions, insertAfter semantics) - Agent (`{signatoryID, recipientID}[]`) -- MultiStream (independent per-session streams of `Immutable`s) +- CoStream (independent per-session streams of `Immutable`s) - Static (single addressable `Immutable`) ## Implementation Abstractions diff --git a/src/coValue.test.ts b/src/contentType.test.ts similarity index 100% rename from src/coValue.test.ts rename to src/contentType.test.ts diff --git a/src/coValue.ts b/src/contentType.ts similarity index 93% rename from src/coValue.ts rename to src/contentType.ts index ebe9e9808..c936dc950 100644 --- a/src/coValue.ts +++ b/src/contentType.ts @@ -1,14 +1,14 @@ import { JsonAtom, JsonObject, JsonValue } from "./jsonValue"; import { MultiLog, MultiLogID, TransactionID } from "./multilog"; -export type CoValueID = MultiLogID & { +export type CoValueID = MultiLogID & { readonly __type: T; }; -export type CoValueContent = +export type ContentType = | CoMap<{[key: string]: JsonValue}, JsonValue> | CoList - | MultiStream + | CoStream | Static; type MapOp = { @@ -204,12 +204,12 @@ export class CoList { } } -export class MultiStream { - id: CoValueID>; - type: "multistream" = "multistream"; +export class CoStream { + id: CoValueID>; + type: "costream" = "costream"; constructor(multilog: MultiLog) { - this.id = multilog.id as CoValueID>; + this.id = multilog.id as CoValueID>; } toJSON(): JsonObject { @@ -230,7 +230,7 @@ export class Static { } } -export function expectMap(content: CoValueContent): CoMap<{ [key: string]: string }, {}> { +export function expectMap(content: ContentType): CoMap<{ [key: string]: string }, {}> { if (content.type !== "comap") { throw new Error("Expected map"); } diff --git a/src/index.ts b/src/index.ts index 453614bc3..cc2ad60c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,13 @@ -import { CoValueContent } from "./coValue"; +import { ContentType } from "./contentType"; import { JsonValue } from "./jsonValue"; import { MultiLog } from "./multilog"; import { LocalNode } from "./node"; -type Value = JsonValue | CoValueContent; +type Value = JsonValue | ContentType; export { JsonValue, - CoValueContent as CoValue, + ContentType as CoValue, Value, LocalNode, MultiLog diff --git a/src/jsonValue.ts b/src/jsonValue.ts index 9a535263c..67bc29e0c 100644 --- a/src/jsonValue.ts +++ b/src/jsonValue.ts @@ -1,6 +1,6 @@ -import { CoValueID, CoValueContent } from "./coValue"; +import { CoValueID, ContentType } from "./contentType"; export type JsonAtom = string | number | boolean | null; -export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID; +export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID; export type JsonArray = JsonValue[]; export type JsonObject = { [key: string]: JsonValue; }; diff --git a/src/multilog.test.ts b/src/multilog.test.ts index 09fa2aa0f..66aad04bf 100644 --- a/src/multilog.test.ts +++ b/src/multilog.test.ts @@ -18,7 +18,7 @@ test("Can create multilog with new agent credentials and add transaction to it", ); const multilog = node.createMultiLog({ - type: "multistream", + type: "costream", ruleset: { type: "unsafeAllowAll" }, meta: null, }); @@ -58,7 +58,7 @@ test("transactions with wrong signature are rejected", () => { ); const multilog = node.createMultiLog({ - type: "multistream", + type: "costream", ruleset: { type: "unsafeAllowAll" }, meta: null, }); @@ -97,7 +97,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => { ); const multilog = node.createMultiLog({ - type: "multistream", + type: "costream", ruleset: { type: "unsafeAllowAll" }, meta: null, }); diff --git a/src/multilog.ts b/src/multilog.ts index 105017178..f6a2aaf60 100644 --- a/src/multilog.ts +++ b/src/multilog.ts @@ -1,5 +1,5 @@ import { randomBytes } from "@noble/hashes/utils"; -import { CoList, CoMap, CoValueContent, Static, MultiStream } from "./coValue"; +import { CoList, CoMap, ContentType, Static, CoStream } from "./contentType"; import { Encrypted, Hash, @@ -36,7 +36,7 @@ import { MultiLogKnownState, NewContentMessage } from "./sync"; export type MultiLogID = `coval_${string}`; export type MultiLogHeader = { - type: CoValueContent["type"]; + type: ContentType["type"]; ruleset: RulesetDef; meta: JsonValue; }; @@ -94,7 +94,7 @@ export class MultiLog { node: LocalNode; header: MultiLogHeader; sessions: { [key: SessionID]: SessionLog }; - content?: CoValueContent; + content?: ContentType; constructor(header: MultiLogHeader, node: LocalNode) { this.id = multilogIDforHeader(header); @@ -260,7 +260,7 @@ export class MultiLog { ); } - getCurrentContent(): CoValueContent { + getCurrentContent(): ContentType { if (this.content) { return this.content; } @@ -269,8 +269,8 @@ export class MultiLog { this.content = new CoMap(this); } else if (this.header.type === "colist") { this.content = new CoList(this); - } else if (this.header.type === "multistream") { - this.content = new MultiStream(this); + } else if (this.header.type === "costream") { + this.content = new CoStream(this); } else if (this.header.type === "static") { this.content = new Static(this); } else { diff --git a/src/node.ts b/src/node.ts index c824702c5..db0b4649e 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,4 +1,4 @@ -import { CoMap } from "./coValue"; +import { CoMap } from "./contentType"; import { newRandomKeySecret, seal } from "./crypto"; import { MultiLogID, diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 041e5a0ac..0f21679a2 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -6,7 +6,7 @@ import { newRandomSessionID, } from "./multilog"; import { LocalNode } from "./node"; -import { expectMap } from "./coValue"; +import { expectMap } from "./contentType"; import { expectTeamContent } from "./permissions"; import { getRecipientID, diff --git a/src/permissions.ts b/src/permissions.ts index 85ab519bc..9dc10cef0 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,4 +1,4 @@ -import { CoMap, CoValueContent, MapOpPayload } from "./coValue"; +import { CoMap, ContentType, MapOpPayload } from "./contentType"; import { JsonValue } from "./jsonValue"; import { Encrypted, @@ -215,7 +215,7 @@ export type TeamContent = { [key: AgentID]: Role } & { }; }; -export function expectTeamContent(content: CoValueContent): CoMap { +export function expectTeamContent(content: ContentType): CoMap { if (content.type !== "comap") { throw new Error("Expected map"); } diff --git a/src/sync.test.ts b/src/sync.test.ts index 1c7edbea3..56f2c758a 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -7,7 +7,7 @@ import { } from "./multilog"; import { LocalNode } from "./node"; import { Peer, SyncMessage } from "./sync"; -import { MapOpPayload, expectMap } from "./coValue"; +import { MapOpPayload, expectMap } from "./contentType"; test( "Node replies with initial tx and header to empty subscribe", From 25dd2e9447821caeaa6b7f85f0e49f34e8ae23ac Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 2 Aug 2023 16:36:31 +0100 Subject: [PATCH 25/47] Rename MultiLog to CoValue --- README.md | 4 +- src/{multilog.test.ts => coValue.test.ts} | 24 +-- src/{multilog.ts => coValue.ts} | 46 +++--- src/contentType.test.ts | 30 ++-- src/contentType.ts | 42 ++--- src/crypto.ts | 14 +- src/index.ts | 6 +- src/node.ts | 190 +++++++++++----------- src/permissions.test.ts | 56 +++---- src/permissions.ts | 56 +++---- src/sync.test.ts | 124 +++++++------- src/sync.ts | 32 ++-- 12 files changed, 312 insertions(+), 312 deletions(-) rename src/{multilog.test.ts => coValue.test.ts} (84%) rename src/{multilog.ts => coValue.ts} (92%) diff --git a/README.md b/README.md index 6b38c12ad..bd08d7c76 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ THIS IS WORK IN PROGRESS - Static (single addressable `Immutable`) ## Implementation Abstractions -- MultiLog +- CoValue - Session Logs - Transactions - Private (encrypted) transactions - Trusting (unencrypted) transactions - Rulesets -- CoValue Types +- CoValue Content Types - LocalNode - Peers - AgentCredentials diff --git a/src/multilog.test.ts b/src/coValue.test.ts similarity index 84% rename from src/multilog.test.ts rename to src/coValue.test.ts index 66aad04bf..f1af174cf 100644 --- a/src/multilog.test.ts +++ b/src/coValue.test.ts @@ -1,23 +1,23 @@ import { expect, test } from "bun:test"; import { - MultiLog, + CoValue, Transaction, getAgent, getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./multilog"; +} from "./coValue"; import { LocalNode } from "./node"; import { sign } from "./crypto"; -test("Can create multilog with new agent credentials and add transaction to it", () => { +test("Can create coValue with new agent credentials and add transaction to it", () => { const agentCredential = newRandomAgentCredential(); const node = new LocalNode( agentCredential, newRandomSessionID(getAgentID(getAgent(agentCredential))) ); - const multilog = node.createMultiLog({ + const coValue = node.createCoValue({ type: "costream", ruleset: { type: "unsafeAllowAll" }, meta: null, @@ -33,13 +33,13 @@ test("Can create multilog with new agent credentials and add transaction to it", ], }; - const { expectedNewHash } = multilog.expectedNewHashAfter( + const { expectedNewHash } = coValue.expectedNewHashAfter( node.ownSessionID, [transaction] ); expect( - multilog.tryAddTransactions( + coValue.tryAddTransactions( node.ownSessionID, [transaction], expectedNewHash, @@ -57,7 +57,7 @@ test("transactions with wrong signature are rejected", () => { newRandomSessionID(getAgentID(getAgent(agentCredential))) ); - const multilog = node.createMultiLog({ + const coValue = node.createCoValue({ type: "costream", ruleset: { type: "unsafeAllowAll" }, meta: null, @@ -73,13 +73,13 @@ test("transactions with wrong signature are rejected", () => { ], }; - const { expectedNewHash } = multilog.expectedNewHashAfter( + const { expectedNewHash } = coValue.expectedNewHashAfter( node.ownSessionID, [transaction] ); expect( - multilog.tryAddTransactions( + coValue.tryAddTransactions( node.ownSessionID, [transaction], expectedNewHash, @@ -96,7 +96,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => { newRandomSessionID(getAgentID(getAgent(agentCredential))) ); - const multilog = node.createMultiLog({ + const coValue = node.createCoValue({ type: "costream", ruleset: { type: "unsafeAllowAll" }, meta: null, @@ -112,7 +112,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => { ], }; - const { expectedNewHash } = multilog.expectedNewHashAfter( + const { expectedNewHash } = coValue.expectedNewHashAfter( node.ownSessionID, [ { @@ -128,7 +128,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => { ); expect( - multilog.tryAddTransactions( + coValue.tryAddTransactions( node.ownSessionID, [transaction], expectedNewHash, diff --git a/src/multilog.ts b/src/coValue.ts similarity index 92% rename from src/multilog.ts rename to src/coValue.ts index f6a2aaf60..6bb8e06a4 100644 --- a/src/multilog.ts +++ b/src/coValue.ts @@ -31,17 +31,17 @@ import { expectTeamContent, } from "./permissions"; import { LocalNode } from "./node"; -import { MultiLogKnownState, NewContentMessage } from "./sync"; +import { CoValueKnownState, NewContentMessage } from "./sync"; -export type MultiLogID = `coval_${string}`; +export type RawCoValueID = `coval_${string}`; -export type MultiLogHeader = { +export type CoValueHeader = { type: ContentType["type"]; ruleset: RulesetDef; meta: JsonValue; }; -function multilogIDforHeader(header: MultiLogHeader): MultiLogID { +function coValueIDforHeader(header: CoValueHeader): RawCoValueID { const hash = shortHash(header); return `coval_${hash.slice("shortHash_".length)}`; } @@ -69,7 +69,7 @@ export type PrivateTransaction = { keyUsed: KeyID; encryptedChanges: Encrypted< JsonValue[], - { in: MultiLogID; tx: TransactionID } + { in: RawCoValueID; tx: TransactionID } >; }; @@ -89,15 +89,15 @@ export type DecryptedTransaction = { export type TransactionID = { sessionID: SessionID; txIndex: number }; -export class MultiLog { - id: MultiLogID; +export class CoValue { + id: RawCoValueID; node: LocalNode; - header: MultiLogHeader; + header: CoValueHeader; sessions: { [key: SessionID]: SessionLog }; content?: ContentType; - constructor(header: MultiLogHeader, node: LocalNode) { - this.id = multilogIDforHeader(header); + constructor(header: CoValueHeader, node: LocalNode) { + this.id = coValueIDforHeader(header); this.header = header; this.sessions = {}; this.node = node; @@ -106,18 +106,18 @@ export class MultiLog { testWithDifferentCredentials( agentCredential: AgentCredential, ownSessionID: SessionID - ): MultiLog { + ): CoValue { const newNode = this.node.testWithDifferentCredentials( agentCredential, ownSessionID ); - return newNode.expectMultiLogLoaded(this.id); + return newNode.expectCoValueLoaded(this.id); } - knownState(): MultiLogKnownState { + knownState(): CoValueKnownState { return { - multilogID: this.id, + coValueID: this.id, header: true, sessions: Object.fromEntries( Object.entries(this.sessions).map(([k, v]) => [ @@ -187,7 +187,7 @@ export class MultiLog { this.content = undefined; - this.node.syncMultiLog(this); + this.node.syncCoValue(this); const _ = this.getCurrentContent(); @@ -274,7 +274,7 @@ export class MultiLog { } else if (this.header.type === "static") { this.content = new Static(this); } else { - throw new Error(`Unknown multilog type ${this.header.type}`); + throw new Error(`Unknown coValue type ${this.header.type}`); } return this.content; @@ -333,7 +333,7 @@ export class MultiLog { }; } else if (this.header.ruleset.type === "ownedByTeam") { return this.node - .expectMultiLogLoaded(this.header.ruleset.team) + .expectCoValueLoaded(this.header.ruleset.team) .getCurrentReadKey(); } else { throw new Error( @@ -411,7 +411,7 @@ export class MultiLog { ); } else if (this.header.ruleset.type === "ownedByTeam") { return this.node - .expectMultiLogLoaded(this.header.ruleset.team) + .expectCoValueLoaded(this.header.ruleset.team) .getReadKey(keyID); } else { throw new Error( @@ -424,10 +424,10 @@ export class MultiLog { return this.sessions[txID.sessionID]?.transactions[txID.txIndex]; } - newContentSince(knownState: MultiLogKnownState | undefined): NewContentMessage | undefined { + newContentSince(knownState: CoValueKnownState | undefined): NewContentMessage | undefined { const newContent: NewContentMessage = { action: "newContent", - multilogID: this.id, + coValueID: this.id, header: knownState?.header ? undefined : this.header, newContent: Object.fromEntries( Object.entries(this.sessions) @@ -483,7 +483,7 @@ export function getAgent(agentCredential: AgentCredential) { }; } -export function getAgentMultilogHeader(agent: Agent): MultiLogHeader { +export function getAgentCoValueHeader(agent: Agent): CoValueHeader { return { type: "comap", ruleset: { @@ -496,12 +496,12 @@ export function getAgentMultilogHeader(agent: Agent): MultiLogHeader { } export function getAgentID(agent: Agent): AgentID { - return `agent_${multilogIDforHeader(getAgentMultilogHeader(agent)).slice( + return `agent_${coValueIDforHeader(getAgentCoValueHeader(agent)).slice( "coval_".length )}`; } -export function agentIDasMultiLogID(agentID: AgentID): MultiLogID { +export function agentIDAsCoValueID(agentID: AgentID): RawCoValueID { return `coval_${agentID.substring("agent_".length)}`; } diff --git a/src/contentType.test.ts b/src/contentType.test.ts index a437a99c8..b0adfabf2 100644 --- a/src/contentType.test.ts +++ b/src/contentType.test.ts @@ -5,7 +5,7 @@ import { getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./multilog"; +} from "./coValue"; import { LocalNode } from "./node"; test("Empty COJSON Map works", () => { @@ -15,13 +15,13 @@ test("Empty COJSON Map works", () => { newRandomSessionID(getAgentID(getAgent(agentCredential))) ); - const multilog = node.createMultiLog({ + const coValue = node.createCoValue({ type: "comap", ruleset: { type: "unsafeAllowAll" }, meta: null, }); - const content = multilog.getCurrentContent(); + const content = coValue.getCurrentContent(); if (content.type !== "comap") { throw new Error("Expected map"); @@ -39,13 +39,13 @@ test("Can insert and delete Map entries in edit()", () => { newRandomSessionID(getAgentID(getAgent(agentCredential))) ); - const multilog = node.createMultiLog({ + const coValue = node.createCoValue({ type: "comap", ruleset: { type: "unsafeAllowAll" }, meta: null, }); - const content = multilog.getCurrentContent(); + const content = coValue.getCurrentContent(); if (content.type !== "comap") { throw new Error("Expected map"); @@ -71,13 +71,13 @@ test("Can get map entry values at different points in time", () => { newRandomSessionID(getAgentID(getAgent(agentCredential))) ); - const multilog = node.createMultiLog({ + const coValue = node.createCoValue({ type: "comap", ruleset: { type: "unsafeAllowAll" }, meta: null, }); - const content = multilog.getCurrentContent(); + const content = coValue.getCurrentContent(); if (content.type !== "comap") { throw new Error("Expected map"); @@ -110,13 +110,13 @@ test("Can get all historic values of key", () => { newRandomSessionID(getAgentID(getAgent(agentCredential))) ); - const multilog = node.createMultiLog({ + const coValue = node.createCoValue({ type: "comap", ruleset: { type: "unsafeAllowAll" }, meta: null, }); - const content = multilog.getCurrentContent(); + const content = coValue.getCurrentContent(); if (content.type !== "comap") { throw new Error("Expected map"); @@ -139,22 +139,22 @@ test("Can get all historic values of key", () => { { txID: txA, value: "A", - at: txA && multilog.getTx(txA)?.madeAt, + at: txA && coValue.getTx(txA)?.madeAt, }, { txID: txB, value: "B", - at: txB && multilog.getTx(txB)?.madeAt, + at: txB && coValue.getTx(txB)?.madeAt, }, { txID: txDel, value: undefined, - at: txDel && multilog.getTx(txDel)?.madeAt, + at: txDel && coValue.getTx(txDel)?.madeAt, }, { txID: txC, value: "C", - at: txC && multilog.getTx(txC)?.madeAt, + at: txC && coValue.getTx(txC)?.madeAt, }, ]); }); @@ -167,13 +167,13 @@ test("Can get last tx ID for a key", () => { newRandomSessionID(getAgentID(getAgent(agentCredential))) ); - const multilog = node.createMultiLog({ + const coValue = node.createCoValue({ type: "comap", ruleset: { type: "unsafeAllowAll" }, meta: null, }); - const content = multilog.getCurrentContent(); + const content = coValue.getCurrentContent(); if (content.type !== "comap") { throw new Error("Expected map"); diff --git a/src/contentType.ts b/src/contentType.ts index c936dc950..4331d9975 100644 --- a/src/contentType.ts +++ b/src/contentType.ts @@ -1,7 +1,7 @@ import { JsonAtom, JsonObject, JsonValue } from "./jsonValue"; -import { MultiLog, MultiLogID, TransactionID } from "./multilog"; +import { CoValue, RawCoValueID, TransactionID } from "./coValue"; -export type CoValueID = MultiLogID & { +export type CoValueID = RawCoValueID & { readonly __type: T; }; @@ -37,22 +37,22 @@ export class CoMap< MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]} > { id: CoValueID>; - multiLog: MultiLog; + coValue: CoValue; type: "comap" = "comap"; ops: {[KK in K]?: MapOp[]}; - constructor(multiLog: MultiLog) { - this.id = multiLog.id as CoValueID>; - this.multiLog = multiLog; + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; + this.coValue = coValue; this.ops = {}; - this.fillOpsFromMultilog(); + this.fillOpsFromCoValue(); } - protected fillOpsFromMultilog() { + protected fillOpsFromCoValue() { this.ops = {}; - for (const { txID, changes, madeAt } of this.multiLog.getValidSortedTransactions()) { + for (const { txID, changes, madeAt } of this.coValue.getValidSortedTransactions()) { for (const [changeIdx, changeUntyped] of ( changes ).entries()) { @@ -154,9 +154,9 @@ export class CoMap< } edit(changer: (editable: WriteableCoMap) => void): CoMap { - const editable = new WriteableCoMap(this.multiLog); + const editable = new WriteableCoMap(this.coValue); changer(editable); - return new CoMap(this.multiLog); + return new CoMap(this.coValue); } } @@ -168,7 +168,7 @@ export class WriteableCoMap< MM extends {[key: string]: JsonValue} = {[KK in K]: M[KK]} > extends CoMap { set(key: KK, value: M[KK], privacy: "private" | "trusting" = "private"): void { - this.multiLog.makeTransaction([ + this.coValue.makeTransaction([ { op: "insert", key, @@ -176,18 +176,18 @@ export class WriteableCoMap< }, ], privacy); - this.fillOpsFromMultilog(); + this.fillOpsFromCoValue(); } delete(key: K, privacy: "private" | "trusting" = "private"): void { - this.multiLog.makeTransaction([ + this.coValue.makeTransaction([ { op: "delete", key, }, ], privacy); - this.fillOpsFromMultilog(); + this.fillOpsFromCoValue(); } } @@ -195,8 +195,8 @@ export class CoList { id: CoValueID>; type: "colist" = "colist"; - constructor(multilog: MultiLog) { - this.id = multilog.id as CoValueID>; + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; } toJSON(): JsonObject { @@ -208,8 +208,8 @@ export class CoStream { id: CoValueID>; type: "costream" = "costream"; - constructor(multilog: MultiLog) { - this.id = multilog.id as CoValueID>; + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; } toJSON(): JsonObject { @@ -221,8 +221,8 @@ export class Static { id: CoValueID>; type: "static" = "static"; - constructor(multilog: MultiLog) { - this.id = multilog.id as CoValueID>; + constructor(coValue: CoValue) { + this.id = coValue.id as CoValueID>; } toJSON(): JsonObject { diff --git a/src/crypto.ts b/src/crypto.ts index b5ba53ece..e1c7820b9 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -5,7 +5,7 @@ import { base58, base64url } from "@scure/base"; import stableStringify from "fast-json-stable-stringify"; import { blake3 } from "@noble/hashes/blake3"; import { randomBytes } from "@noble/ciphers/webcrypto/utils"; -import { MultiLogID, SessionID, TransactionID } from "./multilog"; +import { RawCoValueID, SessionID, TransactionID } from "./coValue"; export type SignatorySecret = `signatorySecret_z${string}`; export type SignatoryID = `signatory_z${string}`; @@ -72,7 +72,7 @@ export function seal( message: T, from: RecipientSecret, to: Set, - nOnceMaterial: { in: MultiLogID; tx: TransactionID } + nOnceMaterial: { in: RawCoValueID; tx: TransactionID } ): SealedSet { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) @@ -113,7 +113,7 @@ export function openAs( sealedSet: SealedSet, recipient: RecipientSecret, from: RecipientID, - nOnceMaterial: { in: MultiLogID; tx: TransactionID } + nOnceMaterial: { in: RawCoValueID; tx: TransactionID } ): T | undefined { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) @@ -219,8 +219,8 @@ function encrypt( export function encryptForTransaction( value: T, keySecret: KeySecret, - nOnceMaterial: { in: MultiLogID; tx: TransactionID } -): Encrypted { + nOnceMaterial: { in: RawCoValueID; tx: TransactionID } +): Encrypted { return encrypt(value, keySecret, nOnceMaterial); } @@ -273,9 +273,9 @@ function decrypt( } export function decryptForTransaction( - encrypted: Encrypted, + encrypted: Encrypted, keySecret: KeySecret, - nOnceMaterial: { in: MultiLogID; tx: TransactionID } + nOnceMaterial: { in: RawCoValueID; tx: TransactionID } ): T | undefined { return decrypt(encrypted, keySecret, nOnceMaterial); } diff --git a/src/index.ts b/src/index.ts index cc2ad60c9..9ddcd6bca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,14 @@ import { ContentType } from "./contentType"; import { JsonValue } from "./jsonValue"; -import { MultiLog } from "./multilog"; +import { CoValue } from "./coValue"; import { LocalNode } from "./node"; type Value = JsonValue | ContentType; export { JsonValue, - ContentType as CoValue, + ContentType, Value, LocalNode, - MultiLog + CoValue } diff --git a/src/node.ts b/src/node.ts index db0b4649e..7acf2db6c 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,19 +1,19 @@ import { CoMap } from "./contentType"; import { newRandomKeySecret, seal } from "./crypto"; import { - MultiLogID, - MultiLog, + RawCoValueID, + CoValue, AgentCredential, AgentID, SessionID, Agent, getAgent, getAgentID, - getAgentMultilogHeader, - MultiLogHeader, + getAgentCoValueHeader, + CoValueHeader, agentIDfromSessionID, - agentIDasMultiLogID, -} from "./multilog"; + agentIDAsCoValueID, +} from "./coValue"; import { Team, expectTeamContent } from "./permissions"; import { NewContentMessage, @@ -31,7 +31,7 @@ import { } from "./sync"; export class LocalNode { - multilogs: { [key: MultiLogID]: MultilogState } = {}; + coValues: { [key: RawCoValueID]: CoValueState } = {}; peers: { [key: PeerID]: PeerState } = {}; agentCredential: AgentCredential; agentID: AgentID; @@ -46,35 +46,35 @@ export class LocalNode { this.knownAgents[agentID] = agent; this.ownSessionID = ownSessionID; - const agentMultilog = new MultiLog(getAgentMultilogHeader(agent), this); - this.multilogs[agentMultilog.id] = { + const agentCoValue = new CoValue(getAgentCoValueHeader(agent), this); + this.coValues[agentCoValue.id] = { state: "loaded", - multilog: agentMultilog, + coValue: agentCoValue, }; } - createMultiLog(header: MultiLogHeader): MultiLog { - const multilog = new MultiLog(header, this); - this.multilogs[multilog.id] = { state: "loaded", multilog }; + createCoValue(header: CoValueHeader): CoValue { + const coValue = new CoValue(header, this); + this.coValues[coValue.id] = { state: "loaded", coValue: coValue }; - this.syncMultiLog(multilog); + this.syncCoValue(coValue); - return multilog; + return coValue; } - loadMultiLog(id: MultiLogID): Promise { - let entry = this.multilogs[id]; + loadCoValue(id: RawCoValueID): Promise { + let entry = this.coValues[id]; if (!entry) { entry = newLoadingState(); - this.multilogs[id] = entry; + this.coValues[id] = entry; for (const peer of Object.values(this.peers)) { peer.outgoing .write({ action: "subscribe", knownState: { - multilogID: id, + coValueID: id, header: false, sessions: {}, }, @@ -85,26 +85,26 @@ export class LocalNode { } } if (entry.state === "loaded") { - return Promise.resolve(entry.multilog); + return Promise.resolve(entry.coValue); } return entry.done; } - expectMultiLogLoaded(id: MultiLogID, expectation?: string): MultiLog { - const entry = this.multilogs[id]; + expectCoValueLoaded(id: RawCoValueID, expectation?: string): CoValue { + const entry = this.coValues[id]; if (!entry) { throw new Error( - `${expectation ? expectation + ": " : ""}Unknown multilog ${id}` + `${expectation ? expectation + ": " : ""}Unknown CoValue ${id}` ); } if (entry.state === "loading") { throw new Error( `${ expectation ? expectation + ": " : "" - }Multilog ${id} not yet loaded` + }CoValue ${id} not yet loaded` ); } - return entry.multilog; + return entry.coValue; } addKnownAgent(agent: Agent) { @@ -113,13 +113,13 @@ export class LocalNode { } createTeam(): Team { - const teamMultilog = this.createMultiLog({ + const teamCoValue = this.createCoValue({ type: "comap", ruleset: { type: "team", initialAdmin: this.agentID }, meta: null, }); - let teamContent = expectTeamContent(teamMultilog.getCurrentContent()); + let teamContent = expectTeamContent(teamCoValue.getCurrentContent()); teamContent = teamContent.edit((editable) => { editable.set(this.agentID, "admin", "trusting"); @@ -130,8 +130,8 @@ export class LocalNode { this.agentCredential.recipientSecret, new Set([getAgent(this.agentCredential).recipientID]), { - in: teamMultilog.id, - tx: teamMultilog.nextTransactionID(), + in: teamCoValue.id, + tx: teamCoValue.nextTransactionID(), } ); @@ -156,7 +156,7 @@ export class LocalNode { this.peers[peer.id] = peerState; if (peer.role === "server") { - for (const entry of Object.values(this.multilogs)) { + for (const entry of Object.values(this.coValues)) { if (entry.state === "loading") { continue; } @@ -164,15 +164,15 @@ export class LocalNode { peerState.outgoing .write({ action: "subscribe", - knownState: entry.multilog.knownState(), + knownState: entry.coValue.knownState(), }) .catch((e) => { // TODO: handle error console.error("Error writing to peer", e); }); - peerState.optimisticKnownStates[entry.multilog.id] = { - multilogID: entry.multilog.id, + peerState.optimisticKnownStates[entry.coValue.id] = { + coValueID: entry.coValue.id, header: false, sessions: {}, }; @@ -217,20 +217,20 @@ export class LocalNode { handleSubscribe( msg: SubscribeMessage, peer: PeerState, - asDependencyOf?: MultiLogID + asDependencyOf?: RawCoValueID ): SyncMessage[] { - const entry = this.multilogs[msg.knownState.multilogID]; + const entry = this.coValues[msg.knownState.coValueID]; if (!entry || entry.state === "loading") { if (!entry) { - this.multilogs[msg.knownState.multilogID] = newLoadingState(); + this.coValues[msg.knownState.coValueID] = newLoadingState(); } return [ { action: "subscribeResponse", knownState: { - multilogID: msg.knownState.multilogID, + coValueID: msg.knownState.coValueID, header: false, sessions: {}, }, @@ -238,39 +238,39 @@ export class LocalNode { ]; } - peer.optimisticKnownStates[entry.multilog.id] = - entry.multilog.knownState(); + peer.optimisticKnownStates[entry.coValue.id] = + entry.coValue.knownState(); - const newContent = entry.multilog.newContentSince(msg.knownState); + const newContent = entry.coValue.newContentSince(msg.knownState); - const dependedOnMultilogs = - entry.multilog.header.ruleset.type === "team" - ? expectTeamContent(entry.multilog.getCurrentContent()) + const dependedOnCoValues = + entry.coValue.header.ruleset.type === "team" + ? expectTeamContent(entry.coValue.getCurrentContent()) .keys() .filter((k): k is AgentID => k.startsWith("agent_")) - .map((agent) => agentIDasMultiLogID(agent)) - : entry.multilog.header.ruleset.type === "ownedByTeam" - ? [entry.multilog.header.ruleset.team] + .map((agent) => agentIDAsCoValueID(agent)) + : entry.coValue.header.ruleset.type === "ownedByTeam" + ? [entry.coValue.header.ruleset.team] : []; return [ - ...dependedOnMultilogs.flatMap((multilogID) => + ...dependedOnCoValues.flatMap((coValueID) => this.handleSubscribe( { action: "subscribe", knownState: { - multilogID, + coValueID, header: false, sessions: {}, }, }, peer, - asDependencyOf || msg.knownState.multilogID + asDependencyOf || msg.knownState.coValueID ) ), { action: "subscribeResponse", - knownState: entry.multilog.knownState(), + knownState: entry.coValue.knownState(), asDependencyOf, }, ...(newContent ? [newContent] : []), @@ -281,70 +281,70 @@ export class LocalNode { msg: SubscribeResponseMessage, peer: PeerState ): SyncMessage[] { - let entry = this.multilogs[msg.knownState.multilogID]; + let entry = this.coValues[msg.knownState.coValueID]; if (!entry) { if (msg.asDependencyOf) { - if (this.multilogs[msg.asDependencyOf]) { + if (this.coValues[msg.asDependencyOf]) { entry = newLoadingState(); - this.multilogs[msg.knownState.multilogID] = entry; + this.coValues[msg.knownState.coValueID] = entry; } } else { throw new Error( - "Expected multilog entry to be created, missing subscribe?" + "Expected coValue entry to be created, missing subscribe?" ); } } if (entry.state === "loading") { - peer.optimisticKnownStates[msg.knownState.multilogID] = + peer.optimisticKnownStates[msg.knownState.coValueID] = msg.knownState; return []; } - const newContent = entry.multilog.newContentSince(msg.knownState); - peer.optimisticKnownStates[msg.knownState.multilogID] = - combinedKnownStates(msg.knownState, entry.multilog.knownState()); + const newContent = entry.coValue.newContentSince(msg.knownState); + peer.optimisticKnownStates[msg.knownState.coValueID] = + combinedKnownStates(msg.knownState, entry.coValue.knownState()); return newContent ? [newContent] : []; } handleNewContent(msg: NewContentMessage): SyncMessage[] { - let entry = this.multilogs[msg.multilogID]; + let entry = this.coValues[msg.coValueID]; if (!entry) { throw new Error( - "Expected multilog entry to be created, missing subscribe?" + "Expected coValue entry to be created, missing subscribe?" ); } - let resolveAfterDone: ((multilog: MultiLog) => void) | undefined; + let resolveAfterDone: ((coValue: CoValue) => void) | undefined; if (entry.state === "loading") { if (!msg.header) { throw new Error("Expected header to be sent in first message"); } - const multilog = new MultiLog(msg.header, this); + const coValue = new CoValue(msg.header, this); resolveAfterDone = entry.resolve; entry = { state: "loaded", - multilog, + coValue: coValue, }; - this.multilogs[msg.multilogID] = entry; + this.coValues[msg.coValueID] = entry; } - const multilog = entry.multilog; + const coValue = entry.coValue; let invalidStateAssumed = false; for (const sessionID of Object.keys(msg.newContent) as SessionID[]) { const ourKnownTxIdx = - multilog.sessions[sessionID]?.transactions.length; + coValue.sessions[sessionID]?.transactions.length; const theirFirstNewTxIdx = msg.newContent[sessionID].after; if ((ourKnownTxIdx || 0) < theirFirstNewTxIdx) { @@ -361,7 +361,7 @@ export class LocalNode { alreadyKnownOffset ); - const success = multilog.tryAddTransactions( + const success = coValue.tryAddTransactions( sessionID, newTransactions, msg.newContent[sessionID].lastHash, @@ -375,14 +375,14 @@ export class LocalNode { } if (resolveAfterDone) { - resolveAfterDone(multilog); + resolveAfterDone(coValue); } return invalidStateAssumed ? [ { action: "wrongAssumedKnownState", - knownState: multilog.knownState(), + knownState: coValue.knownState(), }, ] : []; @@ -392,12 +392,12 @@ export class LocalNode { msg: WrongAssumedKnownStateMessage, peer: PeerState ): SyncMessage[] { - const multilog = this.expectMultiLogLoaded(msg.knownState.multilogID); + const coValue = this.expectCoValueLoaded(msg.knownState.coValueID); - peer.optimisticKnownStates[msg.knownState.multilogID] = - combinedKnownStates(msg.knownState, multilog.knownState()); + peer.optimisticKnownStates[msg.knownState.coValueID] = + combinedKnownStates(msg.knownState, coValue.knownState()); - const newContent = multilog.newContentSince(msg.knownState); + const newContent = coValue.newContentSince(msg.knownState); return newContent ? [newContent] : []; } @@ -406,28 +406,28 @@ export class LocalNode { throw new Error("Method not implemented."); } - async syncMultiLog(multilog: MultiLog) { + async syncCoValue(coValue: CoValue) { for (const peer of Object.values(this.peers)) { const optimisticKnownState = - peer.optimisticKnownStates[multilog.id]; + peer.optimisticKnownStates[coValue.id]; if (optimisticKnownState || peer.role === "server") { const newContent = - multilog.newContentSince(optimisticKnownState); + coValue.newContentSince(optimisticKnownState); - peer.optimisticKnownStates[multilog.id] = peer - .optimisticKnownStates[multilog.id] + peer.optimisticKnownStates[coValue.id] = peer + .optimisticKnownStates[coValue.id] ? combinedKnownStates( - peer.optimisticKnownStates[multilog.id], - multilog.knownState() + peer.optimisticKnownStates[coValue.id], + coValue.knownState() ) - : multilog.knownState(); + : coValue.knownState(); if (!optimisticKnownState && peer.role === "server") { // auto-subscribe await peer.outgoing.write({ action: "subscribe", - knownState: multilog.knownState(), + knownState: coValue.knownState(), }); } @@ -444,21 +444,21 @@ export class LocalNode { ): LocalNode { const newNode = new LocalNode(agentCredential, ownSessionID); - newNode.multilogs = Object.fromEntries( - Object.entries(this.multilogs) + newNode.coValues = Object.fromEntries( + Object.entries(this.coValues) .map(([id, entry]) => { if (entry.state === "loading") { return undefined; } - const newMultilog = new MultiLog( - entry.multilog.header, + const newCoValue = new CoValue( + entry.coValue.header, newNode ); - newMultilog.sessions = entry.multilog.sessions; + newCoValue.sessions = entry.coValue.sessions; - return [id, { state: "loaded", multilog: newMultilog }]; + return [id, { state: "loaded", coValue: newCoValue }]; }) .filter((x): x is Exclude => !!x) ); @@ -472,18 +472,18 @@ export class LocalNode { } } -type MultilogState = +type CoValueState = | { state: "loading"; - done: Promise; - resolve: (multilog: MultiLog) => void; + done: Promise; + resolve: (coValue: CoValue) => void; } - | { state: "loaded"; multilog: MultiLog }; + | { state: "loaded"; coValue: CoValue }; -function newLoadingState(): MultilogState { - let resolve: (multilog: MultiLog) => void; +function newLoadingState(): CoValueState { + let resolve: (coValue: CoValue) => void; - const promise = new Promise((r) => { + const promise = new Promise((r) => { resolve = r; }); diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 0f21679a2..3d26fb40c 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -4,7 +4,7 @@ import { getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./multilog"; +} from "./coValue"; import { LocalNode } from "./node"; import { expectMap } from "./contentType"; import { expectTeamContent } from "./permissions"; @@ -44,7 +44,7 @@ function newTeam() { const node = new LocalNode(admin, newRandomSessionID(adminID)); - const team = node.createMultiLog({ + const team = node.createCoValue({ type: "comap", ruleset: { type: "team", initialAdmin: adminID }, meta: null, @@ -351,7 +351,7 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader test("Admins can write to an object that is owned by their team", () => { const { node, team } = newTeam(); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -393,7 +393,7 @@ test("Writers can write to an object that is owned by their team", () => { expect(editable.get(writerID)).toEqual("writer"); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -431,7 +431,7 @@ test("Writers can write to an object that is owned by their team (high level)", const childObject = team.createMap(); let childObjectAsWriter = expectMap( - childObject.multiLog + childObject.coValue .testWithDifferentCredentials(writer, newRandomSessionID(writerID)) .getCurrentContent() ); @@ -455,7 +455,7 @@ test("Readers can not write to an object that is owned by their team", () => { expect(editable.get(readerID)).toEqual("reader"); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -493,7 +493,7 @@ test("Readers can not write to an object that is owned by their team (high level const childObject = team.createMap(); let childObjectAsReader = expectMap( - childObject.multiLog + childObject.coValue .testWithDifferentCredentials(reader, newRandomSessionID(readerID)) .getCurrentContent() ); @@ -530,7 +530,7 @@ test("Admins can set team read key and then use it to create and read private tr expect(team.getCurrentReadKey().secret).toEqual(readKey); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -588,7 +588,7 @@ test("Admins can set team read key and then writers can use it to create and rea editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -628,7 +628,7 @@ test("Admins can set team read key and then writers can use it to create and rea const childObject = team.createMap(); let childObjectAsWriter = expectMap( - childObject.multiLog + childObject.coValue .testWithDifferentCredentials(writer, newRandomSessionID(writerID)) .getCurrentContent() ); @@ -669,7 +669,7 @@ test("Admins can set team read key and then use it to create private transaction editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -711,7 +711,7 @@ test("Admins can set team read key and then use it to create private transaction expect(editable.get("foo")).toEqual("bar"); }); - const childContentAsReader = expectMap(childObject.multiLog.testWithDifferentCredentials( + const childContentAsReader = expectMap(childObject.coValue.testWithDifferentCredentials( reader, newRandomSessionID(readerID) ).getCurrentContent()); @@ -769,7 +769,7 @@ test("Admins can set team read key and then use it to create private transaction ); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -827,7 +827,7 @@ test("Admins can set team read key and then use it to create private transaction expect(editable.get("foo")).toEqual("bar"); }); - const childContentAsReader1 = expectMap(childObject.multiLog.testWithDifferentCredentials( + const childContentAsReader1 = expectMap(childObject.coValue.testWithDifferentCredentials( reader1, newRandomSessionID(reader1ID) ).getCurrentContent()); @@ -836,7 +836,7 @@ test("Admins can set team read key and then use it to create private transaction team.addMember(reader2ID, "reader"); - const childContentAsReader2 = expectMap(childObject.multiLog.testWithDifferentCredentials( + const childContentAsReader2 = expectMap(childObject.coValue.testWithDifferentCredentials( reader2, newRandomSessionID(reader2ID) ).getCurrentContent()); @@ -869,7 +869,7 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(team.getCurrentReadKey().secret).toEqual(readKey); }); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -923,7 +923,7 @@ test("Admins can set team read key, make a private transaction in an owned objec let childObject = team.createMap(); - const firstReadKey = childObject.multiLog.getCurrentReadKey(); + const firstReadKey = childObject.coValue.getCurrentReadKey(); childObject = childObject.edit((editable) => { editable.set("foo", "bar", "private"); @@ -934,7 +934,7 @@ test("Admins can set team read key, make a private transaction in an owned objec team.rotateReadKey(); - expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(firstReadKey); + expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey); childObject = childObject.edit((editable) => { editable.set("foo2", "bar2", "private"); @@ -948,7 +948,7 @@ test("Admins can set team read key, make a private transaction in an owned objec test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, add a reader, make another private transaction in the owned object, and both can be read by the reader", () => { const { node, team, admin, adminID } = newTeam(); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -1052,7 +1052,7 @@ test("Admins can set team read key, make a private transaction in an owned objec let childObject = team.createMap(); - const firstReadKey = childObject.multiLog.getCurrentReadKey(); + const firstReadKey = childObject.coValue.getCurrentReadKey(); childObject = childObject.edit((editable) => { editable.set("foo", "bar", "private"); @@ -1063,7 +1063,7 @@ test("Admins can set team read key, make a private transaction in an owned objec team.rotateReadKey(); - expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(firstReadKey); + expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey); const reader = newRandomAgentCredential(); const readerID = getAgentID(getAgent(reader)); @@ -1077,7 +1077,7 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(editable.get("foo2")).toEqual("bar2"); }); - const childContentAsReader = expectMap(childObject.multiLog.testWithDifferentCredentials( + const childContentAsReader = expectMap(childObject.coValue.testWithDifferentCredentials( reader, newRandomSessionID(readerID) ).getCurrentContent()); @@ -1090,7 +1090,7 @@ test("Admins can set team read key, make a private transaction in an owned objec test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions", () => { const { node, team, admin, adminID } = newTeam(); - const childObject = node.createMultiLog({ + const childObject = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, @@ -1192,7 +1192,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec expect(editable.get("foo2")).toEqual("bar2"); }); - // TODO: make sure these instances of multilogs sync between each other so this isn't necessary? + // TODO: make sure these instances of coValues sync between each other so this isn't necessary? childObjectAsReader = childObject.testWithDifferentCredentials( reader, newRandomSessionID(readerID) @@ -1228,7 +1228,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec team.rotateReadKey(); - const secondReadKey = childObject.multiLog.getCurrentReadKey(); + const secondReadKey = childObject.coValue.getCurrentReadKey(); const reader = newRandomAgentCredential(); const readerID = getAgentID(getAgent(reader)); @@ -1251,14 +1251,14 @@ test("Admins can set team read rey, make a private transaction in an owned objec team.removeMember(readerID); - expect(childObject.multiLog.getCurrentReadKey()).not.toEqual(secondReadKey); + expect(childObject.coValue.getCurrentReadKey()).not.toEqual(secondReadKey); childObject = childObject.edit((editable) => { editable.set("foo3", "bar3", "private"); expect(editable.get("foo3")).toEqual("bar3"); }); - const childContentAsReader2 = expectMap(childObject.multiLog.testWithDifferentCredentials( + const childContentAsReader2 = expectMap(childObject.coValue.testWithDifferentCredentials( reader2, newRandomSessionID(reader2ID) ).getCurrentContent()); @@ -1267,7 +1267,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec expect(childContentAsReader2.get("foo2")).toEqual("bar2"); expect(childContentAsReader2.get("foo3")).toEqual("bar3"); - expect(() => childObject.multiLog.testWithDifferentCredentials( + expect(() => childObject.coValue.testWithDifferentCredentials( reader, newRandomSessionID(readerID) ).getCurrentContent()).toThrow(/readKey (.+?) not revealed for (.+?)/); diff --git a/src/permissions.ts b/src/permissions.ts index 9dc10cef0..224d568cd 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -15,19 +15,19 @@ import { import { AgentCredential, AgentID, - MultiLog, - MultiLogID, + CoValue, + RawCoValueID, SessionID, Transaction, TransactionID, TrustingTransaction, agentIDfromSessionID, -} from "./multilog"; +} from "./coValue"; import { LocalNode } from "."; export type PermissionsDef = - | { type: "team"; initialAdmin: AgentID; parentTeams?: MultiLogID[] } - | { type: "ownedByTeam"; team: MultiLogID } + | { type: "team"; initialAdmin: AgentID; parentTeams?: RawCoValueID[] } + | { type: "ownedByTeam"; team: RawCoValueID } | { type: "agent"; initialSignatoryID: SignatoryID; @@ -38,11 +38,11 @@ export type PermissionsDef = export type Role = "reader" | "writer" | "admin" | "revoked"; export function determineValidTransactions( - multilog: MultiLog + coValue: CoValue ): { txID: TransactionID; tx: Transaction }[] { - if (multilog.header.ruleset.type === "team") { + if (coValue.header.ruleset.type === "team") { const allTrustingTransactionsSorted = Object.entries( - multilog.sessions + coValue.sessions ).flatMap(([sessionID, sessionLog]) => { return sessionLog.transactions .map((tx, txIndex) => ({ sessionID, txIndex, tx })) @@ -64,7 +64,7 @@ export function determineValidTransactions( return a.tx.madeAt - b.tx.madeAt; }); - const initialAdmin = multilog.header.ruleset.initialAdmin; + const initialAdmin = coValue.header.ruleset.initialAdmin; if (!initialAdmin) { throw new Error("Team must have initialAdmin"); @@ -153,10 +153,10 @@ export function determineValidTransactions( } return validTransactions; - } else if (multilog.header.ruleset.type === "ownedByTeam") { + } else if (coValue.header.ruleset.type === "ownedByTeam") { const teamContent = - multilog.node.expectMultiLogLoaded( - multilog.header.ruleset.team, + coValue.node.expectCoValueLoaded( + coValue.header.ruleset.team, "Determining valid transaction in owned object but its team wasn't loaded" ).getCurrentContent(); @@ -164,7 +164,7 @@ export function determineValidTransactions( throw new Error("Team must be a map"); } - return Object.entries(multilog.sessions).flatMap( + return Object.entries(coValue.sessions).flatMap( ([sessionID, sessionLog]) => { const transactor = agentIDfromSessionID(sessionID as SessionID); return sessionLog.transactions @@ -185,8 +185,8 @@ export function determineValidTransactions( })); } ); - } else if (multilog.header.ruleset.type === "unsafeAllowAll") { - return Object.entries(multilog.sessions).flatMap( + } else if (coValue.header.ruleset.type === "unsafeAllowAll") { + return Object.entries(coValue.sessions).flatMap( ([sessionID, sessionLog]) => { return sessionLog.transactions.map((tx, txIndex) => ({ txID: { sessionID: sessionID as SessionID, txIndex }, @@ -194,11 +194,11 @@ export function determineValidTransactions( })); } ); - } else if (multilog.header.ruleset.type === "agent") { + } else if (coValue.header.ruleset.type === "agent") { // TODO return []; } else { - throw new Error("Unknown ruleset type " + (multilog.header.ruleset as any).type); + throw new Error("Unknown ruleset type " + (coValue.header.ruleset as any).type); } } @@ -232,7 +232,7 @@ export class Team { this.node = node; } - get id(): MultiLogID { + get id(): RawCoValueID { return this.teamMap.id; } @@ -249,15 +249,15 @@ export class Team { throw new Error("Failed to set role"); } - const currentReadKey = this.teamMap.multiLog.getCurrentReadKey(); + const currentReadKey = this.teamMap.coValue.getCurrentReadKey(); const revelation = seal( currentReadKey.secret, - this.teamMap.multiLog.node.agentCredential.recipientSecret, + this.teamMap.coValue.node.agentCredential.recipientSecret, new Set([agent.recipientID]), { - in: this.teamMap.multiLog.id, - tx: this.teamMap.multiLog.nextTransactionID(), + in: this.teamMap.coValue.id, + tx: this.teamMap.coValue.nextTransactionID(), } ); @@ -281,21 +281,21 @@ export class Team { } }) as AgentID[]; - const currentReadKey = this.teamMap.multiLog.getCurrentReadKey(); + const currentReadKey = this.teamMap.coValue.getCurrentReadKey(); const newReadKey = newRandomKeySecret(); const newReadKeyRevelation = seal( newReadKey.secret, - this.teamMap.multiLog.node.agentCredential.recipientSecret, + this.teamMap.coValue.node.agentCredential.recipientSecret, new Set( currentlyPermittedReaders.map( (reader) => this.node.knownAgents[reader].recipientID ) ), { - in: this.teamMap.multiLog.id, - tx: this.teamMap.multiLog.nextTransactionID(), + in: this.teamMap.coValue.id, + tx: this.teamMap.coValue.nextTransactionID(), } ); @@ -329,7 +329,7 @@ export class Team { meta?: M ): CoMap { return this.node - .createMultiLog({ + .createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", @@ -346,7 +346,7 @@ export class Team { ): Team { return new Team( expectTeamContent( - this.teamMap.multiLog + this.teamMap.coValue .testWithDifferentCredentials(credential, sessionId) .getCurrentContent() ), diff --git a/src/sync.test.ts b/src/sync.test.ts index 56f2c758a..2698d1224 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -4,7 +4,7 @@ import { getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./multilog"; +} from "./coValue"; import { LocalNode } from "./node"; import { Peer, SyncMessage } from "./sync"; import { MapOpPayload, expectMap } from "./contentType"; @@ -40,7 +40,7 @@ test( await writer.write({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: false, sessions: {}, }, @@ -57,14 +57,14 @@ test( expect(subscribeResponseMsg.value).toEqual({ action: "subscribeResponse", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); const newContentMsg = await reader.read(); expect(newContentMsg.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: { type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, @@ -76,7 +76,7 @@ test( newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[0].madeAt, changes: [ { @@ -88,9 +88,9 @@ test( }, ], lastHash: - map.multiLog.sessions[node.ownSessionID].lastHash!, + map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); @@ -128,7 +128,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( await writer.write({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: true, sessions: { [node.ownSessionID]: 1, @@ -147,14 +147,14 @@ test("Node replies with only new tx to subscribe with some known state", async ( expect(mapSubscribeResponseMsg.value).toEqual({ action: "subscribeResponse", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); const mapNewContentMsg = await reader.read(); expect(mapNewContentMsg.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: undefined, newContent: { [node.ownSessionID]: { @@ -162,7 +162,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[1].madeAt, changes: [ { @@ -173,15 +173,15 @@ test("Node replies with only new tx to subscribe with some known state", async ( ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); }); -test.skip("TODO: node only replies with new tx to subscribe with some known state, even in the depended on multilogs", () => {}); +test.skip("TODO: node only replies with new tx to subscribe with some known state, even in the depended on coValues", () => {}); test("After subscribing, node sends own known state and new txs to peer", async () => { const admin = newRandomAgentCredential(); @@ -208,7 +208,7 @@ test("After subscribing, node sends own known state and new txs to peer", async await writer.write({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: false, sessions: { [node.ownSessionID]: 0, @@ -227,15 +227,15 @@ test("After subscribing, node sends own known state and new txs to peer", async expect(mapSubscribeResponseMsg.value).toEqual({ action: "subscribeResponse", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); const mapNewContentHeaderOnlyMsg = await reader.read(); expect(mapNewContentHeaderOnlyMsg.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, - header: map.multiLog.header, + coValueID: map.coValue.id, + header: map.coValue.header, newContent: {}, } satisfies SyncMessage); @@ -247,14 +247,14 @@ test("After subscribing, node sends own known state and new txs to peer", async expect(mapEditMsg1.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, + coValueID: map.coValue.id, newContent: { [node.ownSessionID]: { after: 0, newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[0].madeAt, changes: [ { @@ -265,9 +265,9 @@ test("After subscribing, node sends own known state and new txs to peer", async ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); @@ -280,14 +280,14 @@ test("After subscribing, node sends own known state and new txs to peer", async expect(mapEditMsg2.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, + coValueID: map.coValue.id, newContent: { [node.ownSessionID]: { after: 1, newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[1].madeAt, changes: [ { @@ -298,9 +298,9 @@ test("After subscribing, node sends own known state and new txs to peer", async ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); @@ -335,7 +335,7 @@ test("Client replies with known new content to subscribeResponse from server", a await writer.write({ action: "subscribeResponse", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: false, sessions: { [node.ownSessionID]: 0, @@ -349,15 +349,15 @@ test("Client replies with known new content to subscribeResponse from server", a expect(msg1.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, - header: map.multiLog.header, + coValueID: map.coValue.id, + header: map.coValue.header, newContent: { [node.ownSessionID]: { after: 0, newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[0].madeAt, changes: [ { @@ -368,9 +368,9 @@ test("Client replies with known new content to subscribeResponse from server", a ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); @@ -401,7 +401,7 @@ test("No matter the optimistic known state, node respects invalid known state me await writer.write({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: false, sessions: { [node.ownSessionID]: 0, @@ -432,7 +432,7 @@ test("No matter the optimistic known state, node respects invalid known state me await writer.write({ action: "wrongAssumedKnownState", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: true, sessions: { [node.ownSessionID]: 1, @@ -444,7 +444,7 @@ test("No matter the optimistic known state, node respects invalid known state me expect(newContentAfterWrongAssumedState.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: undefined, newContent: { [node.ownSessionID]: { @@ -452,7 +452,7 @@ test("No matter the optimistic known state, node respects invalid known state me newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[1].madeAt, changes: [ { @@ -463,15 +463,15 @@ test("No matter the optimistic known state, node respects invalid known state me ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); }); -test("If we add a peer, but it never subscribes to a multilog, it won't get any messages", async () => { +test("If we add a peer, but it never subscribes to a coValue, it won't get any messages", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -500,7 +500,7 @@ test("If we add a peer, but it never subscribes to a multilog, it won't get any await shouldNotResolve(reader.read(), { timeout: 50 }); }); -test("If we add a server peer, all updates to all multilogs are sent to it, even if it doesn't subscribe", async () => { +test("If we add a server peer, all updates to all coValues are sent to it, even if it doesn't subscribe", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -533,7 +533,7 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even expect(subscribeMsg.value).toEqual({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: true, sessions: {}, }, @@ -543,15 +543,15 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even expect(newContentMsg.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, - header: map.multiLog.header, + coValueID: map.coValue.id, + header: map.coValue.header, newContent: { [node.ownSessionID]: { after: 0, newTransactions: [ { privacy: "trusting", - madeAt: map.multiLog.sessions[node.ownSessionID] + madeAt: map.coValue.sessions[node.ownSessionID] .transactions[0].madeAt, changes: [ { @@ -562,15 +562,15 @@ test("If we add a server peer, all updates to all multilogs are sent to it, even ], }, ], - lastHash: map.multiLog.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, lastSignature: - map.multiLog.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID].lastSignature!, }, }, } satisfies SyncMessage); }); -test("If we add a server peer, newly created multilogs are auto-subscribed to", async () => { +test("If we add a server peer, newly created coValues are auto-subscribed to", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -600,22 +600,22 @@ test("If we add a server peer, newly created multilogs are auto-subscribed to", expect(msg1.value).toEqual({ action: "subscribe", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); const msg2 = await reader.read(); expect(msg2.value).toEqual({ action: "newContent", - multilogID: map.multiLog.id, - header: map.multiLog.header, + coValueID: map.coValue.id, + header: map.coValue.header, newContent: {}, } satisfies SyncMessage); }); test.skip("TODO: when receiving a subscribe response that is behind our optimistic state (due to already sent content), we ignore it", () => {}); -test("When we connect a new server peer, we try to sync all existing multilogs to it", async () => { +test("When we connect a new server peer, we try to sync all existing coValues to it", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -642,14 +642,14 @@ test("When we connect a new server peer, we try to sync all existing multilogs t expect(teamSubscribeMessage.value).toEqual({ action: "subscribe", - knownState: team.teamMap.multiLog.knownState(), + knownState: team.teamMap.coValue.knownState(), } satisfies SyncMessage); const secondMessage = await reader.read(); expect(secondMessage.value).toEqual({ action: "subscribe", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); }); @@ -678,7 +678,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe await writer.write({ action: "subscribe", knownState: { - multilogID: map.multiLog.id, + coValueID: map.coValue.id, header: true, sessions: { [node.ownSessionID]: 1, @@ -696,11 +696,11 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe expect(mapSubscribeResponse.value).toEqual({ action: "subscribeResponse", - knownState: map.multiLog.knownState(), + knownState: map.coValue.knownState(), } satisfies SyncMessage); }); -test("When replaying creation and transactions of a multilog as new content, the receiving peer integrates this information", async () => { +test("When replaying creation and transactions of a coValue as new content, the receiving peer integrates this information", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -752,7 +752,7 @@ test("When replaying creation and transactions of a multilog as new content, the await writer2.write(teamSubscribeMsg.value); const teamSubscribeResponseMsg = await reader2.read(); - expect(node2.multilogs[team.teamMap.multiLog.id]?.state).toEqual("loading"); + expect(node2.coValues[team.teamMap.coValue.id]?.state).toEqual("loading"); const writer1 = inTx1.getWriter(); @@ -765,7 +765,7 @@ test("When replaying creation and transactions of a multilog as new content, the const _mapSubscribeResponseMsg = await reader2.read(); await writer2.write(mapNewContentMsg.value); - expect(node2.multilogs[map.multiLog.id]?.state).toEqual("loading"); + expect(node2.coValues[map.coValue.id]?.state).toEqual("loading"); await writer2.write(mapEditMsg.value); @@ -773,12 +773,12 @@ test("When replaying creation and transactions of a multilog as new content, the expect( expectMap( - node2.expectMultiLogLoaded(map.multiLog.id).getCurrentContent() + node2.expectCoValueLoaded(map.coValue.id).getCurrentContent() ).get("hello") ).toEqual("world"); }); -test("When loading a multilog on one node, the server node it is requested from replies with all the necessary depended on multilogs to make it work", async () => { +test("When loading a coValue on one node, the server node it is requested from replies with all the necessary depended on coValues to make it work", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -798,11 +798,11 @@ test("When loading a multilog on one node, the server node it is requested from node1.addPeer(node2asPeer); node2.addPeer(node1asPeer); - await node2.loadMultiLog(map.multiLog.id); + await node2.loadCoValue(map.coValue.id); expect( expectMap( - node2.expectMultiLogLoaded(map.multiLog.id).getCurrentContent() + node2.expectCoValueLoaded(map.coValue.id).getCurrentContent() ).get("hello") ).toEqual("world"); }); diff --git a/src/sync.ts b/src/sync.ts index 276d84bbe..3a8bd02e3 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,8 +1,8 @@ import { Hash, Signature } from "./crypto"; -import { MultiLogHeader, MultiLogID, SessionID, Transaction } from "./multilog"; +import { CoValueHeader, RawCoValueID, SessionID, Transaction } from "./coValue"; -export type MultiLogKnownState = { - multilogID: MultiLogID; +export type CoValueKnownState = { + coValueID: RawCoValueID; header: boolean; sessions: { [sessionID: SessionID]: number }; }; @@ -16,19 +16,19 @@ export type SyncMessage = export type SubscribeMessage = { action: "subscribe"; - knownState: MultiLogKnownState; + knownState: CoValueKnownState; }; export type SubscribeResponseMessage = { action: "subscribeResponse"; - knownState: MultiLogKnownState; - asDependencyOf?: MultiLogID; + knownState: CoValueKnownState; + asDependencyOf?: RawCoValueID; }; export type NewContentMessage = { action: "newContent"; - multilogID: MultiLogID; - header?: MultiLogHeader; + coValueID: RawCoValueID; + header?: CoValueHeader; newContent: { [sessionID: SessionID]: SessionNewContent; }; @@ -44,12 +44,12 @@ export type SessionNewContent = { export type WrongAssumedKnownStateMessage = { action: "wrongAssumedKnownState"; - knownState: MultiLogKnownState; + knownState: CoValueKnownState; }; export type UnsubscribeMessage = { action: "unsubscribe"; - multilogID: MultiLogID; + coValueID: RawCoValueID; }; export type PeerID = string; @@ -63,15 +63,15 @@ export interface Peer { export interface PeerState { id: PeerID; - optimisticKnownStates: { [multilogID: MultiLogID]: MultiLogKnownState }; + optimisticKnownStates: { [coValueID: RawCoValueID]: CoValueKnownState }; incoming: ReadableStream; outgoing: WritableStreamDefaultWriter; role: "peer" | "server" | "client"; } export function weAreStrictlyAhead( - ourKnownState: MultiLogKnownState, - theirKnownState: MultiLogKnownState + ourKnownState: CoValueKnownState, + theirKnownState: CoValueKnownState ): boolean { if (theirKnownState.header && !ourKnownState.header) { return false; @@ -94,8 +94,8 @@ export function weAreStrictlyAhead( return true; } -export function combinedKnownStates(stateA: MultiLogKnownState, stateB: MultiLogKnownState): MultiLogKnownState { - const sessionStates: MultiLogKnownState["sessions"] = {}; +export function combinedKnownStates(stateA: CoValueKnownState, stateB: CoValueKnownState): CoValueKnownState { + const sessionStates: CoValueKnownState["sessions"] = {}; const allSessions = new Set([...Object.keys(stateA.sessions), ...Object.keys(stateB.sessions)] as SessionID[]); @@ -107,7 +107,7 @@ export function combinedKnownStates(stateA: MultiLogKnownState, stateB: MultiLog } return { - multilogID: stateA.multilogID, + coValueID: stateA.coValueID, header: stateA.header || stateB.header, sessions: sessionStates, }; From f07ec976c96063e0015d6dec891a0f1421bc2963 Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 7 Aug 2023 16:59:49 +0100 Subject: [PATCH 26/47] Improve tests and ensure non-private sync through server works --- .eslintrc.cjs | 18 + bun.lockb | Bin 3145 -> 0 bytes package.json | 47 +- src/coValue.test.ts | 1 - src/coValue.ts | 37 +- src/contentType.test.ts | 7 +- src/contentType.ts | 4 +- src/crypto.test.ts | 5 +- src/crypto.ts | 6 +- src/node.ts | 331 +---- src/permissions.test.ts | 1 - src/permissions.ts | 8 +- src/sync.test.ts | 611 ++++++--- src/sync.ts | 422 +++++- tsconfig.json | 10 +- yarn.lock | 2781 +++++++++++++++++++++++++++++++++++++++ 16 files changed, 3687 insertions(+), 602 deletions(-) create mode 100644 .eslintrc.cjs delete mode 100755 bun.lockb create mode 100644 yarn.lock diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 000000000..132650d83 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + ], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + parserOptions: { + project: './tsconfig.json', + }, + root: true, + rules: { + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], + "@typescript-eslint/no-floating-promises": "error", + }, + + }; \ No newline at end of file diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index e498e2f15b9c847f2b245b68f0fa209587c189da..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3145 zcmd^Bdr(wm6u-a%ipirjA0VB&4t$$LSyJftf~N*g3oRvz0;IoThQ6Gjr#B=brEP z`_6a1*NF^g%6N_`GME|GTrMjrGyB1(Fj+LkT7!vIXf0fcT2PcL{X8j(I@0djKdGuF zkN#z-D}Phx!c{?=mD7#$7R=ALJ=VFcXUgFT&9zExUGm<9BDQbdo~j>pXX_rl8sPzWlr=d6p4cXK+q-fv(nf##u1*7U z%bV)IHO=DsAG3f`2;UNj18rA7m}3gTAAw#^0UojB8xtrx3I00RkShn?OXT<`cp3&J z_y>vK0H72YAIC&I#^8>S_-*3&i1C3B*4k~s%|kZOXd2@gx$}_x!EnO?!$|lqiwEW8 zb)}YUolRvJ`G;f+qhl!tGrz|W71oJF~+dK zp~kjX12R`P?p|hBZn+-P9|kcJUXo`FTcVThXpy&QJWp?|%v|MzO(Q2AWQF^wy5VNdclUQEEitab(REuvVT*X z^2>{9&*)Apn44&q9=D!5EKRE&u77WLiI4Y^bG_QL(ktfhgAJCWD+X%6+K~NiMov;t z=7sRq{G2b|pXtIY!9Yckoc~TQ$tn!|W^TRycaLq&lGs@%y-pmQM0tm0$ z+SmL_T(*5+dD`ofjcwV|g#5tj$nom=_Mqy7Lw_I*$%8i`1ubD(7fLKws%;Zb&i?xR zM8$r_Uv_muZSUiK#&yqiue@|4{E^^ep%jyyH0#(IkIiB6XNq@61h-1N{A*fky92K6 zbm7H4OGNT>awQ$wQ*py`J7e+8IWwZ`g$YaSRcvQide8BxA;B4!H`<~i%qfR$JGSQj zSu^#w=T9|UZfNN%+|&Q&%;?;lC?pzj|KNQ@C3QQ_men^P%L@{;G=^6FC;!OA+|;gn z*iFd)vD<#}u4)stUoy{WXoHy-)Fu;6UR?SGj%8D2F-jS4sAMf#xonP1Z8GT07A?Mg zD8sj%f^FP~{UYHw@zV24E{lyx1>7hMxK?;K;5o)Khvx%1;zjIYH#nd>g%KbQ@yLUG z$cy~gt|NT#SfltQ=f}joTow;Ai@Ot->h77r{H#q;R{89;Y*C=c(kE#ptpp^X@x>=E z_&86KT3~sBqDW0Z-gr-srAk^w$5K={V6b{1RRDNY-~l#LSCBfv;faNFg|DHb@*tIj zhyhOqc(67hwFY?LB#zDo!y_5BQ=B)^cP}>2mXxy`oxVa~IkVc7D{uz0E>))bJ2mll zQqv(awTQJ9tS)3xb&n(p6{Re%;S8k$mT?a2X|tus#4;K~sh;I{VwG{_EHU(IUJnMZ zDdSkCNX@faH7_WzFjK(h1TZL!(4ei*3qmQM$S@qMGr-RZ+FV*<P$Fdoh^l9 acisnlXW4L`WC;NoN2_8H3VZ9Jl)nJ%=VM?1 diff --git a/package.json b/package.json index 45c1931e1..316b1385d 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,30 @@ { - "name": "cojson", - "module": "src/index.ts", - "type": "module", - "license": "MIT", - "devDependencies": { - "bun-types": "latest" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "dependencies": { - "@noble/ciphers": "^0.1.3", - "@noble/curves": "^1.1.0", - "@noble/hashes": "^1.3.1", - "@scure/base": "^1.1.1", - "fast-json-stable-stringify": "^2.1.0" - } -} \ No newline at end of file + "name": "cojson", + "module": "src/index.ts", + "type": "module", + "license": "MIT", + "devDependencies": { + "@types/jest": "^29.5.3", + "@typescript-eslint/eslint-plugin": "^6.2.1", + "@typescript-eslint/parser": "^6.2.1", + "eslint": "^8.46.0", + "jest": "^29.6.2", + "ts-jest": "^29.1.1", + "typescript": "5.0.2" + }, + "dependencies": { + "@noble/ciphers": "^0.1.3", + "@noble/curves": "^1.1.0", + "@noble/hashes": "^1.3.1", + "@scure/base": "^1.1.1", + "fast-json-stable-stringify": "^2.1.0", + "isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae" + }, + "scripts": { + "test": "jest" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node" + } +} diff --git a/src/coValue.test.ts b/src/coValue.test.ts index f1af174cf..fc78fba8c 100644 --- a/src/coValue.test.ts +++ b/src/coValue.test.ts @@ -1,4 +1,3 @@ -import { expect, test } from "bun:test"; import { CoValue, Transaction, diff --git a/src/coValue.ts b/src/coValue.ts index 6bb8e06a4..a6edc8753 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -187,8 +187,6 @@ export class CoValue { this.content = undefined; - this.node.syncCoValue(this); - const _ = this.getCurrentContent(); return true; @@ -252,12 +250,18 @@ export class CoValue { expectedNewHash ); - return this.tryAddTransactions( + const success = this.tryAddTransactions( sessionID, [transaction], expectedNewHash, signature ); + + if (success) { + void this.node.sync.syncCoValue(this); + } + + return success; } getCurrentContent(): ContentType { @@ -376,7 +380,8 @@ export class CoValue { // Try to find indirect revelation through previousKeys for (const entry of readKeyHistory) { - if (entry.value?.previousKeys?.[keyID]) { + const encryptedPreviousKey = entry.value?.previousKeys?.[keyID]; + if (entry.value && encryptedPreviousKey) { const sealingKeyID = entry.value.keyID; const sealingKeySecret = this.getReadKey(sealingKeyID); @@ -388,7 +393,7 @@ export class CoValue { { sealed: keyID, sealing: sealingKeyID, - encrypted: entry.value.previousKeys[keyID], + encrypted: encryptedPreviousKey, }, sealingKeySecret ); @@ -424,7 +429,9 @@ export class CoValue { return this.sessions[txID.sessionID]?.transactions[txID.txIndex]; } - newContentSince(knownState: CoValueKnownState | undefined): NewContentMessage | undefined { + newContentSince( + knownState: CoValueKnownState | undefined + ): NewContentMessage | undefined { const newContent: NewContentMessage = { action: "newContent", coValueID: this.id, @@ -459,14 +466,28 @@ export class CoValue { }) .filter((x): x is Exclude => !!x) ), - } + }; - if (!newContent.header && Object.keys(newContent.newContent).length === 0) { + if ( + !newContent.header && + Object.keys(newContent.newContent).length === 0 + ) { return undefined; } return newContent; } + + getDependedOnCoValues(): RawCoValueID[] { + return this.header.ruleset.type === "team" + ? expectTeamContent(this.getCurrentContent()) + .keys() + .filter((k): k is AgentID => k.startsWith("agent_")) + .map((agent) => agentIDAsCoValueID(agent)) + : this.header.ruleset.type === "ownedByTeam" + ? [this.header.ruleset.team] + : []; + } } export type AgentID = `agent_${string}`; diff --git a/src/contentType.test.ts b/src/contentType.test.ts index b0adfabf2..c1f313a89 100644 --- a/src/contentType.test.ts +++ b/src/contentType.test.ts @@ -1,4 +1,3 @@ -import { test, expect } from "bun:test"; import { agentIDfromSessionID, getAgent, @@ -87,13 +86,13 @@ test("Can get map entry values at different points in time", () => { content.edit((editable) => { const beforeA = Date.now(); - Bun.sleepSync(1); + while(Date.now() < beforeA + 10){} editable.set("hello", "A", "trusting"); const beforeB = Date.now(); - Bun.sleepSync(1); + while(Date.now() < beforeB + 10){} editable.set("hello", "B", "trusting"); const beforeC = Date.now(); - Bun.sleepSync(1); + while(Date.now() < beforeC + 10){} editable.set("hello", "C", "trusting"); expect(editable.get("hello")).toEqual("C"); expect(editable.getAtTime("hello", Date.now())).toEqual("C"); diff --git a/src/contentType.ts b/src/contentType.ts index 4331d9975..ac63c6d64 100644 --- a/src/contentType.ts +++ b/src/contentType.ts @@ -82,7 +82,7 @@ export class CoMap< return undefined; } - let lastEntry = ops[ops.length - 1]; + const lastEntry = ops[ops.length - 1]!; if (lastEntry.op === "delete") { return undefined; @@ -116,7 +116,7 @@ export class CoMap< return undefined; } - const lastEntry = ops[ops.length - 1]; + const lastEntry = ops[ops.length - 1]!; return lastEntry.txID; } diff --git a/src/crypto.test.ts b/src/crypto.test.ts index fc4e0b1a2..3226ff6b0 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -1,4 +1,3 @@ -import { expect, test } from "bun:test"; import { getRecipientID, getSignatoryID, @@ -18,7 +17,7 @@ import { } from "./crypto"; import { base58, base64url } from "@scure/base"; import { x25519 } from "@noble/curves/ed25519"; -import { xsalsa20_poly1305 } from "@noble/ciphers/_slow"; +import { xsalsa20_poly1305 } from "@noble/ciphers/salsa"; import { blake3 } from "@noble/hashes/blake3"; import stableStringify from "fast-json-stable-stringify"; @@ -84,7 +83,7 @@ test("Sealing round-trips, but invalid receiver can't unseal", () => { getRecipientID(sender).substring("recipient_z".length) ); const sealedBytes = base64url.decode( - sealed[getRecipientID(recipient1)].substring("sealed_U".length) + sealed[getRecipientID(recipient1)]!.substring("sealed_U".length) ); const sharedSecret = x25519.getSharedSecret(recipient3priv, senderPub); diff --git a/src/crypto.ts b/src/crypto.ts index e1c7820b9..a7a935049 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -2,7 +2,7 @@ import { ed25519, x25519 } from "@noble/curves/ed25519"; import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa"; import { JsonValue } from "./jsonValue"; import { base58, base64url } from "@scure/base"; -import stableStringify from "fast-json-stable-stringify"; +import { default as stableStringify } from "fast-json-stable-stringify"; import { blake3 } from "@noble/hashes/blake3"; import { randomBytes } from "@noble/ciphers/webcrypto/utils"; import { RawCoValueID, SessionID, TransactionID } from "./coValue"; @@ -91,10 +91,10 @@ export function seal( const sealedSet: SealedSet = {}; for (let i = 0; i < recipientsSorted.length; i++) { - const recipient = recipientsSorted[i]; + const recipient = recipientsSorted[i]!; const sharedSecret = x25519.getSharedSecret( senderPriv, - recipientPubs[i] + recipientPubs[i]! ); const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt( diff --git a/src/node.ts b/src/node.ts index 7acf2db6c..df50bd0f9 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,4 +1,3 @@ -import { CoMap } from "./contentType"; import { newRandomKeySecret, seal } from "./crypto"; import { RawCoValueID, @@ -12,31 +11,17 @@ import { getAgentCoValueHeader, CoValueHeader, agentIDfromSessionID, - agentIDAsCoValueID, } from "./coValue"; import { Team, expectTeamContent } from "./permissions"; -import { - NewContentMessage, - Peer, - PeerID, - PeerState, - SessionNewContent, - SubscribeMessage, - SubscribeResponseMessage, - SyncMessage, - UnsubscribeMessage, - WrongAssumedKnownStateMessage, - combinedKnownStates, - weAreStrictlyAhead, -} from "./sync"; +import { SyncManager } from "./sync"; export class LocalNode { coValues: { [key: RawCoValueID]: CoValueState } = {}; - peers: { [key: PeerID]: PeerState } = {}; agentCredential: AgentCredential; agentID: AgentID; ownSessionID: SessionID; knownAgents: { [key: AgentID]: Agent } = {}; + sync = new SyncManager(this); constructor(agentCredential: AgentCredential, ownSessionID: SessionID) { this.agentCredential = agentCredential; @@ -57,7 +42,7 @@ export class LocalNode { const coValue = new CoValue(header, this); this.coValues[coValue.id] = { state: "loaded", coValue: coValue }; - this.syncCoValue(coValue); + void this.sync.syncCoValue(coValue); return coValue; } @@ -69,20 +54,7 @@ export class LocalNode { this.coValues[id] = entry; - for (const peer of Object.values(this.peers)) { - peer.outgoing - .write({ - action: "subscribe", - knownState: { - coValueID: id, - header: false, - sessions: {}, - }, - }) - .catch((e) => { - console.error("Error writing to peer", e); - }); - } + this.sync.loadFromPeers(id); } if (entry.state === "loaded") { return Promise.resolve(entry.coValue); @@ -145,299 +117,6 @@ export class LocalNode { return new Team(teamContent, this); } - addPeer(peer: Peer) { - const peerState: PeerState = { - id: peer.id, - optimisticKnownStates: {}, - incoming: peer.incoming, - outgoing: peer.outgoing.getWriter(), - role: peer.role, - }; - this.peers[peer.id] = peerState; - - if (peer.role === "server") { - for (const entry of Object.values(this.coValues)) { - if (entry.state === "loading") { - continue; - } - - peerState.outgoing - .write({ - action: "subscribe", - knownState: entry.coValue.knownState(), - }) - .catch((e) => { - // TODO: handle error - console.error("Error writing to peer", e); - }); - - peerState.optimisticKnownStates[entry.coValue.id] = { - coValueID: entry.coValue.id, - header: false, - sessions: {}, - }; - } - } - - const readIncoming = async () => { - for await (const msg of peerState.incoming) { - for (const responseMsg of this.handleSyncMessage( - msg, - peerState - )) { - await peerState.outgoing.write(responseMsg); - } - } - }; - - readIncoming().catch((e) => { - // TODO: handle error - console.error("Error reading from peer", e); - }); - } - - handleSyncMessage(msg: SyncMessage, peer: PeerState): SyncMessage[] { - // TODO: validate - switch (msg.action) { - case "subscribe": - return this.handleSubscribe(msg, peer); - case "subscribeResponse": - return this.handleSubscribeResponse(msg, peer); - case "newContent": - return this.handleNewContent(msg); - case "wrongAssumedKnownState": - return this.handleWrongAssumedKnownState(msg, peer); - case "unsubscribe": - return this.handleUnsubscribe(msg); - default: - throw new Error(`Unknown message type ${(msg as any).action}`); - } - } - - handleSubscribe( - msg: SubscribeMessage, - peer: PeerState, - asDependencyOf?: RawCoValueID - ): SyncMessage[] { - const entry = this.coValues[msg.knownState.coValueID]; - - if (!entry || entry.state === "loading") { - if (!entry) { - this.coValues[msg.knownState.coValueID] = newLoadingState(); - } - - return [ - { - action: "subscribeResponse", - knownState: { - coValueID: msg.knownState.coValueID, - header: false, - sessions: {}, - }, - }, - ]; - } - - peer.optimisticKnownStates[entry.coValue.id] = - entry.coValue.knownState(); - - const newContent = entry.coValue.newContentSince(msg.knownState); - - const dependedOnCoValues = - entry.coValue.header.ruleset.type === "team" - ? expectTeamContent(entry.coValue.getCurrentContent()) - .keys() - .filter((k): k is AgentID => k.startsWith("agent_")) - .map((agent) => agentIDAsCoValueID(agent)) - : entry.coValue.header.ruleset.type === "ownedByTeam" - ? [entry.coValue.header.ruleset.team] - : []; - - return [ - ...dependedOnCoValues.flatMap((coValueID) => - this.handleSubscribe( - { - action: "subscribe", - knownState: { - coValueID, - header: false, - sessions: {}, - }, - }, - peer, - asDependencyOf || msg.knownState.coValueID - ) - ), - { - action: "subscribeResponse", - knownState: entry.coValue.knownState(), - asDependencyOf, - }, - ...(newContent ? [newContent] : []), - ]; - } - - handleSubscribeResponse( - msg: SubscribeResponseMessage, - peer: PeerState - ): SyncMessage[] { - let entry = this.coValues[msg.knownState.coValueID]; - - if (!entry) { - if (msg.asDependencyOf) { - if (this.coValues[msg.asDependencyOf]) { - entry = newLoadingState(); - - this.coValues[msg.knownState.coValueID] = entry; - } - } else { - throw new Error( - "Expected coValue entry to be created, missing subscribe?" - ); - } - } - - if (entry.state === "loading") { - peer.optimisticKnownStates[msg.knownState.coValueID] = - msg.knownState; - return []; - } - - const newContent = entry.coValue.newContentSince(msg.knownState); - peer.optimisticKnownStates[msg.knownState.coValueID] = - combinedKnownStates(msg.knownState, entry.coValue.knownState()); - - return newContent ? [newContent] : []; - } - - handleNewContent(msg: NewContentMessage): SyncMessage[] { - let entry = this.coValues[msg.coValueID]; - - if (!entry) { - throw new Error( - "Expected coValue entry to be created, missing subscribe?" - ); - } - - let resolveAfterDone: ((coValue: CoValue) => void) | undefined; - - if (entry.state === "loading") { - if (!msg.header) { - throw new Error("Expected header to be sent in first message"); - } - - const coValue = new CoValue(msg.header, this); - - resolveAfterDone = entry.resolve; - - entry = { - state: "loaded", - coValue: coValue, - }; - - this.coValues[msg.coValueID] = entry; - } - - const coValue = entry.coValue; - - let invalidStateAssumed = false; - - for (const sessionID of Object.keys(msg.newContent) as SessionID[]) { - const ourKnownTxIdx = - coValue.sessions[sessionID]?.transactions.length; - const theirFirstNewTxIdx = msg.newContent[sessionID].after; - - if ((ourKnownTxIdx || 0) < theirFirstNewTxIdx) { - invalidStateAssumed = true; - continue; - } - - const alreadyKnownOffset = ourKnownTxIdx - ? ourKnownTxIdx - theirFirstNewTxIdx - : 0; - - const newTransactions = - msg.newContent[sessionID].newTransactions.slice( - alreadyKnownOffset - ); - - const success = coValue.tryAddTransactions( - sessionID, - newTransactions, - msg.newContent[sessionID].lastHash, - msg.newContent[sessionID].lastSignature - ); - - if (!success) { - console.error("Failed to add transactions", newTransactions); - continue; - } - } - - if (resolveAfterDone) { - resolveAfterDone(coValue); - } - - return invalidStateAssumed - ? [ - { - action: "wrongAssumedKnownState", - knownState: coValue.knownState(), - }, - ] - : []; - } - - handleWrongAssumedKnownState( - msg: WrongAssumedKnownStateMessage, - peer: PeerState - ): SyncMessage[] { - const coValue = this.expectCoValueLoaded(msg.knownState.coValueID); - - peer.optimisticKnownStates[msg.knownState.coValueID] = - combinedKnownStates(msg.knownState, coValue.knownState()); - - const newContent = coValue.newContentSince(msg.knownState); - - return newContent ? [newContent] : []; - } - - handleUnsubscribe(msg: UnsubscribeMessage): SyncMessage[] { - throw new Error("Method not implemented."); - } - - async syncCoValue(coValue: CoValue) { - for (const peer of Object.values(this.peers)) { - const optimisticKnownState = - peer.optimisticKnownStates[coValue.id]; - - if (optimisticKnownState || peer.role === "server") { - const newContent = - coValue.newContentSince(optimisticKnownState); - - peer.optimisticKnownStates[coValue.id] = peer - .optimisticKnownStates[coValue.id] - ? combinedKnownStates( - peer.optimisticKnownStates[coValue.id], - coValue.knownState() - ) - : coValue.knownState(); - - if (!optimisticKnownState && peer.role === "server") { - // auto-subscribe - await peer.outgoing.write({ - action: "subscribe", - knownState: coValue.knownState(), - }); - } - - if (newContent) { - await peer.outgoing.write(newContent); - } - } - } - } - testWithDifferentCredentials( agentCredential: AgentCredential, ownSessionID: SessionID @@ -480,7 +159,7 @@ type CoValueState = } | { state: "loaded"; coValue: CoValue }; -function newLoadingState(): CoValueState { +export function newLoadingState(): CoValueState { let resolve: (coValue: CoValue) => void; const promise = new Promise((r) => { diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 3d26fb40c..270c267b0 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -1,4 +1,3 @@ -import { test, expect } from "bun:test"; import { getAgent, getAgentID, diff --git a/src/permissions.ts b/src/permissions.ts index 224d568cd..8f0a0d0bc 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -290,7 +290,13 @@ export class Team { this.teamMap.coValue.node.agentCredential.recipientSecret, new Set( currentlyPermittedReaders.map( - (reader) => this.node.knownAgents[reader].recipientID + (reader) => { + const readerAgent = this.node.knownAgents[reader]; + if (!readerAgent) { + throw new Error("Unknown agent " + reader); + } + return readerAgent.recipientID + } ) ), { diff --git a/src/sync.test.ts b/src/sync.test.ts index 2698d1224..4bd41715d 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -1,13 +1,20 @@ -import { test, expect } from "bun:test"; import { + AgentID, + agentIDAsCoValueID, getAgent, getAgentID, newRandomAgentCredential, newRandomSessionID, } from "./coValue"; import { LocalNode } from "./node"; -import { Peer, SyncMessage } from "./sync"; +import { Peer, PeerID, SyncMessage } from "./sync"; import { MapOpPayload, expectMap } from "./contentType"; +import { Team } from "./permissions"; +import { + ReadableStream, + WritableStream, + TransformStream, +} from "isomorphic-streams"; test( "Node replies with initial tx and header to empty subscribe", @@ -28,7 +35,7 @@ test( const [inRx, inTx] = newStreamPair(); const [outRx, outTx] = newStreamPair(); - node.addPeer({ + node.sync.addPeer({ id: "test", incoming: inRx, outgoing: outTx, @@ -39,27 +46,25 @@ test( await writer.write({ action: "subscribe", - knownState: { - coValueID: map.coValue.id, - header: false, - sessions: {}, - }, + coValueID: map.coValue.id, + header: false, + sessions: {}, }); const reader = outRx.getReader(); - const _adminSubscribeResponseMsg = await reader.read(); - const _adminNewContentMsg = await reader.read(); - const _teamSubscribeResponseMsg = await reader.read(); - const _teamNewContentMsg = await reader.read(); + expect((await reader.read()).value).toMatchObject(admStateEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamStateEx(team)); - const subscribeResponseMsg = await reader.read(); - - expect(subscribeResponseMsg.value).toEqual({ - action: "subscribeResponse", - knownState: map.coValue.knownState(), + const mapTellKnownStateMsg = await reader.read(); + expect(mapTellKnownStateMsg.value).toEqual({ + action: "tellKnownState", + ...map.coValue.knownState(), } satisfies SyncMessage); + expect((await reader.read()).value).toMatchObject(admContEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamContentEx(team)); + const newContentMsg = await reader.read(); expect(newContentMsg.value).toEqual({ @@ -76,8 +81,8 @@ test( newTransactions: [ { privacy: "trusting", - madeAt: map.coValue.sessions[node.ownSessionID] - .transactions[0].madeAt, + madeAt: map.coValue.sessions[node.ownSessionID]! + .transactions[0]!.madeAt, changes: [ { op: "insert", @@ -88,14 +93,13 @@ test( }, ], lastHash: - map.coValue.sessions[node.ownSessionID].lastHash!, + map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: - map.coValue.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, }, } satisfies SyncMessage); }, - { timeout: 100 } ); test("Node replies with only new tx to subscribe with some known state", async () => { @@ -116,7 +120,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( const [inRx, inTx] = newStreamPair(); const [outRx, outTx] = newStreamPair(); - node.addPeer({ + node.sync.addPeer({ id: "test", incoming: inRx, outgoing: outTx, @@ -127,29 +131,27 @@ test("Node replies with only new tx to subscribe with some known state", async ( await writer.write({ action: "subscribe", - knownState: { - coValueID: map.coValue.id, - header: true, - sessions: { - [node.ownSessionID]: 1, - }, + coValueID: map.coValue.id, + header: true, + sessions: { + [node.ownSessionID]: 1, }, }); const reader = outRx.getReader(); - const _adminSubscribeResponseMsg = await reader.read(); - const _adminNewContentMsg = await reader.read(); - const _teamSubscribeResponseMsg = await reader.read(); - const _teamNewContentMsg = await reader.read(); + expect((await reader.read()).value).toMatchObject(admStateEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamStateEx(team)); - const mapSubscribeResponseMsg = await reader.read(); - - expect(mapSubscribeResponseMsg.value).toEqual({ - action: "subscribeResponse", - knownState: map.coValue.knownState(), + const mapTellKnownStateMsg = await reader.read(); + expect(mapTellKnownStateMsg.value).toEqual({ + action: "tellKnownState", + ...map.coValue.knownState(), } satisfies SyncMessage); + expect((await reader.read()).value).toMatchObject(admContEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamContentEx(team)); + const mapNewContentMsg = await reader.read(); expect(mapNewContentMsg.value).toEqual({ @@ -162,8 +164,8 @@ test("Node replies with only new tx to subscribe with some known state", async ( newTransactions: [ { privacy: "trusting", - madeAt: map.coValue.sessions[node.ownSessionID] - .transactions[1].madeAt, + madeAt: map.coValue.sessions[node.ownSessionID]! + .transactions[1]!.madeAt, changes: [ { op: "insert", @@ -173,15 +175,17 @@ test("Node replies with only new tx to subscribe with some known state", async ( ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: - map.coValue.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, }, } satisfies SyncMessage); }); -test.skip("TODO: node only replies with new tx to subscribe with some known state, even in the depended on coValues", () => {}); +test.todo( + "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(); @@ -196,7 +200,7 @@ test("After subscribing, node sends own known state and new txs to peer", async const [inRx, inTx] = newStreamPair(); const [outRx, outTx] = newStreamPair(); - node.addPeer({ + node.sync.addPeer({ id: "test", incoming: inRx, outgoing: outTx, @@ -207,29 +211,27 @@ test("After subscribing, node sends own known state and new txs to peer", async await writer.write({ action: "subscribe", - knownState: { - coValueID: map.coValue.id, - header: false, - sessions: { - [node.ownSessionID]: 0, - }, + coValueID: map.coValue.id, + header: false, + sessions: { + [node.ownSessionID]: 0, }, }); const reader = outRx.getReader(); - const _adminSubscribeResponseMsg = await reader.read(); - const _adminNewContentMsg = await reader.read(); - const _teamSubscribeResponseMsg = await reader.read(); - const _teamNewContentMsg = await reader.read(); + expect((await reader.read()).value).toMatchObject(admStateEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamStateEx(team)); - const mapSubscribeResponseMsg = await reader.read(); - - expect(mapSubscribeResponseMsg.value).toEqual({ - action: "subscribeResponse", - knownState: map.coValue.knownState(), + const mapTellKnownStateMsg = await reader.read(); + expect(mapTellKnownStateMsg.value).toEqual({ + action: "tellKnownState", + ...map.coValue.knownState(), } satisfies SyncMessage); + expect((await reader.read()).value).toMatchObject(admContEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamContentEx(team)); + const mapNewContentHeaderOnlyMsg = await reader.read(); expect(mapNewContentHeaderOnlyMsg.value).toEqual({ @@ -254,8 +256,8 @@ test("After subscribing, node sends own known state and new txs to peer", async newTransactions: [ { privacy: "trusting", - madeAt: map.coValue.sessions[node.ownSessionID] - .transactions[0].madeAt, + madeAt: map.coValue.sessions[node.ownSessionID]! + .transactions[0]!.madeAt, changes: [ { op: "insert", @@ -265,9 +267,9 @@ test("After subscribing, node sends own known state and new txs to peer", async ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: - map.coValue.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, }, } satisfies SyncMessage); @@ -287,8 +289,8 @@ test("After subscribing, node sends own known state and new txs to peer", async newTransactions: [ { privacy: "trusting", - madeAt: map.coValue.sessions[node.ownSessionID] - .transactions[1].madeAt, + madeAt: map.coValue.sessions[node.ownSessionID]! + .transactions[1]!.madeAt, changes: [ { op: "insert", @@ -298,15 +300,15 @@ test("After subscribing, node sends own known state and new txs to peer", async ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: - map.coValue.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, }, } satisfies SyncMessage); }); -test("Client replies with known new content to subscribeResponse from server", async () => { +test("Client replies with known new content to tellKnownState from server", async () => { const admin = newRandomAgentCredential(); const adminID = getAgentID(getAgent(admin)); @@ -323,31 +325,43 @@ test("Client replies with known new content to subscribeResponse from server", a const [inRx, inTx] = newStreamPair(); const [outRx, outTx] = newStreamPair(); - node.addPeer({ + node.sync.addPeer({ id: "test", incoming: inRx, outgoing: outTx, role: "peer", }); + const reader = outRx.getReader(); + + // expect((await reader.read()).value).toMatchObject(teamStateEx(team)); + const writer = inTx.getWriter(); await writer.write({ - action: "subscribeResponse", - knownState: { - coValueID: map.coValue.id, - header: false, - sessions: { - [node.ownSessionID]: 0, - }, + action: "tellKnownState", + coValueID: map.coValue.id, + header: false, + sessions: { + [node.ownSessionID]: 0, }, }); - const reader = outRx.getReader(); + expect((await reader.read()).value).toMatchObject(admStateEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamStateEx(team)); - const msg1 = await reader.read(); + const mapTellKnownStateMsg = await reader.read(); + expect(mapTellKnownStateMsg.value).toEqual({ + action: "tellKnownState", + ...map.coValue.knownState(), + } satisfies SyncMessage); - expect(msg1.value).toEqual({ + expect((await reader.read()).value).toMatchObject(admContEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamContentEx(team)); + + const mapNewContentMsg = await reader.read(); + + expect(mapNewContentMsg.value).toEqual({ action: "newContent", coValueID: map.coValue.id, header: map.coValue.header, @@ -357,8 +371,8 @@ test("Client replies with known new content to subscribeResponse from server", a newTransactions: [ { privacy: "trusting", - madeAt: map.coValue.sessions[node.ownSessionID] - .transactions[0].madeAt, + madeAt: map.coValue.sessions[node.ownSessionID]! + .transactions[0]!.madeAt, changes: [ { op: "insert", @@ -368,9 +382,9 @@ test("Client replies with known new content to subscribeResponse from server", a ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: - map.coValue.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, }, } satisfies SyncMessage); @@ -389,7 +403,7 @@ test("No matter the optimistic known state, node respects invalid known state me const [inRx, inTx] = newStreamPair(); const [outRx, outTx] = newStreamPair(); - node.addPeer({ + node.sync.addPeer({ id: "test", incoming: inRx, outgoing: outTx, @@ -400,23 +414,35 @@ test("No matter the optimistic known state, node respects invalid known state me await writer.write({ action: "subscribe", - knownState: { - coValueID: map.coValue.id, - header: false, - sessions: { - [node.ownSessionID]: 0, - }, + coValueID: map.coValue.id, + header: false, + sessions: { + [node.ownSessionID]: 0, }, }); const reader = outRx.getReader(); - const _adminSubscribeResponseMsg = await reader.read(); - const _adminNewContentMsg = await reader.read(); - const _teamSubscribeResponseMsg = await reader.read(); - const _teamNewContentMsg = await reader.read(); - const _mapSubscribeResponseMsg = await reader.read(); - const _mapNewContentHeaderOnlyMsg = await reader.read(); + 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); + + expect((await reader.read()).value).toMatchObject(admContEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamContentEx(team)); + + const mapNewContentHeaderOnlyMsg = await reader.read(); + + expect(mapNewContentHeaderOnlyMsg.value).toEqual({ + action: "newContent", + coValueID: map.coValue.id, + header: map.coValue.header, + newContent: {}, + } satisfies SyncMessage); map.edit((editable) => { editable.set("hello", "world", "trusting"); @@ -431,12 +457,10 @@ test("No matter the optimistic known state, node respects invalid known state me await writer.write({ action: "wrongAssumedKnownState", - knownState: { - coValueID: map.coValue.id, - header: true, - sessions: { - [node.ownSessionID]: 1, - }, + coValueID: map.coValue.id, + header: true, + sessions: { + [node.ownSessionID]: 1, }, } satisfies SyncMessage); @@ -452,8 +476,8 @@ test("No matter the optimistic known state, node respects invalid known state me newTransactions: [ { privacy: "trusting", - madeAt: map.coValue.sessions[node.ownSessionID] - .transactions[1].madeAt, + madeAt: map.coValue.sessions[node.ownSessionID]! + .transactions[1]!.madeAt, changes: [ { op: "insert", @@ -463,9 +487,9 @@ test("No matter the optimistic known state, node respects invalid known state me ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: - map.coValue.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, }, } satisfies SyncMessage); @@ -481,10 +505,10 @@ test("If we add a peer, but it never subscribes to a coValue, it won't get any m const map = team.createMap(); - const [inRx, inTx] = newStreamPair(); + const [inRx, _inTx] = newStreamPair(); const [outRx, outTx] = newStreamPair(); - node.addPeer({ + node.sync.addPeer({ id: "test", incoming: inRx, outgoing: outTx, @@ -497,7 +521,7 @@ test("If we add a peer, but it never subscribes to a coValue, it won't get any m const reader = outRx.getReader(); - await shouldNotResolve(reader.read(), { timeout: 50 }); + 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 () => { @@ -510,38 +534,45 @@ test("If we add a server peer, all updates to all coValues are sent to it, even const map = team.createMap(); - const [inRx, inTx] = newStreamPair(); + const [inRx, _inTx] = newStreamPair(); const [outRx, outTx] = newStreamPair(); - node.addPeer({ + node.sync.addPeer({ id: "test", incoming: inRx, outgoing: outTx, role: "server", }); + const reader = outRx.getReader(); + expect((await reader.read()).value).toMatchObject({ + action: "subscribe", + coValueID: agentIDAsCoValueID(adminID), + }); + expect((await reader.read()).value).toMatchObject({ + action: "subscribe", + coValueID: team.teamMap.coValue.id, + }); + + const mapSubscribeMsg = await reader.read(); + + expect(mapSubscribeMsg.value).toEqual({ + action: "subscribe", + coValueID: map.coValue.id, + header: true, + sessions: {}, + } satisfies SyncMessage); + map.edit((editable) => { editable.set("hello", "world", "trusting"); }); - const reader = outRx.getReader(); - const _adminSubscribeMsg = await reader.read(); - const _teamSubscribeMsg = await reader.read(); + expect((await reader.read()).value).toMatchObject(admContEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamContentEx(team)); - const subscribeMsg = await reader.read(); + const mapNewContentMsg = await reader.read(); - expect(subscribeMsg.value).toEqual({ - action: "subscribe", - knownState: { - coValueID: map.coValue.id, - header: true, - sessions: {}, - }, - } satisfies SyncMessage); - - const newContentMsg = await reader.read(); - - expect(newContentMsg.value).toEqual({ + expect(mapNewContentMsg.value).toEqual({ action: "newContent", coValueID: map.coValue.id, header: map.coValue.header, @@ -551,8 +582,8 @@ test("If we add a server peer, all updates to all coValues are sent to it, even newTransactions: [ { privacy: "trusting", - madeAt: map.coValue.sessions[node.ownSessionID] - .transactions[0].madeAt, + madeAt: map.coValue.sessions[node.ownSessionID]! + .transactions[0]!.madeAt, changes: [ { op: "insert", @@ -562,9 +593,9 @@ test("If we add a server peer, all updates to all coValues are sent to it, even ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID].lastHash!, + lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: - map.coValue.sessions[node.ownSessionID].lastSignature!, + map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, }, } satisfies SyncMessage); @@ -580,10 +611,10 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a team.createMap(); - const [inRx, inTx] = newStreamPair(); + const [inRx, _inTx] = newStreamPair(); const [outRx, outTx] = newStreamPair(); - node.addPeer({ + node.sync.addPeer({ id: "test", incoming: inRx, outgoing: outTx, @@ -591,21 +622,30 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a }); const reader = outRx.getReader(); - const _initialMsg1 = await reader.read(); - const _initialMsg2 = await reader.read(); + expect((await reader.read()).value).toMatchObject({ + action: "subscribe", + coValueID: agentIDAsCoValueID(adminID), + }); + expect((await reader.read()).value).toMatchObject({ + action: "subscribe", + coValueID: team.teamMap.coValue.id, + }); const map = team.createMap(); - const msg1 = await reader.read(); + const mapSubscribeMsg = await reader.read(); - expect(msg1.value).toEqual({ + expect(mapSubscribeMsg.value).toEqual({ action: "subscribe", - knownState: map.coValue.knownState(), + ...map.coValue.knownState(), } satisfies SyncMessage); - const msg2 = await reader.read(); + expect((await reader.read()).value).toMatchObject(admContEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamContentEx(team)); - expect(msg2.value).toEqual({ + const mapContentMsg = await reader.read(); + + expect(mapContentMsg.value).toEqual({ action: "newContent", coValueID: map.coValue.id, header: map.coValue.header, @@ -613,7 +653,9 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a } satisfies SyncMessage); }); -test.skip("TODO: when receiving a subscribe response that is behind our optimistic state (due to already sent content), we ignore it", () => {}); +test.todo( + "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(); @@ -625,10 +667,10 @@ test("When we connect a new server peer, we try to sync all existing coValues to const map = team.createMap(); - const [inRx, inTx] = newStreamPair(); + const [inRx, _inTx] = newStreamPair(); const [outRx, outTx] = newStreamPair(); - node.addPeer({ + node.sync.addPeer({ id: "test", incoming: inRx, outgoing: outTx, @@ -642,14 +684,14 @@ test("When we connect a new server peer, we try to sync all existing coValues to expect(teamSubscribeMessage.value).toEqual({ action: "subscribe", - knownState: team.teamMap.coValue.knownState(), + ...team.teamMap.coValue.knownState(), } satisfies SyncMessage); const secondMessage = await reader.read(); expect(secondMessage.value).toEqual({ action: "subscribe", - knownState: map.coValue.knownState(), + ...map.coValue.knownState(), } satisfies SyncMessage); }); @@ -666,7 +708,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe const [inRx, inTx] = newStreamPair(); const [outRx, outTx] = newStreamPair(); - node.addPeer({ + node.sync.addPeer({ id: "test", incoming: inRx, outgoing: outTx, @@ -677,30 +719,27 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe await writer.write({ action: "subscribe", - knownState: { - coValueID: map.coValue.id, - header: true, - sessions: { - [node.ownSessionID]: 1, - }, + coValueID: map.coValue.id, + header: true, + sessions: { + [node.ownSessionID]: 1, }, }); const reader = outRx.getReader(); - const _adminSubscribeResponseMsg = await reader.read(); - const _adminNewContentMsg = await reader.read(); - const _teamSubscribeResponseMsg = await reader.read(); - const _teamNewContentMsg = await reader.read(); - const mapSubscribeResponse = await reader.read(); + expect((await reader.read()).value).toMatchObject(admStateEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamStateEx(team)); + const mapTellKnownState = await reader.read(); - expect(mapSubscribeResponse.value).toEqual({ - action: "subscribeResponse", - knownState: map.coValue.knownState(), + expect(mapTellKnownState.value).toEqual({ + action: "tellKnownState", + ...map.coValue.knownState(), } satisfies SyncMessage); }); -test("When replaying creation and transactions of a coValue as new content, the receiving peer integrates this information", async () => { +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(); const adminID = getAgentID(getAgent(admin)); @@ -711,63 +750,106 @@ test("When replaying creation and transactions of a coValue as new content, the const [inRx1, inTx1] = newStreamPair(); const [outRx1, outTx1] = newStreamPair(); - node1.addPeer({ + node1.sync.addPeer({ id: "test2", incoming: inRx1, outgoing: outTx1, role: "server", }); - const reader1 = outRx1.getReader(); - - const _adminSubscriptionMsg = await reader1.read(); - const teamSubscribeMsg = await reader1.read(); - - const map = team.createMap(); - - const mapSubscriptionMsg = await reader1.read(); - const mapNewContentMsg = await reader1.read(); - - map.edit((editable) => { - editable.set("hello", "world", "trusting"); - }); - - const mapEditMsg = await reader1.read(); + const to1 = inTx1.getWriter(); + const from1 = outRx1.getReader(); const node2 = new LocalNode(admin, newRandomSessionID(adminID)); const [inRx2, inTx2] = newStreamPair(); const [outRx2, outTx2] = newStreamPair(); - node2.addPeer({ + node2.sync.addPeer({ id: "test1", incoming: inRx2, outgoing: outTx2, role: "client", }); - const writer2 = inTx2.getWriter(); - const reader2 = outRx2.getReader(); + const to2 = inTx2.getWriter(); + const from2 = outRx2.getReader(); - await writer2.write(teamSubscribeMsg.value); - const teamSubscribeResponseMsg = await reader2.read(); + const adminSubscribeMessage = await from1.read(); + expect(adminSubscribeMessage.value).toMatchObject({ + action: "subscribe", + coValueID: agentIDAsCoValueID(adminID), + }); + const teamSubscribeMsg = await from1.read(); + expect(teamSubscribeMsg.value).toMatchObject({ + action: "subscribe", + coValueID: team.teamMap.coValue.id, + }); - expect(node2.coValues[team.teamMap.coValue.id]?.state).toEqual("loading"); + await to2.write(adminSubscribeMessage.value!); + await to2.write(teamSubscribeMsg.value!); - const writer1 = inTx1.getWriter(); + const adminTellKnownStateMsg = await from2.read(); + expect(adminTellKnownStateMsg.value).toMatchObject(admStateEx(adminID)); - await writer1.write(teamSubscribeResponseMsg.value); - const teamContentMsg = await reader1.read(); + const teamTellKnownStateMsg = await from2.read(); + expect(teamTellKnownStateMsg.value).toMatchObject(teamStateEx(team)); - await writer2.write(teamContentMsg.value); + expect( + node2.sync.peers["test1"]!.optimisticKnownStates[ + team.teamMap.coValue.id + ] + ).toBeDefined(); - await writer2.write(mapSubscriptionMsg.value); - const _mapSubscribeResponseMsg = await reader2.read(); - await writer2.write(mapNewContentMsg.value); + await to1.write(adminTellKnownStateMsg.value!); + await to1.write(teamTellKnownStateMsg.value!); + + const adminContentMsg = await from1.read(); + expect(adminContentMsg.value).toMatchObject(admContEx(adminID)); + + const teamContentMsg = await from1.read(); + expect(teamContentMsg.value).toMatchObject(teamContentEx(team)); + + await to2.write(adminContentMsg.value!); + await to2.write(teamContentMsg.value!); + + const map = team.createMap(); + + const mapSubscriptionMsg = await from1.read(); + expect(mapSubscriptionMsg.value).toMatchObject({ + action: "subscribe", + coValueID: map.coValue.id, + }); + + const mapNewContentMsg = await from1.read(); + expect(mapNewContentMsg.value).toEqual({ + action: "newContent", + coValueID: map.coValue.id, + header: map.coValue.header, + newContent: {}, + } satisfies SyncMessage); + + await to2.write(mapSubscriptionMsg.value!); + + const mapTellKnownStateMsg = await from2.read(); + expect(mapTellKnownStateMsg.value).toEqual({ + action: "tellKnownState", + coValueID: map.coValue.id, + header: false, + sessions: {}, + } satisfies SyncMessage); expect(node2.coValues[map.coValue.id]?.state).toEqual("loading"); - await writer2.write(mapEditMsg.value); + await to2.write(mapNewContentMsg.value!); + + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + const mapEditMsg = await from1.read(); + + await to2.write(mapEditMsg.value!); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -778,7 +860,8 @@ test("When replaying creation and transactions of a coValue as new content, the ).toEqual("world"); }); -test("When loading a coValue on one node, the server node it is requested from replies with all the necessary depended on coValues to make it work", async () => { +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(); const adminID = getAgentID(getAgent(admin)); @@ -793,10 +876,10 @@ test("When loading a coValue on one node, the server node it is requested from r const node2 = new LocalNode(admin, newRandomSessionID(adminID)); - const [node2asPeer, node1asPeer] = connectedPeers(); + const [node1asPeer, node2asPeer] = connectedPeers("peer1", "peer2"); - node1.addPeer(node2asPeer); - node2.addPeer(node1asPeer); + node1.sync.addPeer(node2asPeer); + node2.sync.addPeer(node1asPeer); await node2.loadCoValue(map.coValue.id); @@ -807,6 +890,76 @@ test("When loading a coValue on one node, the server node it is requested from r ).toEqual("world"); }); +test("Can sync a coValue through a server to another client", async () => { + const admin = newRandomAgentCredential(); + const adminID = getAgentID(getAgent(admin)); + + const client1 = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = client1.createTeam(); + + const map = team.createMap(); + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + const server = new LocalNode(admin, newRandomSessionID(adminID)); + + const [serverAsPeer, client1AsPeer] = connectedPeers("server", "client1", { + trace: true, + peer1role: "server", + peer2role: "client", + }); + + client1.sync.addPeer(serverAsPeer); + server.sync.addPeer(client1AsPeer); + + const client2 = new LocalNode(admin, newRandomSessionID(adminID)); + + const [serverAsOtherPeer, client2AsPeer] = connectedPeers( + "server", + "client2", + { trace: true, peer1role: "server", peer2role: "client" } + ); + + client2.sync.addPeer(serverAsOtherPeer); + server.sync.addPeer(client2AsPeer); + + const mapOnClient2 = await client2.loadCoValue(map.coValue.id); + + expect(expectMap(mapOnClient2.getCurrentContent()).get("hello")).toEqual( + "world" + ); +}); + +function teamContentEx(team: Team) { + return { + action: "newContent", + coValueID: team.teamMap.coValue.id, + }; +} + +function admContEx(adminID: AgentID) { + return { + action: "newContent", + coValueID: agentIDAsCoValueID(adminID), + }; +} + +function teamStateEx(team: Team) { + return { + action: "tellKnownState", + coValueID: team.teamMap.coValue.id, + }; +} + +function admStateEx(adminID: AgentID) { + return { + action: "tellKnownState", + coValueID: agentIDAsCoValueID(adminID), + }; +} + function newStreamPair(): [ReadableStream, WritableStream] { const queue: T[] = []; let resolveNextItemReady: () => void = () => {}; @@ -816,9 +969,11 @@ function newStreamPair(): [ReadableStream, WritableStream] { const readable = new ReadableStream({ async pull(controller) { - while (true) { + let retriesLeft = 3; + while (retriesLeft > 0) { + retriesLeft--; if (queue.length > 0) { - controller.enqueue(queue.shift()); + controller.enqueue(queue.shift()!); if (queue.length === 0) { nextItemReady = new Promise((resolve) => { resolveNextItemReady = resolve; @@ -829,6 +984,7 @@ function newStreamPair(): [ReadableStream, WritableStream] { await nextItemReady; } } + throw new Error("Should only use one retry to get next item in queue.") }, }); @@ -836,7 +992,8 @@ function newStreamPair(): [ReadableStream, WritableStream] { write(chunk) { queue.push(chunk); if (queue.length === 1) { - resolveNextItemReady(); + // make sure that await write resolves before corresponding read + process.nextTick(() => resolveNextItemReady()); } }, }); @@ -844,43 +1001,63 @@ function newStreamPair(): [ReadableStream, WritableStream] { return [readable, writable]; } -function shouldNotResolve(promise: Promise, ops: { timeout: number }) { +function shouldNotResolve(promise: Promise, ops: { timeout: number }): Promise { return new Promise((resolve, reject) => { - promise.then((v) => - reject( - new Error( - "Should not have resolved, but resolved to " + - JSON.stringify(v) + promise + .then((v) => + reject( + new Error( + "Should not have resolved, but resolved to " + + JSON.stringify(v) + ) ) ) - ); + .catch(reject); setTimeout(resolve, ops.timeout); }); } -function connectedPeers(trace?: boolean): [Peer, Peer] { +function connectedPeers( + peer1id: PeerID, + peer2id: PeerID, + { + trace = false, + peer1role = "peer", + peer2role = "peer", + }: { + trace?: boolean; + peer1role?: Peer["role"]; + peer2role?: Peer["role"]; + } = {} +): [Peer, Peer] { const [inRx1, inTx1] = newStreamPair(); const [outRx1, outTx1] = newStreamPair(); const [inRx2, inTx2] = newStreamPair(); const [outRx2, outTx2] = newStreamPair(); - outRx2 + void outRx2 .pipeThrough( new TransformStream({ - transform(chunk, controller) { - trace && console.log("peer 2 -> peer 1", chunk); + transform( + chunk: SyncMessage, + controller: { enqueue: (msg: SyncMessage) => void } + ) { + trace && console.log(`${peer2id} -> ${peer1id}`, chunk); controller.enqueue(chunk); }, }) ) .pipeTo(inTx1); - outRx1 + void outRx1 .pipeThrough( new TransformStream({ - transform(chunk, controller) { - trace && console.log("peer 1 -> peer 2", chunk); + transform( + chunk: SyncMessage, + controller: { enqueue: (msg: SyncMessage) => void } + ) { + trace && console.log(`${peer1id} -> ${peer2id}`, chunk); controller.enqueue(chunk); }, }) @@ -888,18 +1065,18 @@ function connectedPeers(trace?: boolean): [Peer, Peer] { .pipeTo(inTx2); const peer2AsPeer: Peer = { - id: "test2", + id: peer2id, incoming: inRx1, outgoing: outTx1, - role: "peer", + role: peer2role, }; const peer1AsPeer: Peer = { - id: "test1", + id: peer1id, incoming: inRx2, outgoing: outTx2, - role: "peer", + role: peer1role, }; - return [peer2AsPeer, peer1AsPeer]; + return [peer1AsPeer, peer2AsPeer]; } diff --git a/src/sync.ts b/src/sync.ts index 3a8bd02e3..72ee1aec2 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,5 +1,9 @@ import { Hash, Signature } from "./crypto"; import { CoValueHeader, RawCoValueID, SessionID, Transaction } from "./coValue"; +import { CoValue } from "./coValue"; +import { LocalNode } from "./node"; +import { newLoadingState } from "./node"; +import { ReadableStream, WritableStream, WritableStreamDefaultWriter } from "isomorphic-streams"; export type CoValueKnownState = { coValueID: RawCoValueID; @@ -7,23 +11,29 @@ export type CoValueKnownState = { sessions: { [sessionID: SessionID]: number }; }; +export function emptyKnownState(coValueID: RawCoValueID): CoValueKnownState { + return { + coValueID, + header: false, + sessions: {}, + }; +} + export type SyncMessage = | SubscribeMessage - | SubscribeResponseMessage + | TellKnownStateMessage | NewContentMessage | WrongAssumedKnownStateMessage | UnsubscribeMessage; export type SubscribeMessage = { action: "subscribe"; - knownState: CoValueKnownState; -}; +} & CoValueKnownState; -export type SubscribeResponseMessage = { - action: "subscribeResponse"; - knownState: CoValueKnownState; +export type TellKnownStateMessage = { + action: "tellKnownState"; asDependencyOf?: RawCoValueID; -}; +} & CoValueKnownState; export type NewContentMessage = { action: "newContent"; @@ -44,8 +54,7 @@ export type SessionNewContent = { export type WrongAssumedKnownStateMessage = { action: "wrongAssumedKnownState"; - knownState: CoValueKnownState; -}; +} & CoValueKnownState; export type UnsubscribeMessage = { action: "unsubscribe"; @@ -64,6 +73,7 @@ export interface Peer { export interface PeerState { id: PeerID; optimisticKnownStates: { [coValueID: RawCoValueID]: CoValueKnownState }; + toldKnownState: Set; incoming: ReadableStream; outgoing: WritableStreamDefaultWriter; role: "peer" | "server" | "client"; @@ -94,10 +104,16 @@ export function weAreStrictlyAhead( return true; } -export function combinedKnownStates(stateA: CoValueKnownState, stateB: CoValueKnownState): CoValueKnownState { +export function combinedKnownStates( + stateA: CoValueKnownState, + stateB: CoValueKnownState +): CoValueKnownState { const sessionStates: CoValueKnownState["sessions"] = {}; - const allSessions = new Set([...Object.keys(stateA.sessions), ...Object.keys(stateB.sessions)] as SessionID[]); + const allSessions = new Set([ + ...Object.keys(stateA.sessions), + ...Object.keys(stateB.sessions), + ] as SessionID[]); for (const sessionID of allSessions) { const stateAValue = stateA.sessions[sessionID]; @@ -111,4 +127,386 @@ export function combinedKnownStates(stateA: CoValueKnownState, stateB: CoValueKn header: stateA.header || stateB.header, sessions: sessionStates, }; -} \ No newline at end of file +} + +export class SyncManager { + peers: { [key: PeerID]: PeerState } = {}; + local: LocalNode; + + constructor(local: LocalNode) { + this.local = local; + } + + loadFromPeers(id: RawCoValueID) { + for (const peer of Object.values(this.peers)) { + peer.outgoing + .write({ + action: "subscribe", + coValueID: id, + header: false, + sessions: {}, + }) + .catch((e) => { + console.error("Error writing to peer", e); + }); + } + } + + async handleSyncMessage(msg: SyncMessage, peer: PeerState) { + // TODO: validate + switch (msg.action) { + case "subscribe": + return await this.handleSubscribe(msg, peer); + case "tellKnownState": + return await this.handleTellKnownState(msg, peer); + case "newContent": + return await this.handleNewContent(msg, peer); + case "wrongAssumedKnownState": + return await this.handleWrongAssumedKnownState(msg, peer); + case "unsubscribe": + return await this.handleUnsubscribe(msg); + default: + throw new Error( + `Unknown message type ${ + (msg as { action: "string" }).action + }` + ); + } + } + + async subscribeToIncludingDependencies( + coValueID: RawCoValueID, + peer: PeerState + ) { + const coValue = this.local.expectCoValueLoaded(coValueID); + + for (const coValueID of coValue.getDependedOnCoValues()) { + await this.subscribeToIncludingDependencies(coValueID, peer); + } + + if (!peer.toldKnownState.has(coValueID)) { + peer.toldKnownState.add(coValueID); + await peer.outgoing.write({ + action: "subscribe", + ...coValue.knownState(), + }); + } + } + + async tellUntoldKnownStateIncludingDependencies( + coValueID: RawCoValueID, + peer: PeerState, + asDependencyOf?: RawCoValueID + ) { + const coValue = this.local.expectCoValueLoaded(coValueID); + + for (const dependentCoValueID of coValue.getDependedOnCoValues()) { + await this.tellUntoldKnownStateIncludingDependencies( + dependentCoValueID, + peer, + asDependencyOf || coValueID + ); + } + + if (!peer.toldKnownState.has(coValueID)) { + await peer.outgoing.write({ + action: "tellKnownState", + asDependencyOf, + ...coValue.knownState(), + }); + + peer.toldKnownState.add(coValueID); + } + } + + async sendNewContentIncludingDependencies( + coValueID: RawCoValueID, + peer: PeerState + ) { + const coValue = this.local.expectCoValueLoaded(coValueID); + + for (const coValueID of coValue.getDependedOnCoValues()) { + await this.sendNewContentIncludingDependencies(coValueID, peer); + } + + const newContent = coValue.newContentSince( + peer.optimisticKnownStates[coValueID] + ); + + if (newContent) { + await peer.outgoing.write(newContent); + peer.optimisticKnownStates[coValueID] = combinedKnownStates( + peer.optimisticKnownStates[coValueID] || + emptyKnownState(coValueID), + coValue.knownState() + ); + } + } + + addPeer(peer: Peer) { + const peerState: PeerState = { + id: peer.id, + optimisticKnownStates: {}, + incoming: peer.incoming, + outgoing: peer.outgoing.getWriter(), + toldKnownState: new Set(), + role: peer.role, + }; + this.peers[peer.id] = peerState; + + if (peer.role === "server") { + const initialSync = async () => { + for (const entry of Object.values(this.local.coValues)) { + if (entry.state === "loading") { + continue; + } + + await this.subscribeToIncludingDependencies( + entry.coValue.id, + peerState + ); + + peerState.optimisticKnownStates[entry.coValue.id] = { + coValueID: entry.coValue.id, + header: false, + sessions: {}, + }; + } + }; + void initialSync(); + } + + const readIncoming = async () => { + for await (const msg of peerState.incoming) { + try { + await this.handleSyncMessage(msg, peerState); + } catch (e) { + console.error( + `Error reading from peer ${peer.id}`, + JSON.stringify(msg), + e + ); + } + } + }; + + void readIncoming(); + } + + async handleSubscribe(msg: SubscribeMessage, peer: PeerState) { + const entry = this.local.coValues[msg.coValueID]; + + if (!entry || entry.state === "loading") { + if (!entry) { + this.local.coValues[msg.coValueID] = newLoadingState(); + } + + peer.optimisticKnownStates[msg.coValueID] = knownStateIn(msg); + peer.toldKnownState.add(msg.coValueID); + + await peer.outgoing.write({ + action: "tellKnownState", + coValueID: msg.coValueID, + header: false, + sessions: {}, + }); + + return; + } + + peer.optimisticKnownStates[msg.coValueID] = knownStateIn(msg); + + await this.tellUntoldKnownStateIncludingDependencies( + msg.coValueID, + peer + ); + + await this.sendNewContentIncludingDependencies(msg.coValueID, peer); + } + + async handleTellKnownState(msg: TellKnownStateMessage, peer: PeerState) { + let entry = this.local.coValues[msg.coValueID]; + + peer.optimisticKnownStates[msg.coValueID] = combinedKnownStates( + peer.optimisticKnownStates[msg.coValueID] || emptyKnownState(msg.coValueID), + knownStateIn(msg) + ); + + if (!entry) { + if (msg.asDependencyOf) { + if (this.local.coValues[msg.asDependencyOf]) { + entry = newLoadingState(); + + this.local.coValues[msg.coValueID] = entry; + } else { + throw new Error( + "Expected coValue dependency entry to be created, missing subscribe?" + ); + } + } else { + throw new Error( + "Expected coValue entry to be created, missing subscribe?" + ); + } + } + + if (entry.state === "loading") { + return []; + } + + await this.tellUntoldKnownStateIncludingDependencies( + msg.coValueID, + peer + ); + await this.sendNewContentIncludingDependencies(msg.coValueID, peer); + } + + async handleNewContent(msg: NewContentMessage, peer: PeerState) { + let entry = this.local.coValues[msg.coValueID]; + + if (!entry) { + throw new Error( + "Expected coValue entry to be created, missing subscribe?" + ); + } + + let resolveAfterDone: ((coValue: CoValue) => void) | undefined; + + const peerOptimisticKnownState = + peer.optimisticKnownStates[msg.coValueID]; + + if (!peerOptimisticKnownState) { + throw new Error( + "Expected optimisticKnownState to be set for coValue we receive new content for" + ); + } + + if (entry.state === "loading") { + if (!msg.header) { + throw new Error("Expected header to be sent in first message"); + } + + peerOptimisticKnownState.header = true; + + const coValue = new CoValue(msg.header, this.local); + + resolveAfterDone = entry.resolve; + + entry = { + state: "loaded", + coValue: coValue, + }; + + this.local.coValues[msg.coValueID] = entry; + } + + const coValue = entry.coValue; + + let invalidStateAssumed = false; + + for (const [sessionID, newContentForSession] of Object.entries( + msg.newContent + ) as [SessionID, SessionNewContent][]) { + const ourKnownTxIdx = + coValue.sessions[sessionID]?.transactions.length; + const theirFirstNewTxIdx = newContentForSession.after; + + if ((ourKnownTxIdx || 0) < theirFirstNewTxIdx) { + invalidStateAssumed = true; + continue; + } + + const alreadyKnownOffset = ourKnownTxIdx + ? ourKnownTxIdx - theirFirstNewTxIdx + : 0; + + const newTransactions = + newContentForSession.newTransactions.slice(alreadyKnownOffset); + + const success = coValue.tryAddTransactions( + sessionID, + newTransactions, + newContentForSession.lastHash, + newContentForSession.lastSignature + ); + + if (!success) { + console.error("Failed to add transactions", newTransactions); + continue; + } + + peerOptimisticKnownState.sessions[sessionID] = + newContentForSession.after + + newContentForSession.newTransactions.length; + } + + if (resolveAfterDone) { + resolveAfterDone(coValue); + } + + await this.syncCoValue(coValue); + + if (invalidStateAssumed) { + await peer.outgoing.write({ + action: "wrongAssumedKnownState", + ...coValue.knownState(), + }); + } + } + + async handleWrongAssumedKnownState( + msg: WrongAssumedKnownStateMessage, + peer: PeerState + ) { + const coValue = this.local.expectCoValueLoaded(msg.coValueID); + + peer.optimisticKnownStates[msg.coValueID] = combinedKnownStates( + msg, + coValue.knownState() + ); + + const newContent = coValue.newContentSince(msg); + + if (newContent) { + await peer.outgoing.write(newContent); + } + } + + handleUnsubscribe(_msg: UnsubscribeMessage) { + throw new Error("Method not implemented."); + } + + async syncCoValue(coValue: CoValue) { + for (const peer of Object.values(this.peers)) { + const optimisticKnownState = peer.optimisticKnownStates[coValue.id]; + + const shouldSync = + optimisticKnownState || + peer.role === "server"; + + if (shouldSync) { + await this.tellUntoldKnownStateIncludingDependencies( + coValue.id, + peer + ); + await this.sendNewContentIncludingDependencies( + coValue.id, + peer + ); + } + } + } +} + +function knownStateIn( + msg: + | SubscribeMessage + | TellKnownStateMessage + | WrongAssumedKnownStateMessage +) { + return { + coValueID: msg.coValueID, + header: msg.header, + sessions: msg.sessions, + }; +} diff --git a/tsconfig.json b/tsconfig.json index 91aa71fb6..a255db408 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,10 +14,8 @@ "forceConsistentCasingInFileNames": true, "allowJs": true, "noEmit": true, - "types": [ - "bun-types" // add Bun global - ], - - // "noUncheckedIndexedAccess": true - } + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + }, + "include": ["./src/**/*"], } diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000..51df233c8 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,2781 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@aashutoshrathi/word-wrap@^1.2.3": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" + integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== + +"@ampproject/remapping@^2.2.0": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" + integrity sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg== + dependencies: + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.5.tgz#234d98e1551960604f1246e6475891a570ad5658" + integrity sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ== + dependencies: + "@babel/highlight" "^7.22.5" + +"@babel/compat-data@^7.22.9": + version "7.22.9" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.9.tgz#71cdb00a1ce3a329ce4cbec3a44f9fef35669730" + integrity sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ== + +"@babel/core@^7.11.6", "@babel/core@^7.12.3": + version "7.22.9" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.22.9.tgz#bd96492c68822198f33e8a256061da3cf391f58f" + integrity sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.22.5" + "@babel/generator" "^7.22.9" + "@babel/helper-compilation-targets" "^7.22.9" + "@babel/helper-module-transforms" "^7.22.9" + "@babel/helpers" "^7.22.6" + "@babel/parser" "^7.22.7" + "@babel/template" "^7.22.5" + "@babel/traverse" "^7.22.8" + "@babel/types" "^7.22.5" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.2" + semver "^6.3.1" + +"@babel/generator@^7.22.7", "@babel/generator@^7.22.9", "@babel/generator@^7.7.2": + version "7.22.9" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.22.9.tgz#572ecfa7a31002fa1de2a9d91621fd895da8493d" + integrity sha512-KtLMbmicyuK2Ak/FTCJVbDnkN1SlT8/kceFTiuDiiRUUSMnHMidxSCdG4ndkTOHHpoomWe/4xkvHkEOncwjYIw== + dependencies: + "@babel/types" "^7.22.5" + "@jridgewell/gen-mapping" "^0.3.2" + "@jridgewell/trace-mapping" "^0.3.17" + jsesc "^2.5.1" + +"@babel/helper-compilation-targets@^7.22.9": + version "7.22.9" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.9.tgz#f9d0a7aaaa7cd32a3f31c9316a69f5a9bcacb892" + integrity sha512-7qYrNM6HjpnPHJbopxmb8hSPoZ0gsX8IvUS32JGVoy+pU9e5N0nLr1VjJoR6kA4d9dmGLxNYOjeB8sUDal2WMw== + dependencies: + "@babel/compat-data" "^7.22.9" + "@babel/helper-validator-option" "^7.22.5" + browserslist "^4.21.9" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-environment-visitor@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz#f06dd41b7c1f44e1f8da6c4055b41ab3a09a7e98" + integrity sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q== + +"@babel/helper-function-name@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz#ede300828905bb15e582c037162f99d5183af1be" + integrity sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ== + dependencies: + "@babel/template" "^7.22.5" + "@babel/types" "^7.22.5" + +"@babel/helper-hoist-variables@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" + integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-module-imports@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz#1a8f4c9f4027d23f520bd76b364d44434a72660c" + integrity sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-module-transforms@^7.22.9": + version "7.22.9" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz#92dfcb1fbbb2bc62529024f72d942a8c97142129" + integrity sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ== + dependencies: + "@babel/helper-environment-visitor" "^7.22.5" + "@babel/helper-module-imports" "^7.22.5" + "@babel/helper-simple-access" "^7.22.5" + "@babel/helper-split-export-declaration" "^7.22.6" + "@babel/helper-validator-identifier" "^7.22.5" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.8.0": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz#dd7ee3735e8a313b9f7b05a773d892e88e6d7295" + integrity sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg== + +"@babel/helper-simple-access@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz#4938357dc7d782b80ed6dbb03a0fba3d22b1d5de" + integrity sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-split-export-declaration@^7.22.6": + version "7.22.6" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz#322c61b7310c0997fe4c323955667f18fcefb91c" + integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-string-parser@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f" + integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw== + +"@babel/helper-validator-identifier@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz#9544ef6a33999343c8740fa51350f30eeaaaf193" + integrity sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ== + +"@babel/helper-validator-option@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz#de52000a15a177413c8234fa3a8af4ee8102d0ac" + integrity sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw== + +"@babel/helpers@^7.22.6": + version "7.22.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.22.6.tgz#8e61d3395a4f0c5a8060f309fb008200969b5ecd" + integrity sha512-YjDs6y/fVOYFV8hAf1rxd1QvR9wJe1pDBZ2AREKq/SDayfPzgk0PBnVuTCE5X1acEpMMNOVUqoe+OwiZGJ+OaA== + dependencies: + "@babel/template" "^7.22.5" + "@babel/traverse" "^7.22.6" + "@babel/types" "^7.22.5" + +"@babel/highlight@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.5.tgz#aa6c05c5407a67ebce408162b7ede789b4d22031" + integrity sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw== + dependencies: + "@babel/helper-validator-identifier" "^7.22.5" + chalk "^2.0.0" + js-tokens "^4.0.0" + +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.22.5", "@babel/parser@^7.22.7": + version "7.22.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.7.tgz#df8cf085ce92ddbdbf668a7f186ce848c9036cae" + integrity sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q== + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-bigint@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" + integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.8.3": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-import-meta@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-jsx@^7.7.2": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.22.5.tgz#a6b68e84fb76e759fc3b93e901876ffabbe1d918" + integrity sha512-gvyP4hZrgrs/wWMaocvxZ44Hw0b3W8Pe+cMxc8V1ULQ07oh8VNbIRaoD1LRZVTvD+0nieDKjfgKg89sD7rrKrg== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/plugin-syntax-logical-assignment-operators@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-top-level-await@^7.8.3": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-typescript@^7.7.2": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.22.5.tgz#aac8d383b062c5072c647a31ef990c1d0af90272" + integrity sha512-1mS2o03i7t1c6VzH6fdQ3OA8tcEIxwG18zIPRp+UY1Ihv6W+XZzBCVxExF9upussPXJ0xE9XRHwMoNs1ep/nRQ== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + +"@babel/template@^7.22.5", "@babel/template@^7.3.3": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec" + integrity sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw== + dependencies: + "@babel/code-frame" "^7.22.5" + "@babel/parser" "^7.22.5" + "@babel/types" "^7.22.5" + +"@babel/traverse@^7.22.6", "@babel/traverse@^7.22.8": + version "7.22.8" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.22.8.tgz#4d4451d31bc34efeae01eac222b514a77aa4000e" + integrity sha512-y6LPR+wpM2I3qJrsheCTwhIinzkETbplIgPBbwvqPKc+uljeA5gP+3nP8irdYt1mjQaDnlIcG+dw8OjAco4GXw== + dependencies: + "@babel/code-frame" "^7.22.5" + "@babel/generator" "^7.22.7" + "@babel/helper-environment-visitor" "^7.22.5" + "@babel/helper-function-name" "^7.22.5" + "@babel/helper-hoist-variables" "^7.22.5" + "@babel/helper-split-export-declaration" "^7.22.6" + "@babel/parser" "^7.22.7" + "@babel/types" "^7.22.5" + debug "^4.1.0" + globals "^11.1.0" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.5", "@babel/types@^7.3.3": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.5.tgz#cd93eeaab025880a3a47ec881f4b096a5b786fbe" + integrity sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA== + dependencies: + "@babel/helper-string-parser" "^7.22.5" + "@babel/helper-validator-identifier" "^7.22.5" + to-fast-properties "^2.0.0" + +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" + integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== + dependencies: + eslint-visitor-keys "^3.3.0" + +"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1": + version "4.6.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.6.2.tgz#1816b5f6948029c5eaacb0703b850ee0cb37d8f8" + integrity sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw== + +"@eslint/eslintrc@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.1.tgz#18d635e24ad35f7276e8a49d135c7d3ca6a46f93" + integrity sha512-9t7ZA7NGGK8ckelF0PQCfcxIUzs1Md5rrO6U/c+FIQNanea5UZC0wqKXH4vHBccmu4ZJgZ2idtPeW7+Q2npOEA== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^9.6.0" + globals "^13.19.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@^8.46.0": + version "8.46.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.46.0.tgz#3f7802972e8b6fe3f88ed1aabc74ec596c456db6" + integrity sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA== + +"@humanwhocodes/config-array@^0.11.10": + version "0.11.10" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2" + integrity sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ== + dependencies: + "@humanwhocodes/object-schema" "^1.2.1" + debug "^4.1.1" + minimatch "^3.0.5" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/object-schema@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" + integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== + +"@istanbuljs/load-nyc-config@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" + integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== + dependencies: + camelcase "^5.3.1" + find-up "^4.1.0" + get-package-type "^0.1.0" + js-yaml "^3.13.1" + resolve-from "^5.0.0" + +"@istanbuljs/schema@^0.1.2": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jest/console@^29.6.2": + version "29.6.2" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.6.2.tgz#bf1d4101347c23e07c029a1b1ae07d550f5cc541" + integrity sha512-0N0yZof5hi44HAR2pPS+ikJ3nzKNoZdVu8FffRf3wy47I7Dm7etk/3KetMdRUqzVd16V4O2m2ISpNTbnIuqy1w== + dependencies: + "@jest/types" "^29.6.1" + "@types/node" "*" + chalk "^4.0.0" + jest-message-util "^29.6.2" + jest-util "^29.6.2" + slash "^3.0.0" + +"@jest/core@^29.6.2": + version "29.6.2" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.6.2.tgz#6f2d1dbe8aa0265fcd4fb8082ae1952f148209c8" + integrity sha512-Oj+5B+sDMiMWLhPFF+4/DvHOf+U10rgvCLGPHP8Xlsy/7QxS51aU/eBngudHlJXnaWD5EohAgJ4js+T6pa+zOg== + dependencies: + "@jest/console" "^29.6.2" + "@jest/reporters" "^29.6.2" + "@jest/test-result" "^29.6.2" + "@jest/transform" "^29.6.2" + "@jest/types" "^29.6.1" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + ci-info "^3.2.0" + exit "^0.1.2" + graceful-fs "^4.2.9" + jest-changed-files "^29.5.0" + jest-config "^29.6.2" + jest-haste-map "^29.6.2" + jest-message-util "^29.6.2" + jest-regex-util "^29.4.3" + jest-resolve "^29.6.2" + jest-resolve-dependencies "^29.6.2" + jest-runner "^29.6.2" + jest-runtime "^29.6.2" + jest-snapshot "^29.6.2" + jest-util "^29.6.2" + jest-validate "^29.6.2" + jest-watcher "^29.6.2" + micromatch "^4.0.4" + pretty-format "^29.6.2" + slash "^3.0.0" + strip-ansi "^6.0.0" + +"@jest/environment@^29.6.2": + version "29.6.2" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.6.2.tgz#794c0f769d85e7553439d107d3f43186dc6874a9" + integrity sha512-AEcW43C7huGd/vogTddNNTDRpO6vQ2zaQNrttvWV18ArBx9Z56h7BIsXkNFJVOO4/kblWEQz30ckw0+L3izc+Q== + dependencies: + "@jest/fake-timers" "^29.6.2" + "@jest/types" "^29.6.1" + "@types/node" "*" + jest-mock "^29.6.2" + +"@jest/expect-utils@^29.6.2": + version "29.6.2" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.6.2.tgz#1b97f290d0185d264dd9fdec7567a14a38a90534" + integrity sha512-6zIhM8go3RV2IG4aIZaZbxwpOzz3ZiM23oxAlkquOIole+G6TrbeXnykxWYlqF7kz2HlBjdKtca20x9atkEQYg== + dependencies: + jest-get-type "^29.4.3" + +"@jest/expect@^29.6.2": + version "29.6.2" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.6.2.tgz#5a2ad58bb345165d9ce0a1845bbf873c480a4b28" + integrity sha512-m6DrEJxVKjkELTVAztTLyS/7C92Y2b0VYqmDROYKLLALHn8T/04yPs70NADUYPrV3ruI+H3J0iUIuhkjp7vkfg== + dependencies: + expect "^29.6.2" + jest-snapshot "^29.6.2" + +"@jest/fake-timers@^29.6.2": + version "29.6.2" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.6.2.tgz#fe9d43c5e4b1b901168fe6f46f861b3e652a2df4" + integrity sha512-euZDmIlWjm1Z0lJ1D0f7a0/y5Kh/koLFMUBE5SUYWrmy8oNhJpbTBDAP6CxKnadcMLDoDf4waRYCe35cH6G6PA== + dependencies: + "@jest/types" "^29.6.1" + "@sinonjs/fake-timers" "^10.0.2" + "@types/node" "*" + jest-message-util "^29.6.2" + jest-mock "^29.6.2" + jest-util "^29.6.2" + +"@jest/globals@^29.6.2": + version "29.6.2" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.6.2.tgz#74af81b9249122cc46f1eb25793617eec69bf21a" + integrity sha512-cjuJmNDjs6aMijCmSa1g2TNG4Lby/AeU7/02VtpW+SLcZXzOLK2GpN2nLqcFjmhy3B3AoPeQVx7BnyOf681bAw== + dependencies: + "@jest/environment" "^29.6.2" + "@jest/expect" "^29.6.2" + "@jest/types" "^29.6.1" + jest-mock "^29.6.2" + +"@jest/reporters@^29.6.2": + version "29.6.2" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.6.2.tgz#524afe1d76da33d31309c2c4a2c8062d0c48780a" + integrity sha512-sWtijrvIav8LgfJZlrGCdN0nP2EWbakglJY49J1Y5QihcQLfy7ovyxxjJBRXMNltgt4uPtEcFmIMbVshEDfFWw== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@jest/console" "^29.6.2" + "@jest/test-result" "^29.6.2" + "@jest/transform" "^29.6.2" + "@jest/types" "^29.6.1" + "@jridgewell/trace-mapping" "^0.3.18" + "@types/node" "*" + chalk "^4.0.0" + collect-v8-coverage "^1.0.0" + exit "^0.1.2" + glob "^7.1.3" + graceful-fs "^4.2.9" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-instrument "^5.1.0" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.1.3" + jest-message-util "^29.6.2" + jest-util "^29.6.2" + jest-worker "^29.6.2" + slash "^3.0.0" + string-length "^4.0.1" + strip-ansi "^6.0.0" + v8-to-istanbul "^9.0.1" + +"@jest/schemas@^29.6.0": + version "29.6.0" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.0.tgz#0f4cb2c8e3dca80c135507ba5635a4fd755b0040" + integrity sha512-rxLjXyJBTL4LQeJW3aKo0M/+GkCOXsO+8i9Iu7eDb6KwtP65ayoDsitrdPBtujxQ88k4wI2FNYfa6TOGwSn6cQ== + dependencies: + "@sinclair/typebox" "^0.27.8" + +"@jest/source-map@^29.6.0": + version "29.6.0" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.6.0.tgz#bd34a05b5737cb1a99d43e1957020ac8e5b9ddb1" + integrity sha512-oA+I2SHHQGxDCZpbrsCQSoMLb3Bz547JnM+jUr9qEbuw0vQlWZfpPS7CO9J7XiwKicEz9OFn/IYoLkkiUD7bzA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.18" + callsites "^3.0.0" + graceful-fs "^4.2.9" + +"@jest/test-result@^29.6.2": + version "29.6.2" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.6.2.tgz#fdd11583cd1608e4db3114e8f0cce277bf7a32ed" + integrity sha512-3VKFXzcV42EYhMCsJQURptSqnyjqCGbtLuX5Xxb6Pm6gUf1wIRIl+mandIRGJyWKgNKYF9cnstti6Ls5ekduqw== + dependencies: + "@jest/console" "^29.6.2" + "@jest/types" "^29.6.1" + "@types/istanbul-lib-coverage" "^2.0.0" + collect-v8-coverage "^1.0.0" + +"@jest/test-sequencer@^29.6.2": + version "29.6.2" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.6.2.tgz#585eff07a68dd75225a7eacf319780cb9f6b9bf4" + integrity sha512-GVYi6PfPwVejO7slw6IDO0qKVum5jtrJ3KoLGbgBWyr2qr4GaxFV6su+ZAjdTX75Sr1DkMFRk09r2ZVa+wtCGw== + dependencies: + "@jest/test-result" "^29.6.2" + graceful-fs "^4.2.9" + jest-haste-map "^29.6.2" + slash "^3.0.0" + +"@jest/transform@^29.6.2": + version "29.6.2" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.6.2.tgz#522901ebbb211af08835bc3bcdf765ab778094e3" + integrity sha512-ZqCqEISr58Ce3U+buNFJYUktLJZOggfyvR+bZMaiV1e8B1SIvJbwZMrYz3gx/KAPn9EXmOmN+uB08yLCjWkQQg== + dependencies: + "@babel/core" "^7.11.6" + "@jest/types" "^29.6.1" + "@jridgewell/trace-mapping" "^0.3.18" + babel-plugin-istanbul "^6.1.1" + chalk "^4.0.0" + convert-source-map "^2.0.0" + fast-json-stable-stringify "^2.1.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.6.2" + jest-regex-util "^29.4.3" + jest-util "^29.6.2" + micromatch "^4.0.4" + pirates "^4.0.4" + slash "^3.0.0" + write-file-atomic "^4.0.2" + +"@jest/types@^29.6.1": + version "29.6.1" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.1.tgz#ae79080278acff0a6af5eb49d063385aaa897bf2" + integrity sha512-tPKQNMPuXgvdOn2/Lg9HNfUvjYVGolt04Hp03f5hAk878uwOLikN+JzeLY0HcVgKgFl9Hs3EIqpu3WX27XNhnw== + dependencies: + "@jest/schemas" "^29.6.0" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + +"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098" + integrity sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ== + dependencies: + "@jridgewell/set-array" "^1.0.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.9" + +"@jridgewell/resolve-uri@3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" + integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== + +"@jridgewell/set-array@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" + integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== + +"@jridgewell/sourcemap-codec@1.4.14": + version "1.4.14" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" + integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== + +"@jridgewell/sourcemap-codec@^1.4.10": + version "1.4.15" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" + integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== + +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.9": + version "0.3.18" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz#25783b2086daf6ff1dcb53c9249ae480e4dd4cd6" + integrity sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA== + dependencies: + "@jridgewell/resolve-uri" "3.1.0" + "@jridgewell/sourcemap-codec" "1.4.14" + +"@noble/ciphers@^0.1.3": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.1.4.tgz#96327dca147829ed9eee0d96cfdf7c57915765f0" + integrity sha512-d3ZR8vGSpy3v/nllS+bD/OMN5UZqusWiQqkyj7AwzTnhXFH72pF5oB4Ach6DQ50g5kXxC28LdaYBEpsyv9KOUQ== + +"@noble/curves@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d" + integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA== + dependencies: + "@noble/hashes" "1.3.1" + +"@noble/hashes@1.3.1", "@noble/hashes@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" + integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA== + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@scure/base@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938" + integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA== + +"@sinclair/typebox@^0.27.8": + version "0.27.8" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" + integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== + +"@sinonjs/commons@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.0.tgz#beb434fe875d965265e04722ccfc21df7f755d72" + integrity sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^10.0.2": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" + integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@types/babel__core@^7.1.14": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.1.tgz#916ecea274b0c776fec721e333e55762d3a9614b" + integrity sha512-aACu/U/omhdk15O4Nfb+fHgH/z3QsfQzpnvRZhYhThms83ZnAOZz7zZAWO7mn2yyNQaA4xTO8GLK3uqFU4bYYw== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.6.4" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.4.tgz#1f20ce4c5b1990b37900b63f050182d28c2439b7" + integrity sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.1" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.1.tgz#3d1a48fd9d6c0edfd56f2ff578daed48f36c8969" + integrity sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.1.tgz#dd6f1d2411ae677dcb2db008c962598be31d6acf" + integrity sha512-MitHFXnhtgwsGZWtT68URpOvLN4EREih1u3QtQiN4VdAxWKRVvGCSvw/Qth0M0Qq3pJpnGOu5JaM/ydK7OGbqg== + dependencies: + "@babel/types" "^7.20.7" + +"@types/graceful-fs@^4.1.3": + version "4.1.6" + resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" + integrity sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw== + dependencies: + "@types/node" "*" + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" + integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g== + +"@types/istanbul-lib-report@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" + integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff" + integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw== + dependencies: + "@types/istanbul-lib-report" "*" + +"@types/jest@^29.5.3": + version "29.5.3" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.3.tgz#7a35dc0044ffb8b56325c6802a4781a626b05777" + integrity sha512-1Nq7YrO/vJE/FYnqYyw0FS8LdrjExSgIiHyKg7xPpn+yi8Q4huZryKnkJatN1ZRH89Kw2v33/8ZMB7DuZeSLlA== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + +"@types/json-schema@^7.0.12": + version "7.0.12" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" + integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== + +"@types/node@*": + version "20.4.8" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.8.tgz#b5dda19adaa473a9bf0ab5cbd8f30ec7d43f5c85" + integrity sha512-0mHckf6D2DiIAzh8fM8f3HQCvMKDpK94YQ0DSVkfWTG9BZleYIWudw9cJxX8oCk9bM+vAkDyujDV6dmKHbvQpg== + +"@types/semver@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.0.tgz#591c1ce3a702c45ee15f47a42ade72c2fd78978a" + integrity sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw== + +"@types/stack-utils@^2.0.0": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" + integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== + +"@types/yargs-parser@*": + version "21.0.0" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" + integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== + +"@types/yargs@^17.0.8": + version "17.0.24" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" + integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== + dependencies: + "@types/yargs-parser" "*" + +"@typescript-eslint/eslint-plugin@^6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.2.1.tgz#41b79923fee46a745a3a50cba1c33c622aa3c79a" + integrity sha512-iZVM/ALid9kO0+I81pnp1xmYiFyqibAHzrqX4q5YvvVEyJqY+e6rfTXSCsc2jUxGNqJqTfFSSij/NFkZBiBzLw== + dependencies: + "@eslint-community/regexpp" "^4.5.1" + "@typescript-eslint/scope-manager" "6.2.1" + "@typescript-eslint/type-utils" "6.2.1" + "@typescript-eslint/utils" "6.2.1" + "@typescript-eslint/visitor-keys" "6.2.1" + debug "^4.3.4" + graphemer "^1.4.0" + ignore "^5.2.4" + natural-compare "^1.4.0" + natural-compare-lite "^1.4.0" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/parser@^6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.2.1.tgz#e18a31eea1cca8841a565f1701960c8123ed07f9" + integrity sha512-Ld+uL1kYFU8e6btqBFpsHkwQ35rw30IWpdQxgOqOh4NfxSDH6uCkah1ks8R/RgQqI5hHPXMaLy9fbFseIe+dIg== + dependencies: + "@typescript-eslint/scope-manager" "6.2.1" + "@typescript-eslint/types" "6.2.1" + "@typescript-eslint/typescript-estree" "6.2.1" + "@typescript-eslint/visitor-keys" "6.2.1" + debug "^4.3.4" + +"@typescript-eslint/scope-manager@6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.2.1.tgz#b6f43a867b84e5671fe531f2b762e0b68f7cf0c4" + integrity sha512-UCqBF9WFqv64xNsIEPfBtenbfodPXsJ3nPAr55mGPkQIkiQvgoWNo+astj9ZUfJfVKiYgAZDMnM6dIpsxUMp3Q== + dependencies: + "@typescript-eslint/types" "6.2.1" + "@typescript-eslint/visitor-keys" "6.2.1" + +"@typescript-eslint/type-utils@6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.2.1.tgz#8eb8a2cccdf39cd7cf93e02bd2c3782dc90b0525" + integrity sha512-fTfCgomBMIgu2Dh2Or3gMYgoNAnQm3RLtRp+jP7A8fY+LJ2+9PNpi5p6QB5C4RSP+U3cjI0vDlI3mspAkpPVbQ== + dependencies: + "@typescript-eslint/typescript-estree" "6.2.1" + "@typescript-eslint/utils" "6.2.1" + debug "^4.3.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/types@6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.2.1.tgz#7fcdeceb503aab601274bf5e210207050d88c8ab" + integrity sha512-528bGcoelrpw+sETlyM91k51Arl2ajbNT9L4JwoXE2dvRe1yd8Q64E4OL7vHYw31mlnVsf+BeeLyAZUEQtqahQ== + +"@typescript-eslint/typescript-estree@6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.2.1.tgz#2af6e90c1e91cb725a5fe1682841a3f74549389e" + integrity sha512-G+UJeQx9AKBHRQBpmvr8T/3K5bJa485eu+4tQBxFq0KoT22+jJyzo1B50JDT9QdC1DEmWQfdKsa8ybiNWYsi0Q== + dependencies: + "@typescript-eslint/types" "6.2.1" + "@typescript-eslint/visitor-keys" "6.2.1" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/utils@6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.2.1.tgz#2aa4279ec13053d05615bcbde2398e1e8f08c334" + integrity sha512-eBIXQeupYmxVB6S7x+B9SdBeB6qIdXKjgQBge2J+Ouv8h9Cxm5dHf/gfAZA6dkMaag+03HdbVInuXMmqFB/lKQ== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@types/json-schema" "^7.0.12" + "@types/semver" "^7.5.0" + "@typescript-eslint/scope-manager" "6.2.1" + "@typescript-eslint/types" "6.2.1" + "@typescript-eslint/typescript-estree" "6.2.1" + semver "^7.5.4" + +"@typescript-eslint/visitor-keys@6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.2.1.tgz#442e7c09fe94b715a54ebe30e967987c3c41fbf4" + integrity sha512-iTN6w3k2JEZ7cyVdZJTVJx2Lv7t6zFA8DCrJEHD2mwfc16AEvvBWVhbFh34XyG2NORCd0viIgQY1+u7kPI0WpA== + dependencies: + "@typescript-eslint/types" "6.2.1" + eslint-visitor-keys "^3.4.1" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.9.0: + version "8.10.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" + integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== + +ajv@^6.12.4: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-escapes@^4.2.1: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +anymatch@^3.0.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +babel-jest@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.6.2.tgz#cada0a59e07f5acaeb11cbae7e3ba92aec9c1126" + integrity sha512-BYCzImLos6J3BH/+HvUCHG1dTf2MzmAB4jaVxHV+29RZLjR29XuYTmsf2sdDwkrb+FczkGo3kOhE7ga6sI0P4A== + dependencies: + "@jest/transform" "^29.6.2" + "@types/babel__core" "^7.1.14" + babel-plugin-istanbul "^6.1.1" + babel-preset-jest "^29.5.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + slash "^3.0.0" + +babel-plugin-istanbul@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" + integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-instrument "^5.0.4" + test-exclude "^6.0.0" + +babel-plugin-jest-hoist@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.5.0.tgz#a97db437936f441ec196990c9738d4b88538618a" + integrity sha512-zSuuuAlTMT4mzLj2nPnUm6fsE6270vdOfnpbJ+RmruU75UhLFvL0N2NgI7xpeS7NaB6hGqmd5pVpGTDYvi4Q3w== + dependencies: + "@babel/template" "^7.3.3" + "@babel/types" "^7.3.3" + "@types/babel__core" "^7.1.14" + "@types/babel__traverse" "^7.0.6" + +babel-preset-current-node-syntax@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" + integrity sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ== + dependencies: + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-bigint" "^7.8.3" + "@babel/plugin-syntax-class-properties" "^7.8.3" + "@babel/plugin-syntax-import-meta" "^7.8.3" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.8.3" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.8.3" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-top-level-await" "^7.8.3" + +babel-preset-jest@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.5.0.tgz#57bc8cc88097af7ff6a5ab59d1cd29d52a5916e2" + integrity sha512-JOMloxOqdiBSxMAzjRaH023/vvcaSaec49zvg+2LmNsktC7ei39LTJGw02J+9uUtTZUq6xbLyJ4dxe9sSmIuAg== + dependencies: + babel-plugin-jest-hoist "^29.5.0" + babel-preset-current-node-syntax "^1.0.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +browserslist@^4.21.9: + version "4.21.10" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.10.tgz#dbbac576628c13d3b2231332cb2ec5a46e015bb0" + integrity sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ== + dependencies: + caniuse-lite "^1.0.30001517" + electron-to-chromium "^1.4.477" + node-releases "^2.0.13" + update-browserslist-db "^1.0.11" + +bs-logger@0.x: + version "0.2.6" + resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" + integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== + dependencies: + fast-json-stable-stringify "2.x" + +bser@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" + integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== + dependencies: + node-int64 "^0.4.0" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^6.2.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caniuse-lite@^1.0.30001517: + version "1.0.30001519" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001519.tgz#3e7b8b8a7077e78b0eb054d69e6edf5c7df35601" + integrity sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg== + +chalk@^2.0.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +char-regex@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" + integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== + +ci-info@^3.2.0: + version "3.8.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" + integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== + +cjs-module-lexer@^1.0.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107" + integrity sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ== + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== + +collect-v8-coverage@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" + integrity sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q== + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +convert-source-map@^1.6.0, convert-source-map@^1.7.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" + integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +cross-spawn@^7.0.2, cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +dedent@^1.0.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.1.tgz#4f3fc94c8b711e9bb2800d185cd6ad20f2a90aff" + integrity sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +deepmerge@^4.2.2: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +detect-newline@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" + integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== + +diff-sequences@^29.4.3: + version "29.4.3" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2" + integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA== + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +electron-to-chromium@^1.4.477: + version "1.4.485" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.485.tgz#fde3ee9ee8112a3414c0dfa545385ad08ec43408" + integrity sha512-1ndQ5IBNEnFirPwvyud69GHL+31FkE09gH/CJ6m3KCbkx3i0EVOrjwz4UNxRmN9H8OVHbC6vMRZGN1yCvjSs9w== + +emittery@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" + integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-scope@^7.2.2: + version "7.2.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" + integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.2.tgz#8c2095440eca8c933bedcadf16fefa44dbe9ba5f" + integrity sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw== + +eslint@^8.46.0: + version "8.46.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.46.0.tgz#a06a0ff6974e53e643acc42d1dcf2e7f797b3552" + integrity sha512-cIO74PvbW0qU8e0mIvk5IV3ToWdCq5FYG6gWPHHkx6gNdjlbAYvtfHmlCMXxjcoVaIdwy/IAt3+mDkZkfvb2Dg== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.6.1" + "@eslint/eslintrc" "^2.1.1" + "@eslint/js" "^8.46.0" + "@humanwhocodes/config-array" "^0.11.10" + "@humanwhocodes/module-importer" "^1.0.1" + "@nodelib/fs.walk" "^1.2.8" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + doctrine "^3.0.0" + escape-string-regexp "^4.0.0" + eslint-scope "^7.2.2" + eslint-visitor-keys "^3.4.2" + espree "^9.6.1" + esquery "^1.4.2" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + find-up "^5.0.0" + glob-parent "^6.0.2" + globals "^13.19.0" + graphemer "^1.4.0" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + strip-ansi "^6.0.1" + text-table "^0.2.0" + +espree@^9.6.0, espree@^9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" + integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== + dependencies: + acorn "^8.9.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.4.1" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.4.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" + integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +execa@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== + +expect@^29.0.0, expect@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.6.2.tgz#7b08e83eba18ddc4a2cf62b5f2d1918f5cd84521" + integrity sha512-iAErsLxJ8C+S02QbLAwgSGSezLQK+XXRDt8IuFXFpwCNw2ECmzZSmjKcCaFVp5VRMk+WAvz6h6jokzEzBFZEuA== + dependencies: + "@jest/expect-utils" "^29.6.2" + "@types/node" "*" + jest-get-type "^29.4.3" + jest-matcher-utils "^29.6.2" + jest-message-util "^29.6.2" + jest-util "^29.6.2" + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-glob@^3.2.9: + version "3.3.1" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4" + integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fastq@^1.6.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a" + integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw== + dependencies: + reusify "^1.0.4" + +fb-watchman@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" + integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== + dependencies: + bser "2.1.1" + +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== + dependencies: + flat-cache "^3.0.4" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" + integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== + dependencies: + flatted "^3.1.0" + rimraf "^3.0.2" + +flatted@^3.1.0: + version "3.2.7" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" + integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^7.1.3, glob@^7.1.4: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +globals@^13.19.0: + version "13.20.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.20.0.tgz#ea276a1e508ffd4f1612888f9d1bad1e2717bf82" + integrity sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ== + dependencies: + type-fest "^0.20.2" + +globby@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" + integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.2.9" + ignore "^5.2.0" + merge2 "^1.4.1" + slash "^3.0.0" + +graceful-fs@^4.2.9: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +graphemer@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" + integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +ignore@^5.2.0, ignore@^5.2.4: + version "5.2.4" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" + integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== + +import-fresh@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-local@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" + integrity sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg== + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-core-module@^2.13.0: + version "2.13.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db" + integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ== + dependencies: + has "^1.0.3" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-generator-fn@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" + integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== + +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +"isomorphic-streams@https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae": + version "1.0.3" + resolved "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae" + +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" + integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw== + +istanbul-lib-instrument@^5.0.4, istanbul-lib-instrument@^5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" + integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== + dependencies: + "@babel/core" "^7.12.3" + "@babel/parser" "^7.14.7" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.2.0" + semver "^6.3.0" + +istanbul-lib-report@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" + integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + source-map "^0.6.1" + +istanbul-reports@^3.1.3: + version "3.1.6" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.6.tgz#2544bcab4768154281a2f0870471902704ccaa1a" + integrity sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jest-changed-files@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.5.0.tgz#e88786dca8bf2aa899ec4af7644e16d9dcf9b23e" + integrity sha512-IFG34IUMUaNBIxjQXF/iu7g6EcdMrGRRxaUSw92I/2g2YC6vCdTltl4nHvt7Ci5nSJwXIkCu8Ka1DKF+X7Z1Ag== + dependencies: + execa "^5.0.0" + p-limit "^3.1.0" + +jest-circus@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.6.2.tgz#1e6ffca60151ac66cad63fce34f443f6b5bb4258" + integrity sha512-G9mN+KOYIUe2sB9kpJkO9Bk18J4dTDArNFPwoZ7WKHKel55eKIS/u2bLthxgojwlf9NLCVQfgzM/WsOVvoC6Fw== + dependencies: + "@jest/environment" "^29.6.2" + "@jest/expect" "^29.6.2" + "@jest/test-result" "^29.6.2" + "@jest/types" "^29.6.1" + "@types/node" "*" + chalk "^4.0.0" + co "^4.6.0" + dedent "^1.0.0" + is-generator-fn "^2.0.0" + jest-each "^29.6.2" + jest-matcher-utils "^29.6.2" + jest-message-util "^29.6.2" + jest-runtime "^29.6.2" + jest-snapshot "^29.6.2" + jest-util "^29.6.2" + p-limit "^3.1.0" + pretty-format "^29.6.2" + pure-rand "^6.0.0" + slash "^3.0.0" + stack-utils "^2.0.3" + +jest-cli@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.6.2.tgz#edb381763398d1a292cd1b636a98bfa5644b8fda" + integrity sha512-TT6O247v6dCEX2UGHGyflMpxhnrL0DNqP2fRTKYm3nJJpCTfXX3GCMQPGFjXDoj0i5/Blp3jriKXFgdfmbYB6Q== + dependencies: + "@jest/core" "^29.6.2" + "@jest/test-result" "^29.6.2" + "@jest/types" "^29.6.1" + chalk "^4.0.0" + exit "^0.1.2" + graceful-fs "^4.2.9" + import-local "^3.0.2" + jest-config "^29.6.2" + jest-util "^29.6.2" + jest-validate "^29.6.2" + prompts "^2.0.1" + yargs "^17.3.1" + +jest-config@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.6.2.tgz#c68723f06b31ca5e63030686e604727d406cd7c3" + integrity sha512-VxwFOC8gkiJbuodG9CPtMRjBUNZEHxwfQXmIudSTzFWxaci3Qub1ddTRbFNQlD/zUeaifLndh/eDccFX4wCMQw== + dependencies: + "@babel/core" "^7.11.6" + "@jest/test-sequencer" "^29.6.2" + "@jest/types" "^29.6.1" + babel-jest "^29.6.2" + chalk "^4.0.0" + ci-info "^3.2.0" + deepmerge "^4.2.2" + glob "^7.1.3" + graceful-fs "^4.2.9" + jest-circus "^29.6.2" + jest-environment-node "^29.6.2" + jest-get-type "^29.4.3" + jest-regex-util "^29.4.3" + jest-resolve "^29.6.2" + jest-runner "^29.6.2" + jest-util "^29.6.2" + jest-validate "^29.6.2" + micromatch "^4.0.4" + parse-json "^5.2.0" + pretty-format "^29.6.2" + slash "^3.0.0" + strip-json-comments "^3.1.1" + +jest-diff@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.6.2.tgz#c36001e5543e82a0805051d3ceac32e6825c1c46" + integrity sha512-t+ST7CB9GX5F2xKwhwCf0TAR17uNDiaPTZnVymP9lw0lssa9vG+AFyDZoeIHStU3WowFFwT+ky+er0WVl2yGhA== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.4.3" + jest-get-type "^29.4.3" + pretty-format "^29.6.2" + +jest-docblock@^29.4.3: + version "29.4.3" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.4.3.tgz#90505aa89514a1c7dceeac1123df79e414636ea8" + integrity sha512-fzdTftThczeSD9nZ3fzA/4KkHtnmllawWrXO69vtI+L9WjEIuXWs4AmyME7lN5hU7dB0sHhuPfcKofRsUb/2Fg== + dependencies: + detect-newline "^3.0.0" + +jest-each@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.6.2.tgz#c9e4b340bcbe838c73adf46b76817b15712d02ce" + integrity sha512-MsrsqA0Ia99cIpABBc3izS1ZYoYfhIy0NNWqPSE0YXbQjwchyt6B1HD2khzyPe1WiJA7hbxXy77ZoUQxn8UlSw== + dependencies: + "@jest/types" "^29.6.1" + chalk "^4.0.0" + jest-get-type "^29.4.3" + jest-util "^29.6.2" + pretty-format "^29.6.2" + +jest-environment-node@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.6.2.tgz#a9ea2cabff39b08eca14ccb32c8ceb924c8bb1ad" + integrity sha512-YGdFeZ3T9a+/612c5mTQIllvWkddPbYcN2v95ZH24oWMbGA4GGS2XdIF92QMhUhvrjjuQWYgUGW2zawOyH63MQ== + dependencies: + "@jest/environment" "^29.6.2" + "@jest/fake-timers" "^29.6.2" + "@jest/types" "^29.6.1" + "@types/node" "*" + jest-mock "^29.6.2" + jest-util "^29.6.2" + +jest-get-type@^29.4.3: + version "29.4.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5" + integrity sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg== + +jest-haste-map@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.6.2.tgz#298c25ea5255cfad8b723179d4295cf3a50a70d1" + integrity sha512-+51XleTDAAysvU8rT6AnS1ZJ+WHVNqhj1k6nTvN2PYP+HjU3kqlaKQ1Lnw3NYW3bm2r8vq82X0Z1nDDHZMzHVA== + dependencies: + "@jest/types" "^29.6.1" + "@types/graceful-fs" "^4.1.3" + "@types/node" "*" + anymatch "^3.0.3" + fb-watchman "^2.0.0" + graceful-fs "^4.2.9" + jest-regex-util "^29.4.3" + jest-util "^29.6.2" + jest-worker "^29.6.2" + micromatch "^4.0.4" + walker "^1.0.8" + optionalDependencies: + fsevents "^2.3.2" + +jest-leak-detector@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.6.2.tgz#e2b307fee78cab091c37858a98c7e1d73cdf5b38" + integrity sha512-aNqYhfp5uYEO3tdWMb2bfWv6f0b4I0LOxVRpnRLAeque2uqOVVMLh6khnTcE2qJ5wAKop0HcreM1btoysD6bPQ== + dependencies: + jest-get-type "^29.4.3" + pretty-format "^29.6.2" + +jest-matcher-utils@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.6.2.tgz#39de0be2baca7a64eacb27291f0bd834fea3a535" + integrity sha512-4LiAk3hSSobtomeIAzFTe+N8kL6z0JtF3n6I4fg29iIW7tt99R7ZcIFW34QkX+DuVrf+CUe6wuVOpm7ZKFJzZQ== + dependencies: + chalk "^4.0.0" + jest-diff "^29.6.2" + jest-get-type "^29.4.3" + pretty-format "^29.6.2" + +jest-message-util@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.6.2.tgz#af7adc2209c552f3f5ae31e77cf0a261f23dc2bb" + integrity sha512-vnIGYEjoPSuRqV8W9t+Wow95SDp6KPX2Uf7EoeG9G99J2OVh7OSwpS4B6J0NfpEIpfkBNHlBZpA2rblEuEFhZQ== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.6.1" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.6.2" + slash "^3.0.0" + stack-utils "^2.0.3" + +jest-mock@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.6.2.tgz#ef9c9b4d38c34a2ad61010a021866dad41ce5e00" + integrity sha512-hoSv3lb3byzdKfwqCuT6uTscan471GUECqgNYykg6ob0yiAw3zYc7OrPnI9Qv8Wwoa4lC7AZ9hyS4AiIx5U2zg== + dependencies: + "@jest/types" "^29.6.1" + "@types/node" "*" + jest-util "^29.6.2" + +jest-pnp-resolver@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" + integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== + +jest-regex-util@^29.4.3: + version "29.4.3" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.4.3.tgz#a42616141e0cae052cfa32c169945d00c0aa0bb8" + integrity sha512-O4FglZaMmWXbGHSQInfXewIsd1LMn9p3ZXB/6r4FOkyhX2/iP/soMG98jGvk/A3HAN78+5VWcBGO0BJAPRh4kg== + +jest-resolve-dependencies@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.6.2.tgz#36435269b6672c256bcc85fb384872c134cc4cf2" + integrity sha512-LGqjDWxg2fuQQm7ypDxduLu/m4+4Lb4gczc13v51VMZbVP5tSBILqVx8qfWcsdP8f0G7aIqByIALDB0R93yL+w== + dependencies: + jest-regex-util "^29.4.3" + jest-snapshot "^29.6.2" + +jest-resolve@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.6.2.tgz#f18405fe4b50159b7b6d85e81f6a524d22afb838" + integrity sha512-G/iQUvZWI5e3SMFssc4ug4dH0aZiZpsDq9o1PtXTV1210Ztyb2+w+ZgQkB3iOiC5SmAEzJBOHWz6Hvrd+QnNPw== + dependencies: + chalk "^4.0.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.6.2" + jest-pnp-resolver "^1.2.2" + jest-util "^29.6.2" + jest-validate "^29.6.2" + resolve "^1.20.0" + resolve.exports "^2.0.0" + slash "^3.0.0" + +jest-runner@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.6.2.tgz#89e8e32a8fef24781a7c4c49cd1cb6358ac7fc01" + integrity sha512-wXOT/a0EspYgfMiYHxwGLPCZfC0c38MivAlb2lMEAlwHINKemrttu1uSbcGbfDV31sFaPWnWJPmb2qXM8pqZ4w== + dependencies: + "@jest/console" "^29.6.2" + "@jest/environment" "^29.6.2" + "@jest/test-result" "^29.6.2" + "@jest/transform" "^29.6.2" + "@jest/types" "^29.6.1" + "@types/node" "*" + chalk "^4.0.0" + emittery "^0.13.1" + graceful-fs "^4.2.9" + jest-docblock "^29.4.3" + jest-environment-node "^29.6.2" + jest-haste-map "^29.6.2" + jest-leak-detector "^29.6.2" + jest-message-util "^29.6.2" + jest-resolve "^29.6.2" + jest-runtime "^29.6.2" + jest-util "^29.6.2" + jest-watcher "^29.6.2" + jest-worker "^29.6.2" + p-limit "^3.1.0" + source-map-support "0.5.13" + +jest-runtime@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.6.2.tgz#692f25e387f982e89ab83270e684a9786248e545" + integrity sha512-2X9dqK768KufGJyIeLmIzToDmsN0m7Iek8QNxRSI/2+iPFYHF0jTwlO3ftn7gdKd98G/VQw9XJCk77rbTGZnJg== + dependencies: + "@jest/environment" "^29.6.2" + "@jest/fake-timers" "^29.6.2" + "@jest/globals" "^29.6.2" + "@jest/source-map" "^29.6.0" + "@jest/test-result" "^29.6.2" + "@jest/transform" "^29.6.2" + "@jest/types" "^29.6.1" + "@types/node" "*" + chalk "^4.0.0" + cjs-module-lexer "^1.0.0" + collect-v8-coverage "^1.0.0" + glob "^7.1.3" + graceful-fs "^4.2.9" + jest-haste-map "^29.6.2" + jest-message-util "^29.6.2" + jest-mock "^29.6.2" + jest-regex-util "^29.4.3" + jest-resolve "^29.6.2" + jest-snapshot "^29.6.2" + jest-util "^29.6.2" + slash "^3.0.0" + strip-bom "^4.0.0" + +jest-snapshot@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.6.2.tgz#9b431b561a83f2bdfe041e1cab8a6becdb01af9c" + integrity sha512-1OdjqvqmRdGNvWXr/YZHuyhh5DeaLp1p/F8Tht/MrMw4Kr1Uu/j4lRG+iKl1DAqUJDWxtQBMk41Lnf/JETYBRA== + dependencies: + "@babel/core" "^7.11.6" + "@babel/generator" "^7.7.2" + "@babel/plugin-syntax-jsx" "^7.7.2" + "@babel/plugin-syntax-typescript" "^7.7.2" + "@babel/types" "^7.3.3" + "@jest/expect-utils" "^29.6.2" + "@jest/transform" "^29.6.2" + "@jest/types" "^29.6.1" + babel-preset-current-node-syntax "^1.0.0" + chalk "^4.0.0" + expect "^29.6.2" + graceful-fs "^4.2.9" + jest-diff "^29.6.2" + jest-get-type "^29.4.3" + jest-matcher-utils "^29.6.2" + jest-message-util "^29.6.2" + jest-util "^29.6.2" + natural-compare "^1.4.0" + pretty-format "^29.6.2" + semver "^7.5.3" + +jest-util@^29.0.0, jest-util@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.6.2.tgz#8a052df8fff2eebe446769fd88814521a517664d" + integrity sha512-3eX1qb6L88lJNCFlEADKOkjpXJQyZRiavX1INZ4tRnrBVr2COd3RgcTLyUiEXMNBlDU/cgYq6taUS0fExrWW4w== + dependencies: + "@jest/types" "^29.6.1" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + +jest-validate@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.6.2.tgz#25d972af35b2415b83b1373baf1a47bb266c1082" + integrity sha512-vGz0yMN5fUFRRbpJDPwxMpgSXW1LDKROHfBopAvDcmD6s+B/s8WJrwi+4bfH4SdInBA5C3P3BI19dBtKzx1Arg== + dependencies: + "@jest/types" "^29.6.1" + camelcase "^6.2.0" + chalk "^4.0.0" + jest-get-type "^29.4.3" + leven "^3.1.0" + pretty-format "^29.6.2" + +jest-watcher@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.6.2.tgz#77c224674f0620d9f6643c4cfca186d8893ca088" + integrity sha512-GZitlqkMkhkefjfN/p3SJjrDaxPflqxEAv3/ik10OirZqJGYH5rPiIsgVcfof0Tdqg3shQGdEIxDBx+B4tuLzA== + dependencies: + "@jest/test-result" "^29.6.2" + "@jest/types" "^29.6.1" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + emittery "^0.13.1" + jest-util "^29.6.2" + string-length "^4.0.1" + +jest-worker@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.6.2.tgz#682fbc4b6856ad0aa122a5403c6d048b83f3fb44" + integrity sha512-l3ccBOabTdkng8I/ORCkADz4eSMKejTYv1vB/Z83UiubqhC1oQ5Li6dWCyqOIvSifGjUBxuvxvlm6KGK2DtuAQ== + dependencies: + "@types/node" "*" + jest-util "^29.6.2" + merge-stream "^2.0.0" + supports-color "^8.0.0" + +jest@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/jest/-/jest-29.6.2.tgz#3bd55b9fd46a161b2edbdf5f1d1bd0d1eab76c42" + integrity sha512-8eQg2mqFbaP7CwfsTpCxQ+sHzw1WuNWL5UUvjnWP4hx2riGz9fPSzYOaU5q8/GqWn1TfgZIVTqYJygbGbWAANg== + dependencies: + "@jest/core" "^29.6.2" + "@jest/types" "^29.6.1" + import-local "^3.0.2" + jest-cli "^29.6.2" + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.13.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^2.2.2, json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.memoize@4.x: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + +make-error@1.x: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +makeerror@1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" + integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== + dependencies: + tmpl "1.0.5" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +merge2@^1.3.0, merge2@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +natural-compare-lite@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" + integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== + +node-releases@^2.0.13: + version "2.0.13" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d" + integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ== + +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +optionator@^0.9.3: + version "0.9.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" + integrity sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg== + dependencies: + "@aashutoshrathi/word-wrap" "^1.2.3" + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2, p-limit@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pirates@^4.0.4: + version "4.0.6" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" + integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== + +pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +pretty-format@^29.0.0, pretty-format@^29.6.2: + version "29.6.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.6.2.tgz#3d5829261a8a4d89d8b9769064b29c50ed486a47" + integrity sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg== + dependencies: + "@jest/schemas" "^29.6.0" + ansi-styles "^5.0.0" + react-is "^18.0.0" + +prompts@^2.0.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + +punycode@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" + integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== + +pure-rand@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.2.tgz#a9c2ddcae9b68d736a8163036f088a2781c8b306" + integrity sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ== + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +react-is@^18.0.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" + integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve.exports@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.2.tgz#f8c934b8e6a13f539e38b7098e2e36134f01e800" + integrity sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg== + +resolve@^1.20.0: + version "1.22.4" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.4.tgz#1dc40df46554cdaf8948a486a10f6ba1e2026c34" + integrity sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +reusify@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +semver@^6.3.0, semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.5.3, semver@^7.5.4: + version "7.5.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" + integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== + dependencies: + lru-cache "^6.0.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^3.0.3, signal-exit@^3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +source-map-support@0.5.13: + version "0.5.13" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" + integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0, source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +stack-utils@^2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + +string-length@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" + integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== + dependencies: + char-regex "^1.0.2" + strip-ansi "^6.0.0" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-bom@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" + integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== + +tmpl@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +ts-api-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.1.tgz#8144e811d44c749cd65b2da305a032510774452d" + integrity sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A== + +ts-jest@^29.1.1: + version "29.1.1" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.1.tgz#f58fe62c63caf7bfcc5cc6472082f79180f0815b" + integrity sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA== + dependencies: + bs-logger "0.x" + fast-json-stable-stringify "2.x" + jest-util "^29.0.0" + json5 "^2.2.3" + lodash.memoize "4.x" + make-error "1.x" + semver "^7.5.3" + yargs-parser "^21.0.1" + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +typescript@5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.2.tgz#891e1a90c5189d8506af64b9ef929fca99ba1ee5" + integrity sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw== + +update-browserslist-db@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940" + integrity sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA== + dependencies: + escalade "^3.1.1" + picocolors "^1.0.0" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +v8-to-istanbul@^9.0.1: + version "9.1.0" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz#1b83ed4e397f58c85c266a570fc2558b5feb9265" + integrity sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.12" + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^1.6.0" + +walker@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" + integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== + dependencies: + makeerror "1.0.12" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +write-file-atomic@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" + integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== + dependencies: + imurmurhash "^0.1.4" + signal-exit "^3.0.7" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + +yargs-parser@^21.0.1, yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.3.1: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== From 8578d0f2fc173a98d536775ccaa71462b85fbc1c Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 7 Aug 2023 17:52:58 +0100 Subject: [PATCH 27/47] Introduce coValue nicknames and change how agentIDs work --- src/coValue.test.ts | 12 +++--- src/coValue.ts | 36 +++++++++-------- src/contentType.test.ts | 10 ++--- src/crypto.test.ts | 36 ++++++++--------- src/node.ts | 1 + src/permissions.test.ts | 75 ++++++++++++++++++++--------------- src/permissions.ts | 3 +- src/sync.test.ts | 88 +++++++++++++++++++++++++++++++---------- 8 files changed, 161 insertions(+), 100 deletions(-) diff --git a/src/coValue.test.ts b/src/coValue.test.ts index fc78fba8c..618eaaaea 100644 --- a/src/coValue.test.ts +++ b/src/coValue.test.ts @@ -10,7 +10,7 @@ import { LocalNode } from "./node"; import { sign } from "./crypto"; test("Can create coValue with new agent credentials and add transaction to it", () => { - const agentCredential = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential("agent1"); const node = new LocalNode( agentCredential, newRandomSessionID(getAgentID(getAgent(agentCredential))) @@ -48,9 +48,8 @@ test("Can create coValue with new agent credentials and add transaction to it", }); test("transactions with wrong signature are rejected", () => { - const agent = newRandomAgentCredential(); - const wrongAgent = newRandomAgentCredential(); - const agentCredential = newRandomAgentCredential(); + const wrongAgent = newRandomAgentCredential("wrongAgent"); + const agentCredential = newRandomAgentCredential("agent1"); const node = new LocalNode( agentCredential, newRandomSessionID(getAgentID(getAgent(agentCredential))) @@ -88,8 +87,7 @@ test("transactions with wrong signature are rejected", () => { }); test("transactions with correctly signed, but wrong hash are rejected", () => { - const agent = newRandomAgentCredential(); - const agentCredential = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential("agent1"); const node = new LocalNode( agentCredential, newRandomSessionID(getAgentID(getAgent(agentCredential))) @@ -131,7 +129,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => { node.ownSessionID, [transaction], expectedNewHash, - sign(agent.signatorySecret, expectedNewHash) + sign(agentCredential.signatorySecret, expectedNewHash) ) ).toBe(false); }); diff --git a/src/coValue.ts b/src/coValue.ts index a6edc8753..5b2093de7 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -33,27 +33,32 @@ import { import { LocalNode } from "./node"; import { CoValueKnownState, NewContentMessage } from "./sync"; -export type RawCoValueID = `coval_${string}`; +export type RawCoValueID = `co_z${string}` | `co_${string}_z${string}`; export type CoValueHeader = { type: ContentType["type"]; ruleset: RulesetDef; meta: JsonValue; + publicNickname?: string; }; function coValueIDforHeader(header: CoValueHeader): RawCoValueID { const hash = shortHash(header); - return `coval_${hash.slice("shortHash_".length)}`; + if (header.publicNickname) { + return `co_${header.publicNickname}_z${hash.slice("shortHash_z".length)}`; + } else { + return `co_z${hash.slice("shortHash_z".length)}`; + } } -export type SessionID = `session_${string}_${AgentID}`; +export type SessionID = `${AgentID}_session_z${string}`; export function agentIDfromSessionID(sessionID: SessionID): AgentID { - return `agent_${sessionID.substring(sessionID.lastIndexOf("_") + 1)}`; + return sessionID.split("_session")[0] as AgentID; } export function newRandomSessionID(agentID: AgentID): SessionID { - return `session_${base58.encode(randomBytes(8))}_${agentID}`; + return `${agentID}_session_z${base58.encode(randomBytes(8))}`; } type SessionLog = { @@ -482,25 +487,26 @@ export class CoValue { return this.header.ruleset.type === "team" ? expectTeamContent(this.getCurrentContent()) .keys() - .filter((k): k is AgentID => k.startsWith("agent_")) - .map((agent) => agentIDAsCoValueID(agent)) + .filter((k): k is AgentID => k.startsWith("co_agent")) : this.header.ruleset.type === "ownedByTeam" ? [this.header.ruleset.team] : []; } } -export type AgentID = `agent_${string}`; +export type AgentID = `co_agent${string}_z${string}`; export type Agent = { signatoryID: SignatoryID; recipientID: RecipientID; + publicNickname?: string; }; export function getAgent(agentCredential: AgentCredential) { return { signatoryID: getSignatoryID(agentCredential.signatorySecret), recipientID: getRecipientID(agentCredential.recipientSecret), + publicNickname: agentCredential.publicNickname, }; } @@ -513,28 +519,24 @@ export function getAgentCoValueHeader(agent: Agent): CoValueHeader { initialRecipientID: agent.recipientID, }, meta: null, + publicNickname: "agent" + agent.publicNickname?.slice(0, 1).toUpperCase() + agent.publicNickname?.slice(1), }; } export function getAgentID(agent: Agent): AgentID { - return `agent_${coValueIDforHeader(getAgentCoValueHeader(agent)).slice( - "coval_".length - )}`; -} - -export function agentIDAsCoValueID(agentID: AgentID): RawCoValueID { - return `coval_${agentID.substring("agent_".length)}`; + return coValueIDforHeader(getAgentCoValueHeader(agent)) as AgentID; } export type AgentCredential = { signatorySecret: SignatorySecret; recipientSecret: RecipientSecret; + publicNickname?: string; }; -export function newRandomAgentCredential(): AgentCredential { +export function newRandomAgentCredential(publicNickname: string): AgentCredential { const signatorySecret = newRandomSignatory(); const recipientSecret = newRandomRecipient(); - return { signatorySecret, recipientSecret }; + return { signatorySecret, recipientSecret, publicNickname }; } // type Role = "admin" | "writer" | "reader"; diff --git a/src/contentType.test.ts b/src/contentType.test.ts index c1f313a89..53f253e30 100644 --- a/src/contentType.test.ts +++ b/src/contentType.test.ts @@ -8,7 +8,7 @@ import { import { LocalNode } from "./node"; test("Empty COJSON Map works", () => { - const agentCredential = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential("agent1"); const node = new LocalNode( agentCredential, newRandomSessionID(getAgentID(getAgent(agentCredential))) @@ -32,7 +32,7 @@ test("Empty COJSON Map works", () => { }); test("Can insert and delete Map entries in edit()", () => { - const agentCredential = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential("agent1"); const node = new LocalNode( agentCredential, newRandomSessionID(getAgentID(getAgent(agentCredential))) @@ -64,7 +64,7 @@ test("Can insert and delete Map entries in edit()", () => { }); test("Can get map entry values at different points in time", () => { - const agentCredential = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential("agent1"); const node = new LocalNode( agentCredential, newRandomSessionID(getAgentID(getAgent(agentCredential))) @@ -103,7 +103,7 @@ test("Can get map entry values at different points in time", () => { }); test("Can get all historic values of key", () => { - const agentCredential = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential("agent1"); const node = new LocalNode( agentCredential, newRandomSessionID(getAgentID(getAgent(agentCredential))) @@ -160,7 +160,7 @@ test("Can get all historic values of key", () => { }); test("Can get last tx ID for a key", () => { - const agentCredential = newRandomAgentCredential(); + const agentCredential = newRandomAgentCredential("agent1"); const node = new LocalNode( agentCredential, newRandomSessionID(getAgentID(getAgent(agentCredential))) diff --git a/src/crypto.test.ts b/src/crypto.test.ts index 3226ff6b0..fa04d4695 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -49,8 +49,8 @@ test("Sealing round-trips, but invalid receiver can't unseal", () => { const recipient3 = newRandomRecipient(); const nOnceMaterial = { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + in: "co_zTEST", + tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 }, } as const; const sealed = seal( @@ -106,23 +106,23 @@ test("Encryption for transactions round-trips", () => { const { secret } = newRandomKeySecret(); const encrypted1 = encryptForTransaction({ a: "hello" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + in: "co_zTEST", + tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 }, }); const encrypted2 = encryptForTransaction({ b: "world" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + in: "co_zTEST", + tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 }, }); const decrypted1 = decryptForTransaction(encrypted1, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + in: "co_zTEST", + tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 }, }); const decrypted2 = decryptForTransaction(encrypted2, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + in: "co_zTEST", + tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 }, }); expect([decrypted1, decrypted2]).toEqual([{ a: "hello" }, { b: "world" }]); @@ -133,23 +133,23 @@ test("Encryption for transactions doesn't decrypt with a wrong key", () => { const { secret: secret2 } = newRandomKeySecret(); const encrypted1 = encryptForTransaction({ a: "hello" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + in: "co_zTEST", + tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 }, }); const encrypted2 = encryptForTransaction({ b: "world" }, secret, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + in: "co_zTEST", + tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 }, }); const decrypted1 = decryptForTransaction(encrypted1, secret2, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 0 }, + in: "co_zTEST", + tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 }, }); const decrypted2 = decryptForTransaction(encrypted2, secret2, { - in: "coval_zTEST", - tx: { sessionID: "session_zTEST_agent_zTEST", txIndex: 1 }, + in: "co_zTEST", + tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 }, }); expect([decrypted1, decrypted2]).toEqual([undefined, undefined]); diff --git a/src/node.ts b/src/node.ts index df50bd0f9..729171ce5 100644 --- a/src/node.ts +++ b/src/node.ts @@ -89,6 +89,7 @@ export class LocalNode { type: "comap", ruleset: { type: "team", initialAdmin: this.agentID }, meta: null, + publicNickname: "team", }); let teamContent = expectTeamContent(teamCoValue.getCurrentContent()); diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 270c267b0..24742d980 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -17,7 +17,7 @@ import { function teamWithTwoAdmins() { const { team, admin, adminID } = newTeam(); - const otherAdmin = newRandomAgentCredential(); + const otherAdmin = newRandomAgentCredential("otherAdmin"); const otherAdminID = getAgentID(getAgent(otherAdmin)); let content = expectTeamContent(team.getCurrentContent()); @@ -38,7 +38,7 @@ function teamWithTwoAdmins() { } function newTeam() { - const admin = newRandomAgentCredential(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -47,6 +47,7 @@ function newTeam() { type: "comap", ruleset: { type: "team", initialAdmin: adminID }, meta: null, + publicNickname: "team" }); const teamContent = expectTeamContent(team.getCurrentContent()); @@ -64,7 +65,7 @@ test("Initial admin can add another admin to a team", () => { }); function newTeamHighLevel() { - const admin = newRandomAgentCredential(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -77,7 +78,7 @@ function newTeamHighLevel() { function teamWithTwoAdminsHighLevel() { const { admin, adminID, node, team } = newTeamHighLevel(); - const otherAdmin = newRandomAgentCredential(); + const otherAdmin = newRandomAgentCredential("otherAdmin"); const otherAdminID = getAgentID(getAgent(otherAdmin)); node.addKnownAgent(getAgent(otherAdmin)); @@ -103,7 +104,7 @@ test("Added admin can add a third admin to a team", () => { expect(otherContent.get(otherAdminID)).toEqual("admin"); - const thirdAdmin = newRandomAgentCredential(); + const thirdAdmin = newRandomAgentCredential("admin"); const thirdAdminID = getAgentID(getAgent(thirdAdmin)); otherContent.edit((editable) => { @@ -125,7 +126,7 @@ test("Added adming can add a third admin to a team (high level)", () => { newRandomSessionID(otherAdminID) ); - const thirdAdmin = newRandomAgentCredential(); + const thirdAdmin = newRandomAgentCredential("admin"); const thirdAdminID = getAgentID(getAgent(thirdAdmin)); node.addKnownAgent(getAgent(thirdAdmin)); @@ -187,7 +188,7 @@ test("Admins can't demote other admins in a team (high level)", () => { test("Admins an add writers to a team, who can't add admins, writers, or readers", () => { const { team } = newTeam(); - const writer = newRandomAgentCredential(); + const writer = newRandomAgentCredential("writer"); const writerID = getAgentID(getAgent(writer)); let teamContent = expectTeamContent(team.getCurrentContent()); @@ -211,7 +212,7 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers expect(teamContentAsWriter.get(writerID)).toEqual("writer"); - const otherAgent = newRandomAgentCredential(); + const otherAgent = newRandomAgentCredential("otherAgent"); const otherAgentID = getAgentID(getAgent(otherAgent)); teamContentAsWriter.edit((editable) => { @@ -233,7 +234,7 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers test("Admins an add writers to a team, who can't add admins, writers, or readers (high level)", () => { const { team, node } = newTeamHighLevel(); - const writer = newRandomAgentCredential(); + const writer = newRandomAgentCredential("writer"); const writerID = getAgentID(getAgent(writer)); node.addKnownAgent(getAgent(writer)); @@ -248,7 +249,7 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers expect(teamAsWriter.teamMap.get(writerID)).toEqual("writer"); - const otherAgent = newRandomAgentCredential(); + const otherAgent = newRandomAgentCredential("otherAgent"); const otherAgentID = getAgentID(getAgent(otherAgent)); node.addKnownAgent(getAgent(otherAgent)); @@ -268,7 +269,7 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers test("Admins can add readers to a team, who can't add admins, writers, or readers", () => { const { team } = newTeam(); - const reader = newRandomAgentCredential(); + const reader = newRandomAgentCredential("reader"); const readerID = getAgentID(getAgent(reader)); let teamContent = expectTeamContent(team.getCurrentContent()); @@ -292,7 +293,7 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader expect(teamContentAsReader.get(readerID)).toEqual("reader"); - const otherAgent = newRandomAgentCredential(); + const otherAgent = newRandomAgentCredential("otherAgent"); const otherAgentID = getAgentID(getAgent(otherAgent)); teamContentAsReader.edit((editable) => { @@ -314,7 +315,7 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader test("Admins can add readers to a team, who can't add admins, writers, or readers (high level)", () => { const { team, node } = newTeamHighLevel(); - const reader = newRandomAgentCredential(); + const reader = newRandomAgentCredential("reader"); const readerID = getAgentID(getAgent(reader)); node.addKnownAgent(getAgent(reader)); @@ -329,7 +330,7 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader expect(teamAsReader.teamMap.get(readerID)).toEqual("reader"); - const otherAgent = newRandomAgentCredential(); + const otherAgent = newRandomAgentCredential("otherAgent"); const otherAgentID = getAgentID(getAgent(otherAgent)); node.addKnownAgent(getAgent(otherAgent)); @@ -354,6 +355,7 @@ test("Admins can write to an object that is owned by their team", () => { type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + publicNickname: "childObject" }); let childContent = expectMap(childObject.getCurrentContent()); @@ -384,7 +386,7 @@ test("Admins can write to an object that is owned by their team (high level)", ( test("Writers can write to an object that is owned by their team", () => { const { node, team } = newTeam(); - const writer = newRandomAgentCredential(); + const writer = newRandomAgentCredential("writer"); const writerID = getAgentID(getAgent(writer)); expectTeamContent(team.getCurrentContent()).edit((editable) => { @@ -396,6 +398,7 @@ test("Writers can write to an object that is owned by their team", () => { type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + publicNickname: "childObject" }); const childObjectAsWriter = childObject.testWithDifferentCredentials( @@ -420,7 +423,7 @@ test("Writers can write to an object that is owned by their team", () => { test("Writers can write to an object that is owned by their team (high level)", () => { const { node, team } = newTeamHighLevel(); - const writer = newRandomAgentCredential(); + const writer = newRandomAgentCredential("writer"); const writerID = getAgentID(getAgent(writer)); node.addKnownAgent(getAgent(writer)); @@ -446,7 +449,7 @@ test("Writers can write to an object that is owned by their team (high level)", test("Readers can not write to an object that is owned by their team", () => { const { node, team } = newTeam(); - const reader = newRandomAgentCredential(); + const reader = newRandomAgentCredential("reader"); const readerID = getAgentID(getAgent(reader)); expectTeamContent(team.getCurrentContent()).edit((editable) => { @@ -458,6 +461,7 @@ test("Readers can not write to an object that is owned by their team", () => { type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + publicNickname: "childObject" }); const childObjectAsReader = childObject.testWithDifferentCredentials( @@ -482,7 +486,7 @@ test("Readers can not write to an object that is owned by their team", () => { test("Readers can not write to an object that is owned by their team (high level)", () => { const { node, team } = newTeamHighLevel(); - const reader = newRandomAgentCredential(); + const reader = newRandomAgentCredential("reader"); const readerID = getAgentID(getAgent(reader)); node.addKnownAgent(getAgent(reader)); @@ -533,6 +537,7 @@ test("Admins can set team read key and then use it to create and read private tr type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + publicNickname: "childObject" }); let childContent = expectMap(childObject.getCurrentContent()); @@ -562,7 +567,7 @@ test("Admins can set team read key and then use it to create and read private tr test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects", () => { const { node, team, admin } = newTeam(); - const writer = newRandomAgentCredential(); + const writer = newRandomAgentCredential("writer"); const writerID = getAgentID(getAgent(writer)); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); @@ -591,6 +596,7 @@ test("Admins can set team read key and then writers can use it to create and rea type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + publicNickname: "childObject" }); const childObjectAsWriter = childObject.testWithDifferentCredentials( @@ -617,7 +623,7 @@ test("Admins can set team read key and then writers can use it to create and rea test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects (high level)", () => { const { node, team, admin } = newTeamHighLevel(); - const writer = newRandomAgentCredential(); + const writer = newRandomAgentCredential("writer"); const writerID = getAgentID(getAgent(writer)); node.addKnownAgent(getAgent(writer)); @@ -643,7 +649,7 @@ test("Admins can set team read key and then writers can use it to create and rea test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read", () => { const { node, team, admin } = newTeam(); - const reader = newRandomAgentCredential(); + const reader = newRandomAgentCredential("reader"); const readerID = getAgentID(getAgent(reader)); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); @@ -672,6 +678,7 @@ test("Admins can set team read key and then use it to create private transaction type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + publicNickname: "childObject" }); expectMap(childObject.getCurrentContent()).edit((editable) => { @@ -696,7 +703,7 @@ test("Admins can set team read key and then use it to create private transaction test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read (high level)", () => { const { node, team, admin } = newTeamHighLevel(); - const reader = newRandomAgentCredential(); + const reader = newRandomAgentCredential("reader"); const readerID = getAgentID(getAgent(reader)); node.addKnownAgent(getAgent(reader)); @@ -722,9 +729,9 @@ test("Admins can set team read key and then use it to create private transaction test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key", () => { const { node, team, admin } = newTeam(); - const reader1 = newRandomAgentCredential(); + const reader1 = newRandomAgentCredential("reader1"); const reader1ID = getAgentID(getAgent(reader1)); - const reader2 = newRandomAgentCredential(); + const reader2 = newRandomAgentCredential("reader2"); const reader2ID = getAgentID(getAgent(reader2)); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); @@ -772,6 +779,7 @@ test("Admins can set team read key and then use it to create private transaction type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + publicNickname: "childObject" }); expectMap(childObject.getCurrentContent()).edit((editable) => { @@ -809,9 +817,9 @@ test("Admins can set team read key and then use it to create private transaction test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key (high level)", () => { const { node, team, admin } = newTeamHighLevel(); - const reader1 = newRandomAgentCredential(); + const reader1 = newRandomAgentCredential("reader1"); const reader1ID = getAgentID(getAgent(reader1)); - const reader2 = newRandomAgentCredential(); + const reader2 = newRandomAgentCredential("reader2"); const reader2ID = getAgentID(getAgent(reader2)); node.addKnownAgent(getAgent(reader1)); @@ -872,6 +880,7 @@ test("Admins can set team read key, make a private transaction in an owned objec type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + publicNickname: "childObject" }); let childContent = expectMap(childObject.getCurrentContent()); @@ -951,6 +960,7 @@ test("Admins can set team read key, make a private transaction in an owned objec type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + publicNickname: "childObject" }); const teamContent = expectTeamContent(team.getCurrentContent()); @@ -984,7 +994,7 @@ test("Admins can set team read key, make a private transaction in an owned objec childContent = expectMap(childObject.getCurrentContent()); expect(childContent.get("foo")).toEqual("bar"); - const reader = newRandomAgentCredential(); + const reader = newRandomAgentCredential("reader"); const readerID = getAgentID(getAgent(reader)); const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret(); @@ -1064,7 +1074,7 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey); - const reader = newRandomAgentCredential(); + const reader = newRandomAgentCredential("reader"); const readerID = getAgentID(getAgent(reader)); node.addKnownAgent(getAgent(reader)); @@ -1093,13 +1103,14 @@ test("Admins can set team read rey, make a private transaction in an owned objec type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + publicNickname: "childObject" }); const teamContent = expectTeamContent(team.getCurrentContent()); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); - const reader = newRandomAgentCredential(); + const reader = newRandomAgentCredential("reader"); const readerID = getAgentID(getAgent(reader)); - const reader2 = newRandomAgentCredential(); + const reader2 = newRandomAgentCredential("reader2"); const reader2ID = getAgentID(getAgent(reader)); teamContent.edit((editable) => { @@ -1229,9 +1240,9 @@ test("Admins can set team read rey, make a private transaction in an owned objec const secondReadKey = childObject.coValue.getCurrentReadKey(); - const reader = newRandomAgentCredential(); + const reader = newRandomAgentCredential("reader"); const readerID = getAgentID(getAgent(reader)); - const reader2 = newRandomAgentCredential(); + const reader2 = newRandomAgentCredential("reader2"); const reader2ID = getAgentID(getAgent(reader2)); node.addKnownAgent(getAgent(reader)); diff --git a/src/permissions.ts b/src/permissions.ts index 8f0a0d0bc..214e11942 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -271,7 +271,7 @@ export class Team { rotateReadKey() { const currentlyPermittedReaders = this.teamMap.keys().filter((key) => { - if (key.startsWith("agent_")) { + if (key.startsWith("co_agent")) { const role = this.teamMap.get(key); return ( role === "admin" || role === "writer" || role === "reader" @@ -342,6 +342,7 @@ export class Team { team: this.teamMap.id, }, meta: meta || null, + publicNickname: "map", }) .getCurrentContent() as CoMap; } diff --git a/src/sync.test.ts b/src/sync.test.ts index 4bd41715d..248947761 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -1,6 +1,5 @@ import { AgentID, - agentIDAsCoValueID, getAgent, getAgentID, newRandomAgentCredential, @@ -19,7 +18,7 @@ import { test( "Node replies with initial tx and header to empty subscribe", async () => { - const admin = newRandomAgentCredential(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -74,6 +73,7 @@ test( type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, + publicNickname: "map", }, newContent: { [node.ownSessionID]: { @@ -103,7 +103,7 @@ test( ); test("Node replies with only new tx to subscribe with some known state", async () => { - const admin = newRandomAgentCredential(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -188,7 +188,7 @@ test.todo( ); test("After subscribing, node sends own known state and new txs to peer", async () => { - const admin = newRandomAgentCredential(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -309,7 +309,7 @@ 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(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -391,7 +391,7 @@ 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(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -496,7 +496,7 @@ 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(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -525,7 +525,7 @@ test("If we add a peer, but it never subscribes to a coValue, it won't get any m }); test("If we add a server peer, all updates to all coValues are sent to it, even if it doesn't subscribe", async () => { - const admin = newRandomAgentCredential(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -547,7 +547,7 @@ 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: agentIDAsCoValueID(adminID), + coValueID: adminID, }); expect((await reader.read()).value).toMatchObject({ action: "subscribe", @@ -602,7 +602,7 @@ 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(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -624,7 +624,7 @@ 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: agentIDAsCoValueID(adminID), + coValueID: adminID, }); expect((await reader.read()).value).toMatchObject({ action: "subscribe", @@ -658,7 +658,7 @@ test.todo( ); test("When we connect a new server peer, we try to sync all existing coValues to it", async () => { - const admin = newRandomAgentCredential(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -696,7 +696,7 @@ 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(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node = new LocalNode(admin, newRandomSessionID(adminID)); @@ -740,7 +740,7 @@ 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(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node1 = new LocalNode(admin, newRandomSessionID(adminID)); @@ -778,7 +778,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: agentIDAsCoValueID(adminID), + coValueID: adminID, }); const teamSubscribeMsg = await from1.read(); expect(teamSubscribeMsg.value).toMatchObject({ @@ -862,7 +862,7 @@ 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(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const node1 = new LocalNode(admin, newRandomSessionID(adminID)); @@ -891,7 +891,7 @@ 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(); + const admin = newRandomAgentCredential("admin"); const adminID = getAgentID(getAgent(admin)); const client1 = new LocalNode(admin, newRandomSessionID(adminID)); @@ -903,7 +903,55 @@ test("Can sync a coValue through a server to another client", async () => { editable.set("hello", "world", "trusting"); }); - const server = new LocalNode(admin, newRandomSessionID(adminID)); + const serverUser = newRandomAgentCredential("serverUser"); + const serverUserID = getAgentID(getAgent(serverUser)); + + const server = new LocalNode(serverUser, newRandomSessionID(serverUserID)); + + const [serverAsPeer, client1AsPeer] = connectedPeers("server", "client1", { + trace: true, + peer1role: "server", + peer2role: "client", + }); + + client1.sync.addPeer(serverAsPeer); + server.sync.addPeer(client1AsPeer); + + const client2 = new LocalNode(admin, newRandomSessionID(adminID)); + + const [serverAsOtherPeer, client2AsPeer] = connectedPeers( + "server", + "client2", + { trace: true, peer1role: "server", peer2role: "client" } + ); + + client2.sync.addPeer(serverAsOtherPeer); + server.sync.addPeer(client2AsPeer); + + const mapOnClient2 = await client2.loadCoValue(map.coValue.id); + + expect(expectMap(mapOnClient2.getCurrentContent()).get("hello")).toEqual( + "world" + ); +}); + +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 client1 = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = client1.createTeam(); + + const map = team.createMap(); + map.edit((editable) => { + editable.set("hello", "world", "private"); + }); + + const serverUser = newRandomAgentCredential("serverUser"); + const serverUserID = getAgentID(getAgent(serverUser)); + + const server = new LocalNode(serverUser, newRandomSessionID(serverUserID)); const [serverAsPeer, client1AsPeer] = connectedPeers("server", "client1", { trace: true, @@ -942,7 +990,7 @@ function teamContentEx(team: Team) { function admContEx(adminID: AgentID) { return { action: "newContent", - coValueID: agentIDAsCoValueID(adminID), + coValueID: adminID, }; } @@ -956,7 +1004,7 @@ function teamStateEx(team: Team) { function admStateEx(adminID: AgentID) { return { action: "tellKnownState", - coValueID: agentIDAsCoValueID(adminID), + coValueID: adminID, }; } From 9c357bb5d77b7c68980368e45fe6cfbb8064bb10 Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 7 Aug 2023 19:42:02 +0100 Subject: [PATCH 28/47] Better management of locally created agents --- src/coValue.ts | 26 +++--- src/node.ts | 38 +++++--- src/permissions.test.ts | 189 ++++++++++++++++++---------------------- src/permissions.ts | 4 +- 4 files changed, 132 insertions(+), 125 deletions(-) diff --git a/src/coValue.ts b/src/coValue.ts index 5b2093de7..bc3c70b18 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -45,7 +45,9 @@ export type CoValueHeader = { function coValueIDforHeader(header: CoValueHeader): RawCoValueID { const hash = shortHash(header); if (header.publicNickname) { - return `co_${header.publicNickname}_z${hash.slice("shortHash_z".length)}`; + return `co_${header.publicNickname}_z${hash.slice( + "shortHash_z".length + )}`; } else { return `co_z${hash.slice("shortHash_z".length)}`; } @@ -151,8 +153,10 @@ export class CoValue { newHash: Hash, newSignature: Signature ): boolean { - const signatoryID = - this.node.knownAgents[agentIDfromSessionID(sessionID)]?.signatoryID; + const signatoryID = this.node.expectAgentLoaded( + agentIDfromSessionID(sessionID), + "Expected to know signatory of transaction" + ).signatoryID; if (!signatoryID) { console.warn("Unknown agent", agentIDfromSessionID(sessionID)); @@ -362,11 +366,10 @@ export class CoValue { for (const entry of readKeyHistory) { if (entry.value?.keyID === keyID) { const revealer = agentIDfromSessionID(entry.txID.sessionID); - const revealerAgent = this.node.knownAgents[revealer]; - - if (!revealerAgent) { - throw new Error("Unknown revealer"); - } + const revealerAgent = this.node.expectAgentLoaded( + revealer, + "Expected to know revealer" + ); const secret = openAs( entry.value.revelation, @@ -519,7 +522,8 @@ export function getAgentCoValueHeader(agent: Agent): CoValueHeader { initialRecipientID: agent.recipientID, }, meta: null, - publicNickname: "agent" + agent.publicNickname?.slice(0, 1).toUpperCase() + agent.publicNickname?.slice(1), + publicNickname: + "agent" + (agent.publicNickname ? `-${agent.publicNickname}` : ""), }; } @@ -533,7 +537,9 @@ export type AgentCredential = { publicNickname?: string; }; -export function newRandomAgentCredential(publicNickname: string): AgentCredential { +export function newRandomAgentCredential( + publicNickname: string +): AgentCredential { const signatorySecret = newRandomSignatory(); const recipientSecret = newRandomRecipient(); return { signatorySecret, recipientSecret, publicNickname }; diff --git a/src/node.ts b/src/node.ts index 729171ce5..9e53e817f 100644 --- a/src/node.ts +++ b/src/node.ts @@ -11,6 +11,7 @@ import { getAgentCoValueHeader, CoValueHeader, agentIDfromSessionID, + newRandomAgentCredential, } from "./coValue"; import { Team, expectTeamContent } from "./permissions"; import { SyncManager } from "./sync"; @@ -20,7 +21,6 @@ export class LocalNode { agentCredential: AgentCredential; agentID: AgentID; ownSessionID: SessionID; - knownAgents: { [key: AgentID]: Agent } = {}; sync = new SyncManager(this); constructor(agentCredential: AgentCredential, ownSessionID: SessionID) { @@ -28,7 +28,6 @@ export class LocalNode { const agent = getAgent(agentCredential); const agentID = getAgentID(agent); this.agentID = agentID; - this.knownAgents[agentID] = agent; this.ownSessionID = ownSessionID; const agentCoValue = new CoValue(getAgentCoValueHeader(agent), this); @@ -79,9 +78,33 @@ export class LocalNode { return entry.coValue; } - addKnownAgent(agent: Agent) { - const agentID = getAgentID(agent); - this.knownAgents[agentID] = agent; + createAgent(publicNickname: string): AgentCredential { + const agentCredential = newRandomAgentCredential(publicNickname); + + this.createCoValue(getAgentCoValueHeader(getAgent(agentCredential))); + + return agentCredential; + } + + expectAgentLoaded(id: AgentID, expectation?: string): Agent { + const coValue = this.expectCoValueLoaded( + id, + expectation + ); + + if (coValue.header.type !== "comap" || coValue.header.ruleset.type !== "agent") { + throw new Error( + `${ + expectation ? expectation + ": " : "" + }CoValue ${id} is not an agent` + ); + } + + return { + recipientID: coValue.header.ruleset.initialRecipientID, + signatoryID: coValue.header.ruleset.initialSignatoryID, + publicNickname: coValue.header.publicNickname?.replace("agent-", ""), + } } createTeam(): Team { @@ -143,11 +166,6 @@ export class LocalNode { .filter((x): x is Exclude => !!x) ); - newNode.knownAgents = { - ...this.knownAgents, - [agentIDfromSessionID(ownSessionID)]: getAgent(agentCredential), - }; - return newNode; } } diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 24742d980..d5cf4f080 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -15,9 +15,9 @@ import { } from "./crypto"; function teamWithTwoAdmins() { - const { team, admin, adminID } = newTeam(); + const { team, admin, adminID, node } = newTeam(); - const otherAdmin = newRandomAgentCredential("otherAdmin"); + const otherAdmin = node.createAgent("otherAdmin"); const otherAdminID = getAgentID(getAgent(otherAdmin)); let content = expectTeamContent(team.getCurrentContent()); @@ -34,7 +34,7 @@ function teamWithTwoAdmins() { } expect(content.get(otherAdminID)).toEqual("admin"); - return { team, admin, adminID, otherAdmin, otherAdminID }; + return { team, admin, adminID, otherAdmin, otherAdminID, node }; } function newTeam() { @@ -47,7 +47,7 @@ function newTeam() { type: "comap", ruleset: { type: "team", initialAdmin: adminID }, meta: null, - publicNickname: "team" + publicNickname: "team", }); const teamContent = expectTeamContent(team.getCurrentContent()); @@ -78,11 +78,9 @@ function newTeamHighLevel() { function teamWithTwoAdminsHighLevel() { const { admin, adminID, node, team } = newTeamHighLevel(); - const otherAdmin = newRandomAgentCredential("otherAdmin"); + const otherAdmin = node.createAgent("otherAdmin"); const otherAdminID = getAgentID(getAgent(otherAdmin)); - node.addKnownAgent(getAgent(otherAdmin)); - team.addMember(otherAdminID, "admin"); return { admin, adminID, node, team, otherAdmin, otherAdminID }; @@ -93,7 +91,7 @@ test("Initial admin can add another admin to a team (high level)", () => { }); test("Added admin can add a third admin to a team", () => { - const { team, otherAdmin, otherAdminID } = teamWithTwoAdmins(); + const { team, otherAdmin, otherAdminID, node } = teamWithTwoAdmins(); const teamAsOtherAdmin = team.testWithDifferentCredentials( otherAdmin, @@ -104,7 +102,7 @@ test("Added admin can add a third admin to a team", () => { expect(otherContent.get(otherAdminID)).toEqual("admin"); - const thirdAdmin = newRandomAgentCredential("admin"); + const thirdAdmin = node.createAgent("thirdAdmin"); const thirdAdminID = getAgentID(getAgent(thirdAdmin)); otherContent.edit((editable) => { @@ -126,11 +124,9 @@ test("Added adming can add a third admin to a team (high level)", () => { newRandomSessionID(otherAdminID) ); - const thirdAdmin = newRandomAgentCredential("admin"); + const thirdAdmin = node.createAgent("thirdAdmin"); const thirdAdminID = getAgentID(getAgent(thirdAdmin)); - node.addKnownAgent(getAgent(thirdAdmin)); - teamAsOtherAdmin.addMember(thirdAdminID, "admin"); expect(teamAsOtherAdmin.teamMap.get(thirdAdminID)).toEqual("admin"); @@ -187,8 +183,8 @@ test("Admins can't demote other admins in a team (high level)", () => { }); test("Admins an add writers to a team, who can't add admins, writers, or readers", () => { - const { team } = newTeam(); - const writer = newRandomAgentCredential("writer"); + const { team, node } = newTeam(); + const writer = node.createAgent("writer"); const writerID = getAgentID(getAgent(writer)); let teamContent = expectTeamContent(team.getCurrentContent()); @@ -212,7 +208,7 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers expect(teamContentAsWriter.get(writerID)).toEqual("writer"); - const otherAgent = newRandomAgentCredential("otherAgent"); + const otherAgent = node.createAgent("otherAgent"); const otherAgentID = getAgentID(getAgent(otherAgent)); teamContentAsWriter.edit((editable) => { @@ -234,11 +230,9 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers test("Admins an add writers to a team, who can't add admins, writers, or readers (high level)", () => { const { team, node } = newTeamHighLevel(); - const writer = newRandomAgentCredential("writer"); + const writer = node.createAgent("writer"); const writerID = getAgentID(getAgent(writer)); - node.addKnownAgent(getAgent(writer)); - team.addMember(writerID, "writer"); expect(team.teamMap.get(writerID)).toEqual("writer"); @@ -249,11 +243,9 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers expect(teamAsWriter.teamMap.get(writerID)).toEqual("writer"); - const otherAgent = newRandomAgentCredential("otherAgent"); + const otherAgent = node.createAgent("otherAgent"); const otherAgentID = getAgentID(getAgent(otherAgent)); - node.addKnownAgent(getAgent(otherAgent)); - expect(() => teamAsWriter.addMember(otherAgentID, "admin")).toThrow( "Failed to set role" ); @@ -268,8 +260,8 @@ test("Admins an add writers to a team, who can't add admins, writers, or readers }); test("Admins can add readers to a team, who can't add admins, writers, or readers", () => { - const { team } = newTeam(); - const reader = newRandomAgentCredential("reader"); + const { team, node } = newTeam(); + const reader = node.createAgent("reader"); const readerID = getAgentID(getAgent(reader)); let teamContent = expectTeamContent(team.getCurrentContent()); @@ -293,7 +285,7 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader expect(teamContentAsReader.get(readerID)).toEqual("reader"); - const otherAgent = newRandomAgentCredential("otherAgent"); + const otherAgent = node.createAgent("otherAgent"); const otherAgentID = getAgentID(getAgent(otherAgent)); teamContentAsReader.edit((editable) => { @@ -315,11 +307,9 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader test("Admins can add readers to a team, who can't add admins, writers, or readers (high level)", () => { const { team, node } = newTeamHighLevel(); - const reader = newRandomAgentCredential("reader"); + const reader = node.createAgent("reader"); const readerID = getAgentID(getAgent(reader)); - node.addKnownAgent(getAgent(reader)); - team.addMember(readerID, "reader"); expect(team.teamMap.get(readerID)).toEqual("reader"); @@ -330,11 +320,9 @@ test("Admins can add readers to a team, who can't add admins, writers, or reader expect(teamAsReader.teamMap.get(readerID)).toEqual("reader"); - const otherAgent = newRandomAgentCredential("otherAgent"); + const otherAgent = node.createAgent("otherAgent"); const otherAgentID = getAgentID(getAgent(otherAgent)); - node.addKnownAgent(getAgent(otherAgent)); - expect(() => teamAsReader.addMember(otherAgentID, "admin")).toThrow( "Failed to set role" ); @@ -355,7 +343,7 @@ test("Admins can write to an object that is owned by their team", () => { type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - publicNickname: "childObject" + publicNickname: "childObject", }); let childContent = expectMap(childObject.getCurrentContent()); @@ -386,7 +374,7 @@ test("Admins can write to an object that is owned by their team (high level)", ( test("Writers can write to an object that is owned by their team", () => { const { node, team } = newTeam(); - const writer = newRandomAgentCredential("writer"); + const writer = node.createAgent("writer"); const writerID = getAgentID(getAgent(writer)); expectTeamContent(team.getCurrentContent()).edit((editable) => { @@ -398,7 +386,7 @@ test("Writers can write to an object that is owned by their team", () => { type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - publicNickname: "childObject" + publicNickname: "childObject", }); const childObjectAsWriter = childObject.testWithDifferentCredentials( @@ -423,11 +411,9 @@ test("Writers can write to an object that is owned by their team", () => { test("Writers can write to an object that is owned by their team (high level)", () => { const { node, team } = newTeamHighLevel(); - const writer = newRandomAgentCredential("writer"); + const writer = node.createAgent("writer"); const writerID = getAgentID(getAgent(writer)); - node.addKnownAgent(getAgent(writer)); - team.addMember(writerID, "writer"); const childObject = team.createMap(); @@ -449,7 +435,7 @@ test("Writers can write to an object that is owned by their team (high level)", test("Readers can not write to an object that is owned by their team", () => { const { node, team } = newTeam(); - const reader = newRandomAgentCredential("reader"); + const reader = node.createAgent("reader"); const readerID = getAgentID(getAgent(reader)); expectTeamContent(team.getCurrentContent()).edit((editable) => { @@ -461,7 +447,7 @@ test("Readers can not write to an object that is owned by their team", () => { type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - publicNickname: "childObject" + publicNickname: "childObject", }); const childObjectAsReader = childObject.testWithDifferentCredentials( @@ -486,11 +472,9 @@ test("Readers can not write to an object that is owned by their team", () => { test("Readers can not write to an object that is owned by their team (high level)", () => { const { node, team } = newTeamHighLevel(); - const reader = newRandomAgentCredential("reader"); + const reader = node.createAgent("reader"); const readerID = getAgentID(getAgent(reader)); - node.addKnownAgent(getAgent(reader)); - team.addMember(readerID, "reader"); const childObject = team.createMap(); @@ -537,7 +521,7 @@ test("Admins can set team read key and then use it to create and read private tr type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - publicNickname: "childObject" + publicNickname: "childObject", }); let childContent = expectMap(childObject.getCurrentContent()); @@ -567,7 +551,7 @@ test("Admins can set team read key and then use it to create and read private tr test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects", () => { const { node, team, admin } = newTeam(); - const writer = newRandomAgentCredential("writer"); + const writer = node.createAgent("writer"); const writerID = getAgentID(getAgent(writer)); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); @@ -596,7 +580,7 @@ test("Admins can set team read key and then writers can use it to create and rea type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - publicNickname: "childObject" + publicNickname: "childObject", }); const childObjectAsWriter = childObject.testWithDifferentCredentials( @@ -623,11 +607,9 @@ test("Admins can set team read key and then writers can use it to create and rea test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects (high level)", () => { const { node, team, admin } = newTeamHighLevel(); - const writer = newRandomAgentCredential("writer"); + const writer = node.createAgent("writer"); const writerID = getAgentID(getAgent(writer)); - node.addKnownAgent(getAgent(writer)); - team.addMember(writerID, "writer"); const childObject = team.createMap(); @@ -649,7 +631,7 @@ test("Admins can set team read key and then writers can use it to create and rea test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read", () => { const { node, team, admin } = newTeam(); - const reader = newRandomAgentCredential("reader"); + const reader = node.createAgent("reader"); const readerID = getAgentID(getAgent(reader)); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); @@ -678,7 +660,7 @@ test("Admins can set team read key and then use it to create private transaction type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - publicNickname: "childObject" + publicNickname: "childObject", }); expectMap(childObject.getCurrentContent()).edit((editable) => { @@ -703,11 +685,9 @@ test("Admins can set team read key and then use it to create private transaction test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read (high level)", () => { const { node, team, admin } = newTeamHighLevel(); - const reader = newRandomAgentCredential("reader"); + const reader = node.createAgent("reader"); const readerID = getAgentID(getAgent(reader)); - node.addKnownAgent(getAgent(reader)); - team.addMember(readerID, "reader"); let childObject = team.createMap(); @@ -717,21 +697,21 @@ test("Admins can set team read key and then use it to create private transaction expect(editable.get("foo")).toEqual("bar"); }); - const childContentAsReader = expectMap(childObject.coValue.testWithDifferentCredentials( - reader, - newRandomSessionID(readerID) - ).getCurrentContent()); + const childContentAsReader = expectMap( + childObject.coValue + .testWithDifferentCredentials(reader, newRandomSessionID(readerID)) + .getCurrentContent() + ); expect(childContentAsReader.get("foo")).toEqual("bar"); }); - test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key", () => { const { node, team, admin } = newTeam(); - const reader1 = newRandomAgentCredential("reader1"); + const reader1 = node.createAgent("reader1"); const reader1ID = getAgentID(getAgent(reader1)); - const reader2 = newRandomAgentCredential("reader2"); + const reader2 = node.createAgent("reader2"); const reader2ID = getAgentID(getAgent(reader2)); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); @@ -779,7 +759,7 @@ test("Admins can set team read key and then use it to create private transaction type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - publicNickname: "childObject" + publicNickname: "childObject", }); expectMap(childObject.getCurrentContent()).edit((editable) => { @@ -817,14 +797,11 @@ test("Admins can set team read key and then use it to create private transaction test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key (high level)", () => { const { node, team, admin } = newTeamHighLevel(); - const reader1 = newRandomAgentCredential("reader1"); + const reader1 = node.createAgent("reader1"); const reader1ID = getAgentID(getAgent(reader1)); - const reader2 = newRandomAgentCredential("reader2"); + const reader2 = node.createAgent("reader2"); const reader2ID = getAgentID(getAgent(reader2)); - node.addKnownAgent(getAgent(reader1)); - node.addKnownAgent(getAgent(reader2)); - team.addMember(reader1ID, "reader"); let childObject = team.createMap(); @@ -834,24 +811,31 @@ test("Admins can set team read key and then use it to create private transaction expect(editable.get("foo")).toEqual("bar"); }); - const childContentAsReader1 = expectMap(childObject.coValue.testWithDifferentCredentials( - reader1, - newRandomSessionID(reader1ID) - ).getCurrentContent()); + const childContentAsReader1 = expectMap( + childObject.coValue + .testWithDifferentCredentials( + reader1, + newRandomSessionID(reader1ID) + ) + .getCurrentContent() + ); expect(childContentAsReader1.get("foo")).toEqual("bar"); team.addMember(reader2ID, "reader"); - const childContentAsReader2 = expectMap(childObject.coValue.testWithDifferentCredentials( - reader2, - newRandomSessionID(reader2ID) - ).getCurrentContent()); + const childContentAsReader2 = expectMap( + childObject.coValue + .testWithDifferentCredentials( + reader2, + newRandomSessionID(reader2ID) + ) + .getCurrentContent() + ); expect(childContentAsReader2.get("foo")).toEqual("bar"); }); - test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, make another private transaction, and both can be read by the admin", () => { const { node, team, admin, adminID } = newTeam(); @@ -880,7 +864,7 @@ test("Admins can set team read key, make a private transaction in an owned objec type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - publicNickname: "childObject" + publicNickname: "childObject", }); let childContent = expectMap(childObject.getCurrentContent()); @@ -960,7 +944,7 @@ test("Admins can set team read key, make a private transaction in an owned objec type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - publicNickname: "childObject" + publicNickname: "childObject", }); const teamContent = expectTeamContent(team.getCurrentContent()); @@ -994,7 +978,7 @@ test("Admins can set team read key, make a private transaction in an owned objec childContent = expectMap(childObject.getCurrentContent()); expect(childContent.get("foo")).toEqual("bar"); - const reader = newRandomAgentCredential("reader"); + const reader = node.createAgent("reader"); const readerID = getAgentID(getAgent(reader)); const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret(); @@ -1074,11 +1058,9 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey); - const reader = newRandomAgentCredential("reader"); + const reader = node.createAgent("reader"); const readerID = getAgentID(getAgent(reader)); - node.addKnownAgent(getAgent(reader)); - team.addMember(readerID, "reader"); childObject = childObject.edit((editable) => { @@ -1086,15 +1068,15 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(editable.get("foo2")).toEqual("bar2"); }); - const childContentAsReader = expectMap(childObject.coValue.testWithDifferentCredentials( - reader, - newRandomSessionID(readerID) - ).getCurrentContent()); + const childContentAsReader = expectMap( + childObject.coValue + .testWithDifferentCredentials(reader, newRandomSessionID(readerID)) + .getCurrentContent() + ); expect(childContentAsReader.get("foo")).toEqual("bar"); expect(childContentAsReader.get("foo2")).toEqual("bar2"); -}) - +}); test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions", () => { const { node, team, admin, adminID } = newTeam(); @@ -1103,14 +1085,14 @@ test("Admins can set team read rey, make a private transaction in an owned objec type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - publicNickname: "childObject" + publicNickname: "childObject", }); const teamContent = expectTeamContent(team.getCurrentContent()); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); - const reader = newRandomAgentCredential("reader"); + const reader = node.createAgent("reader"); const readerID = getAgentID(getAgent(reader)); - const reader2 = newRandomAgentCredential("reader2"); + const reader2 = node.createAgent("reader2"); const reader2ID = getAgentID(getAgent(reader)); teamContent.edit((editable) => { @@ -1228,7 +1210,6 @@ test("Admins can set team read rey, make a private transaction in an owned objec let childObject = team.createMap(); - childObject = childObject.edit((editable) => { editable.set("foo", "bar", "private"); expect(editable.get("foo")).toEqual("bar"); @@ -1240,14 +1221,11 @@ test("Admins can set team read rey, make a private transaction in an owned objec const secondReadKey = childObject.coValue.getCurrentReadKey(); - const reader = newRandomAgentCredential("reader"); + const reader = node.createAgent("reader"); const readerID = getAgentID(getAgent(reader)); - const reader2 = newRandomAgentCredential("reader2"); + const reader2 = node.createAgent("reader2"); const reader2ID = getAgentID(getAgent(reader2)); - node.addKnownAgent(getAgent(reader)); - node.addKnownAgent(getAgent(reader2)); - team.addMember(readerID, "reader"); team.addMember(reader2ID, "reader"); @@ -1268,17 +1246,22 @@ test("Admins can set team read rey, make a private transaction in an owned objec expect(editable.get("foo3")).toEqual("bar3"); }); - const childContentAsReader2 = expectMap(childObject.coValue.testWithDifferentCredentials( - reader2, - newRandomSessionID(reader2ID) - ).getCurrentContent()); + const childContentAsReader2 = expectMap( + childObject.coValue + .testWithDifferentCredentials( + reader2, + newRandomSessionID(reader2ID) + ) + .getCurrentContent() + ); expect(childContentAsReader2.get("foo")).toEqual("bar"); expect(childContentAsReader2.get("foo2")).toEqual("bar2"); expect(childContentAsReader2.get("foo3")).toEqual("bar3"); - expect(() => childObject.coValue.testWithDifferentCredentials( - reader, - newRandomSessionID(readerID) - ).getCurrentContent()).toThrow(/readKey (.+?) not revealed for (.+?)/); + expect(() => + childObject.coValue + .testWithDifferentCredentials(reader, newRandomSessionID(readerID)) + .getCurrentContent() + ).toThrow(/readKey (.+?) not revealed for (.+?)/); }); diff --git a/src/permissions.ts b/src/permissions.ts index 214e11942..21084ce2a 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -238,7 +238,7 @@ export class Team { addMember(agentID: AgentID, role: Role) { this.teamMap = this.teamMap.edit((map) => { - const agent = this.node.knownAgents[agentID]; + const agent = this.node.expectAgentLoaded(agentID, "Expected to know agent to add them to team"); if (!agent) { throw new Error("Unknown agent " + agentID); @@ -291,7 +291,7 @@ export class Team { new Set( currentlyPermittedReaders.map( (reader) => { - const readerAgent = this.node.knownAgents[reader]; + const readerAgent = this.node.expectAgentLoaded(reader, "Expected to know currently permitted reader"); if (!readerAgent) { throw new Error("Unknown agent " + reader); } From 2cac2e8d4fcc43f0a41eb16b2a4c6c35c574ac65 Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 7 Aug 2023 19:54:07 +0100 Subject: [PATCH 29/47] Deal better with private transactions we don't have a key for --- src/coValue.ts | 67 ++++++++++++++++++++++++----------------- src/permissions.test.ts | 24 ++++++++------- src/permissions.ts | 15 ++++++++- src/sync.test.ts | 3 +- 4 files changed, 68 insertions(+), 41 deletions(-) diff --git a/src/coValue.ts b/src/coValue.ts index bc3c70b18..ed820a20c 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -231,6 +231,10 @@ export class CoValue { if (privacy === "private") { const { secret: keySecret, id: keyID } = this.getCurrentReadKey(); + if (!keySecret) { + throw new Error("Can't make transaction without read key secret"); + } + transaction = { privacy: "private", madeAt, @@ -298,26 +302,40 @@ export class CoValue { const allTransactions: DecryptedTransaction[] = validTransactions.map( ({ txID, tx }) => { - return { - txID, - madeAt: tx.madeAt, - changes: - tx.privacy === "private" - ? decryptForTransaction( - tx.encryptedChanges, - this.getReadKey(tx.keyUsed), - { - in: this.id, - tx: txID, - } - ) || - (() => { - throw new Error("Couldn't decrypt changes"); - })() - : tx.changes, - }; + if (tx.privacy === "trusting") { + return { + txID, + madeAt: tx.madeAt, + changes: tx.changes, + }; + } else { + const readKey = this.getReadKey(tx.keyUsed); + + if (!readKey) { + return undefined; + } else { + const decrytedChanges = decryptForTransaction( + tx.encryptedChanges, + readKey, + { + in: this.id, + tx: txID, + } + ); + + if (!decrytedChanges) { + console.error("Failed to decrypt transaction despite having key"); + return undefined; + } + return { + txID, + madeAt: tx.madeAt, + changes: decrytedChanges, + }; + } + } } - ); + ).filter((x): x is Exclude => !!x); allTransactions.sort( (a, b) => a.madeAt - b.madeAt || @@ -328,7 +346,7 @@ export class CoValue { return allTransactions; } - getCurrentReadKey(): { secret: KeySecret; id: KeyID } { + getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } { if (this.header.ruleset.type === "team") { const content = expectTeamContent(this.getCurrentContent()); @@ -355,7 +373,7 @@ export class CoValue { } } - getReadKey(keyID: KeyID): KeySecret { + getReadKey(keyID: KeyID): KeySecret | undefined { if (this.header.ruleset.type === "team") { const content = expectTeamContent(this.getCurrentContent()); @@ -416,12 +434,7 @@ export class CoValue { } } - throw new Error( - "readKey " + - keyID + - " not revealed for " + - getAgentID(getAgent(this.node.agentCredential)) - ); + return undefined; } else if (this.header.ruleset.type === "ownedByTeam") { return this.node .expectCoValueLoaded(this.header.ruleset.team) diff --git a/src/permissions.test.ts b/src/permissions.test.ts index d5cf4f080..c52a260a9 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -1194,15 +1194,12 @@ test("Admins can set team read rey, make a private transaction in an owned objec newRandomSessionID(reader2ID) ); - expect(() => expectMap(childObjectAsReader.getCurrentContent())).toThrow( - /readKey (.+?) not revealed for (.+?)/ - ); + expect( + expectMap(childObjectAsReader.getCurrentContent()).get("foo2") + ).toBeUndefined(); expect( expectMap(childObjectAsReader2.getCurrentContent()).get("foo2") ).toEqual("bar2"); - expect(() => { - childObjectAsReader.getCurrentContent(); - }).toThrow(); }); test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions (high level)", () => { @@ -1259,9 +1256,14 @@ test("Admins can set team read rey, make a private transaction in an owned objec expect(childContentAsReader2.get("foo2")).toEqual("bar2"); expect(childContentAsReader2.get("foo3")).toEqual("bar3"); - expect(() => - childObject.coValue - .testWithDifferentCredentials(reader, newRandomSessionID(readerID)) - .getCurrentContent() - ).toThrow(/readKey (.+?) not revealed for (.+?)/); + expect( + expectMap( + childObject.coValue + .testWithDifferentCredentials( + reader, + newRandomSessionID(readerID) + ) + .getCurrentContent() + ).get("foo3") + ).toBeUndefined(); }); diff --git a/src/permissions.ts b/src/permissions.ts index 21084ce2a..3f15883c2 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -251,6 +251,10 @@ export class Team { const currentReadKey = this.teamMap.coValue.getCurrentReadKey(); + if (!currentReadKey.secret) { + throw new Error("Can't add member without read key secret"); + } + const revelation = seal( currentReadKey.secret, this.teamMap.coValue.node.agentCredential.recipientSecret, @@ -281,7 +285,16 @@ export class Team { } }) as AgentID[]; - const currentReadKey = this.teamMap.coValue.getCurrentReadKey(); + const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey(); + + if (!maybeCurrentReadKey.secret) { + throw new Error("Can't rotate read key secret we don't have access to"); + } + + const currentReadKey = { + id: maybeCurrentReadKey.id, + secret: maybeCurrentReadKey.secret, + }; const newReadKey = newRandomKeySecret(); diff --git a/src/sync.test.ts b/src/sync.test.ts index 248947761..20c14e788 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -909,7 +909,6 @@ test("Can sync a coValue through a server to another client", async () => { const server = new LocalNode(serverUser, newRandomSessionID(serverUserID)); const [serverAsPeer, client1AsPeer] = connectedPeers("server", "client1", { - trace: true, peer1role: "server", peer2role: "client", }); @@ -922,7 +921,7 @@ test("Can sync a coValue through a server to another client", async () => { const [serverAsOtherPeer, client2AsPeer] = connectedPeers( "server", "client2", - { trace: true, peer1role: "server", peer2role: "client" } + { peer1role: "server", peer2role: "client" } ); client2.sync.addPeer(serverAsOtherPeer); From 209839d889ca51bb98ae09335c375f5f85e18488 Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 9 Aug 2023 11:47:41 +0100 Subject: [PATCH 30/47] 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 + ); } } } From 0c82cb338846cb50083a8e8e11a11e478c805d47 Mon Sep 17 00:00:00 2001 From: Anselm Date: Wed, 9 Aug 2023 14:11:22 +0100 Subject: [PATCH 31/47] Better exports --- package.json | 2 +- src/index.ts | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 6af70a283..0a2262cf4 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "types": "src/index.ts", "type": "module", "license": "MIT", - "version": "0.0.3", + "version": "0.0.4", "devDependencies": { "@types/jest": "^29.5.3", "@typescript-eslint/eslint-plugin": "^6.2.1", diff --git a/src/index.ts b/src/index.ts index df000bba7..bbd41e6d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -import type { CoValueID, ContentType } from "./contentType"; -import type { JsonValue } from "./jsonValue"; import { CoValue, agentCredentialFromBytes, @@ -12,6 +10,11 @@ import { import { LocalNode } from "./node"; import { CoMap } from "./contentTypes/coMap"; +import type { AgentCredential } from "./coValue"; +import type { AgentID, SessionID } from "./ids"; +import type { CoValueID, ContentType } from "./contentType"; +import type { JsonValue } from "./jsonValue"; + type Value = JsonValue | ContentType; const internals = { @@ -25,4 +28,12 @@ const internals = { export { LocalNode, CoValue, CoMap, internals }; -export type { Value, JsonValue, ContentType, CoValueID }; +export type { + Value, + JsonValue, + ContentType, + CoValueID, + AgentCredential, + SessionID, + AgentID, +}; From be1461e42855e7b646295d8eb159f9eb7077dff2 Mon Sep 17 00:00:00 2001 From: Anselm Date: Thu, 10 Aug 2023 11:56:15 +0100 Subject: [PATCH 32/47] Fix imports and remove logs --- .npmignore | 2 + package.json | 9 ++-- src/coValue.ts | 6 --- src/crypto.ts | 2 +- src/index.ts | 2 + yarn.lock | 138 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 10 deletions(-) create mode 100644 .npmignore diff --git a/.npmignore b/.npmignore new file mode 100644 index 000000000..7cafbdb49 --- /dev/null +++ b/.npmignore @@ -0,0 +1,2 @@ +coverage +node_modules \ No newline at end of file diff --git a/package.json b/package.json index 0a2262cf4..987cb2787 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,16 @@ { "name": "cojson", - "module": "src/index.ts", + "module": "dist/index.mjs", + "main": "dist/index.mjs", "types": "src/index.ts", "type": "module", "license": "MIT", - "version": "0.0.4", + "version": "0.0.7", "devDependencies": { "@types/jest": "^29.5.3", "@typescript-eslint/eslint-plugin": "^6.2.1", "@typescript-eslint/parser": "^6.2.1", + "esbuild": "^0.19.0", "eslint": "^8.46.0", "jest": "^29.6.2", "ts-jest": "^29.1.1", @@ -23,7 +25,8 @@ "isomorphic-streams": "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae" }, "scripts": { - "test": "jest" + "test": "jest", + "build": "esbuild `find src \\( -name '*.ts' -o -name '*.tsx' \\)` --out-extension:.js=.mjs --platform=node --target=node14 --outdir=dist" }, "jest": { "preset": "ts-jest", diff --git a/src/coValue.ts b/src/coValue.ts index b4a1b4dc1..ecd0593e3 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -192,8 +192,6 @@ 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] = { @@ -205,12 +203,9 @@ export class CoValue { this.content = undefined; - 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); } @@ -219,7 +214,6 @@ export class CoValue { subscribe(listener: (content?: ContentType) => void): () => void { this.listeners.add(listener); - console.log("Calling listener (initial)", this.id, this.getCurrentContent().toJSON()); listener(this.getCurrentContent()); return () => { diff --git a/src/crypto.ts b/src/crypto.ts index 104dcf891..10fc7a6e2 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -2,7 +2,7 @@ import { ed25519, x25519 } from "@noble/curves/ed25519"; import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa"; import { JsonValue } from "./jsonValue"; import { base58, base64url } from "@scure/base"; -import { default as stableStringify } from "fast-json-stable-stringify"; +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"; diff --git a/src/index.ts b/src/index.ts index bbd41e6d9..76f03e963 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import type { AgentCredential } from "./coValue"; import type { AgentID, SessionID } from "./ids"; import type { CoValueID, ContentType } from "./contentType"; import type { JsonValue } from "./jsonValue"; +import type { SyncMessage } from "./sync"; type Value = JsonValue | ContentType; @@ -36,4 +37,5 @@ export type { AgentCredential, SessionID, AgentID, + SyncMessage }; diff --git a/yarn.lock b/yarn.lock index 51df233c8..de95ff376 100644 --- a/yarn.lock +++ b/yarn.lock @@ -301,6 +301,116 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@esbuild/android-arm64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.0.tgz#c5ea635bdbe9b83d1f78a711120814e716439029" + integrity sha512-AzsozJnB+RNaDncBCs3Ys5g3kqhPFUueItfEaCpp89JH2naFNX2mYDIvUgPYMqqjm8hiFoo+jklb3QHZyR3ubw== + +"@esbuild/android-arm@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.0.tgz#6eb6e1fbc0dbfafa035aaef8b5ecde25b539fcf9" + integrity sha512-GAkjUyHgWTYuex3evPd5V7uV/XS4LMKr1PWHRPW1xNyy/Jx08x3uTrDFRefBYLKT/KpaWM8/YMQcwbp5a3yIDA== + +"@esbuild/android-x64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.0.tgz#99f154f71f5b92e778468bcf0f425d166c17bf20" + integrity sha512-SUG8/qiVhljBDpdkHQ9DvOWbp7hFFIP0OzxOTptbmVsgBgzY6JWowmMd6yJuOhapfxmj/DrvwKmjRLvVSIAKZg== + +"@esbuild/darwin-arm64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.0.tgz#2fcc11abf95fbabbf9167db6a11d899385bd777b" + integrity sha512-HkxZ8k3Jvcw0FORPNTavA8BMgQjLOB6AajT+iXmil7BwY3gU1hWvJJAyWyEogCmA4LdbGvKF8vEykdmJ4xNJJQ== + +"@esbuild/darwin-x64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.0.tgz#b5bbde35468db093fdf994880b0eb4b62613b67c" + integrity sha512-9IRWJjqpWFHM9a5Qs3r3bK834NCFuDY5ZaLrmTjqE+10B6w65UMQzeZjh794JcxpHolsAHqwsN/33crUXNCM2Q== + +"@esbuild/freebsd-arm64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.0.tgz#3f64c76dc590f79cc40acef6b22dd5eb89fc2125" + integrity sha512-s7i2WcXcK0V1PJHVBe7NsGddsL62a9Vhpz2U7zapPrwKoFuxPP9jybwX8SXnropR/AOj3ppt2ern4ItblU6UQQ== + +"@esbuild/freebsd-x64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.0.tgz#14d497e9e858fba2bb9b16130602b7f5944bc09c" + integrity sha512-NMdBSSdgwHCqCsucU5k1xflIIRU0qi1QZnM6+vdGy5fvxm1c8rKh50VzsWsIVTFUG3l91AtRxVwoz3Lcvy3I5w== + +"@esbuild/linux-arm64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.0.tgz#0f2f4d8889f7dc89681c306d7312aa76445a5f65" + integrity sha512-I4zvE2srSZxRPapFnNqj+NL3sDJ1wkvEZqt903OZUlBBgigrQMvzUowvP/TTTu2OGYe1oweg5MFilfyrElIFag== + +"@esbuild/linux-arm@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.0.tgz#0b0f79dc72884f0ad02c0aabfc969a0bee7f6775" + integrity sha512-2F1+lH7ZBcCcgxiSs8EXQV0PPJJdTNiNcXxDb61vzxTRJJkXX1I/ye9mAhfHyScXzHaEibEXg1Jq9SW586zz7w== + +"@esbuild/linux-ia32@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.0.tgz#dfcece1f5e74d0e7db090475e48b28d9aa270687" + integrity sha512-dz2Q7+P92r1Evc8kEN+cQnB3qqPjmCrOZ+EdBTn8lEc1yN8WDgaDORQQiX+mxaijbH8npXBT9GxUqE52Gt6Y+g== + +"@esbuild/linux-loong64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.0.tgz#710f5bd55db3f5d9ebac8773ea49795261a35ca7" + integrity sha512-IcVJovJVflih4oFahhUw+N7YgNbuMSVFNr38awb0LNzfaiIfdqIh518nOfYaNQU3aVfiJnOIRVJDSAP4k35WxA== + +"@esbuild/linux-mips64el@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.0.tgz#a918b310f9bf31fced3853ca52fee6e7acc09824" + integrity sha512-bZGRAGySMquWsKw0gIdsClwfvgbsSq/7oq5KVu1H1r9Il+WzOcfkV1hguntIuBjRVL8agI95i4AukjdAV2YpUw== + +"@esbuild/linux-ppc64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.0.tgz#104771ef6ce2719ac17031f6b9ed8aa98f8e5faf" + integrity sha512-3LC6H5/gCDorxoRBUdpLV/m7UthYSdar0XcCu+ypycQxMS08MabZ06y1D1yZlDzL/BvOYliRNRWVG/YJJvQdbg== + +"@esbuild/linux-riscv64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.0.tgz#83beafa472ad4224adcd4d7469e3a17ba1fbd976" + integrity sha512-jfvdKjWk+Cp2sgLtEEdSHXO7qckrw2B2eFBaoRdmfhThqZs29GMMg7q/LsQpybA7BxCLLEs4di5ucsWzZC5XPA== + +"@esbuild/linux-s390x@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.0.tgz#edc26cb41d8745716bda9c26bac1f0001eaad029" + integrity sha512-ofcucfNLkoXmcnJaw9ugdEOf40AWKGt09WBFCkpor+vFJVvmk/8OPjl/qRtks2Z7BuZbG3ztJuK1zS9z5Cgx9A== + +"@esbuild/linux-x64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.0.tgz#80a6b5e55ad454e0c0af5bdb267335287e331007" + integrity sha512-Fpf7zNDBti3xrQKQKLdXT0hTyOxgFdRJIMtNy8x1az9ATR9/GJ1brYbB/GLWoXhKiHsoWs+2DLkFVNNMTCLEwA== + +"@esbuild/netbsd-x64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.0.tgz#2e6e8d869b58aea34bab9c0c47f15ae1bda29a90" + integrity sha512-AMQAp/5oENgDOvVhvOlbhVe1pWii7oFAMRHlmTjSEMcpjTpIHtFXhv9uAFgUERHm3eYtNvS9Vf+gT55cwuI6Aw== + +"@esbuild/openbsd-x64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.0.tgz#ca0817d3ab332afb0d8d96a2eb42b4d8ebaa8715" + integrity sha512-fDztEve1QUs3h/Dw2AUmBlWGkNQbhDoD05ppm5jKvzQv+HVuV13so7m5RYeiSMIC2XQy7PAjZh+afkxAnCRZxA== + +"@esbuild/sunos-x64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.0.tgz#8de27de2563cb3eb6c1af066b6d7fcb1229fe3d4" + integrity sha512-bKZzJ2/rvUjDzA5Ddyva2tMk89WzNJEibZEaq+wY6SiqPlwgFbqyQLimouxLHiHh1itb5P3SNCIF1bc2bw5H9w== + +"@esbuild/win32-arm64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.0.tgz#67c2b410ff8862be2cd61145ad21e11be00fb914" + integrity sha512-NQJ+4jmnA79saI+sE+QzcEls19uZkoEmdxo7r//PDOjIpX8pmoWtTnWg6XcbnO7o4fieyAwb5U2LvgWynF4diA== + +"@esbuild/win32-ia32@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.0.tgz#cac8992219c6d943bb22226e4afeb3774a29cca1" + integrity sha512-uyxiZAnsfu9diHm9/rIH2soecF/HWLXYUhJKW4q1+/LLmNQ+55lRjvSUDhUmsgJtSUscRJB/3S4RNiTb9o9mCg== + +"@esbuild/win32-x64@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.0.tgz#fa5f12c96811cec3233a53bdbf61d1a05ba9018f" + integrity sha512-jl+NXUjK2StMgqnZnqgNjZuerFG8zQqWXMBZdMMv4W/aO1ZKQaYWZBxTrtWKphkCBVEMh0wMVfGgOd2BjOZqUQ== + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -1216,6 +1326,34 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" +esbuild@^0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.0.tgz#f187e4ce3bcc7396d13f408a991655efeba65282" + integrity sha512-i7i8TP4vuG55bKeLyqqk5sTPu1ZjPH3wkcLvAj/0X/222iWFo3AJUYRKjbOoY6BWFMH3teizxHEdV9Su5ESl0w== + optionalDependencies: + "@esbuild/android-arm" "0.19.0" + "@esbuild/android-arm64" "0.19.0" + "@esbuild/android-x64" "0.19.0" + "@esbuild/darwin-arm64" "0.19.0" + "@esbuild/darwin-x64" "0.19.0" + "@esbuild/freebsd-arm64" "0.19.0" + "@esbuild/freebsd-x64" "0.19.0" + "@esbuild/linux-arm" "0.19.0" + "@esbuild/linux-arm64" "0.19.0" + "@esbuild/linux-ia32" "0.19.0" + "@esbuild/linux-loong64" "0.19.0" + "@esbuild/linux-mips64el" "0.19.0" + "@esbuild/linux-ppc64" "0.19.0" + "@esbuild/linux-riscv64" "0.19.0" + "@esbuild/linux-s390x" "0.19.0" + "@esbuild/linux-x64" "0.19.0" + "@esbuild/netbsd-x64" "0.19.0" + "@esbuild/openbsd-x64" "0.19.0" + "@esbuild/sunos-x64" "0.19.0" + "@esbuild/win32-arm64" "0.19.0" + "@esbuild/win32-ia32" "0.19.0" + "@esbuild/win32-x64" "0.19.0" + escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" From b22bd265fcaeb1e410d03950e0fe29f723bc402f Mon Sep 17 00:00:00 2001 From: Anselm Date: Fri, 11 Aug 2023 09:54:07 +0100 Subject: [PATCH 33/47] Make sure relative imports have extension --- .eslintrc.cjs | 19 +++-- package.json | 8 +- src/coValue.test.ts | 7 +- src/coValue.ts | 22 +++--- src/contentType.test.ts | 6 +- src/contentType.ts | 12 +-- src/contentTypes/coList.ts | 8 +- src/contentTypes/coMap.ts | 12 +-- src/contentTypes/coStream.ts | 8 +- src/contentTypes/static.ts | 10 +-- src/crypto.test.ts | 2 +- src/crypto.ts | 4 +- src/index.ts | 16 ++-- src/jsonValue.ts | 2 +- src/node.ts | 13 ++-- src/permissions.test.ts | 10 +-- src/permissions.ts | 14 ++-- src/sync.test.ts | 14 ++-- src/sync.ts | 12 +-- tsconfig.json | 8 +- yarn.lock | 143 ++--------------------------------- 21 files changed, 107 insertions(+), 243 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 132650d83..6a35dbe61 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,18 +1,21 @@ module.exports = { extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:require-extensions/recommended", ], - parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint'], + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint", "require-extensions"], parserOptions: { - project: './tsconfig.json', + project: "./tsconfig.json", }, root: true, rules: { "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], "@typescript-eslint/no-floating-promises": "error", }, - - }; \ No newline at end of file +}; diff --git a/package.json b/package.json index 987cb2787..c8c485211 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,13 @@ "types": "src/index.ts", "type": "module", "license": "MIT", - "version": "0.0.7", + "version": "0.0.8", "devDependencies": { "@types/jest": "^29.5.3", "@typescript-eslint/eslint-plugin": "^6.2.1", "@typescript-eslint/parser": "^6.2.1", - "esbuild": "^0.19.0", "eslint": "^8.46.0", + "eslint-plugin-require-extensions": "^0.1.3", "jest": "^29.6.2", "ts-jest": "^29.1.1", "typescript": "5.0.2" @@ -26,7 +26,9 @@ }, "scripts": { "test": "jest", - "build": "esbuild `find src \\( -name '*.ts' -o -name '*.tsx' \\)` --out-extension:.js=.mjs --platform=node --target=node14 --outdir=dist" + "lint": "eslint src/**/*.ts", + "build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist", + "prepublishOnly": "npm run build" }, "jest": { "preset": "ts-jest", diff --git a/src/coValue.test.ts b/src/coValue.test.ts index bc221034b..340afb2b8 100644 --- a/src/coValue.test.ts +++ b/src/coValue.test.ts @@ -1,13 +1,12 @@ import { - CoValue, Transaction, getAgent, getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./coValue"; -import { LocalNode } from "./node"; -import { createdNowUnique, sign, uniquenessForHeader } from "./crypto"; +} from './coValue.js'; +import { LocalNode } from './node.js'; +import { createdNowUnique, sign } from './crypto.js'; test("Can create coValue with new agent credentials and add transaction to it", () => { const agentCredential = newRandomAgentCredential("agent1"); diff --git a/src/coValue.ts b/src/coValue.ts index ecd0593e3..a8ff227b0 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -1,8 +1,8 @@ import { randomBytes } from "@noble/hashes/utils"; -import { ContentType } from "./contentType"; -import { Static } from "./contentTypes/static"; -import { CoStream } from "./contentTypes/coStream"; -import { CoMap } from "./contentTypes/coMap"; +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, @@ -29,19 +29,19 @@ import { recipientSecretToBytes, signatorySecretFromBytes, recipientSecretFromBytes, -} from "./crypto"; -import { JsonValue } from "./jsonValue"; +} from './crypto.js'; +import { JsonValue } from './jsonValue.js'; import { base58 } from "@scure/base"; import { PermissionsDef as RulesetDef, Team, determineValidTransactions, expectTeamContent, -} from "./permissions"; -import { LocalNode } from "./node"; -import { CoValueKnownState, NewContentMessage } from "./sync"; -import { AgentID, RawCoValueID, SessionID, TransactionID } from "./ids"; -import { CoList } from "./contentTypes/coList"; +} 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'; export type CoValueHeader = { type: ContentType["type"]; diff --git a/src/contentType.test.ts b/src/contentType.test.ts index 7e11f4327..3979c0316 100644 --- a/src/contentType.test.ts +++ b/src/contentType.test.ts @@ -4,9 +4,9 @@ import { getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./coValue"; -import { createdNowUnique } from "./crypto"; -import { LocalNode } from "./node"; +} from './coValue.js'; +import { createdNowUnique } from "./crypto.js"; +import { LocalNode } from "./node.js"; test("Empty COJSON Map works", () => { const agentCredential = newRandomAgentCredential("agent1"); diff --git a/src/contentType.ts b/src/contentType.ts index 8f389e10e..7c3d9529a 100644 --- a/src/contentType.ts +++ b/src/contentType.ts @@ -1,9 +1,9 @@ -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"; +import { JsonValue } from './jsonValue.js'; +import { RawCoValueID } from './ids.js'; +import { CoMap } from './contentTypes/coMap.js'; +import { CoStream } from './contentTypes/coStream.js'; +import { Static } from './contentTypes/static.js'; +import { CoList } from './contentTypes/coList.js'; export type CoValueID = RawCoValueID & { readonly __type: T; diff --git a/src/contentTypes/coList.ts b/src/contentTypes/coList.ts index 4ea8c1f6b..8dad1b52e 100644 --- a/src/contentTypes/coList.ts +++ b/src/contentTypes/coList.ts @@ -1,10 +1,10 @@ -import { JsonObject, JsonValue } from "../jsonValue"; -import { CoValueID } from "../contentType"; -import { CoValue } from "../coValue"; +import { JsonObject, JsonValue } from '../jsonValue.js'; +import { CoValueID } from '../contentType.js'; +import { CoValue } from '../coValue.js'; export class CoList { id: CoValueID>; - type: "colist" = "colist"; + type = "colist" as const; coValue: CoValue; constructor(coValue: CoValue) { diff --git a/src/contentTypes/coMap.ts b/src/contentTypes/coMap.ts index 6fd45aa32..a25def337 100644 --- a/src/contentTypes/coMap.ts +++ b/src/contentTypes/coMap.ts @@ -1,7 +1,7 @@ -import { JsonObject, JsonValue } from "../jsonValue"; -import { TransactionID } from "../ids"; -import { CoValueID } from "../contentType"; -import { CoValue } from "../coValue"; +import { JsonObject, JsonValue } from '../jsonValue.js'; +import { TransactionID } from '../ids.js'; +import { CoValueID } from '../contentType.js'; +import { CoValue } from '../coValue.js'; type MapOp = { txID: TransactionID; @@ -31,7 +31,7 @@ export class CoMap< > { id: CoValueID>; coValue: CoValue; - type: "comap" = "comap"; + type = "comap" as const; ops: { [KK in K]?: MapOp[]; }; @@ -61,7 +61,7 @@ export class CoMap< txID, madeAt, changeIdx, - ...(change as any), + ...(change as MapOpPayload), }); } } diff --git a/src/contentTypes/coStream.ts b/src/contentTypes/coStream.ts index ab17583ad..daa040d91 100644 --- a/src/contentTypes/coStream.ts +++ b/src/contentTypes/coStream.ts @@ -1,10 +1,10 @@ -import { JsonObject, JsonValue } from "../jsonValue"; -import { CoValueID } from "../contentType"; -import { CoValue } from "../coValue"; +import { JsonObject, JsonValue } from '../jsonValue.js'; +import { CoValueID } from '../contentType.js'; +import { CoValue } from '../coValue.js'; export class CoStream { id: CoValueID>; - type: "costream" = "costream"; + type = "costream" as const; coValue: CoValue; constructor(coValue: CoValue) { diff --git a/src/contentTypes/static.ts b/src/contentTypes/static.ts index db17d1e18..fafc82e57 100644 --- a/src/contentTypes/static.ts +++ b/src/contentTypes/static.ts @@ -1,10 +1,10 @@ -import { JsonObject, JsonValue } from "../jsonValue"; -import { CoValueID } from "../contentType"; -import { CoValue } from "../coValue"; +import { JsonObject, JsonValue } from '../jsonValue.js'; +import { CoValueID } from '../contentType.js'; +import { CoValue } from '../coValue.js'; export class Static { id: CoValueID>; - type: "static" = "static"; + type = "static" as const; coValue: CoValue; constructor(coValue: CoValue) { @@ -16,7 +16,7 @@ export class Static { throw new Error("Method not implemented."); } - subscribe(listener: (coMap: Static) => void): () => void { + subscribe(_listener: (coMap: Static) => void): () => void { throw new Error("Method not implemented."); } } diff --git a/src/crypto.test.ts b/src/crypto.test.ts index fa04d4695..f4a4bdac1 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -14,7 +14,7 @@ import { decryptForTransaction, sealKeySecret, unsealKeySecret, -} from "./crypto"; +} from './crypto.js'; import { base58, base64url } from "@scure/base"; import { x25519 } from "@noble/curves/ed25519"; import { xsalsa20_poly1305 } from "@noble/ciphers/salsa"; diff --git a/src/crypto.ts b/src/crypto.ts index 10fc7a6e2..d8e23c569 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,11 +1,11 @@ import { ed25519, x25519 } from "@noble/curves/ed25519"; import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa"; -import { JsonValue } from "./jsonValue"; +import { JsonValue } from './jsonValue.js'; 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"; +import { RawCoValueID, TransactionID } from './ids.js'; export type SignatorySecret = `signatorySecret_z${string}`; export type SignatoryID = `signatory_z${string}`; diff --git a/src/index.ts b/src/index.ts index 76f03e963..6fb7864d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,15 +6,15 @@ import { getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./coValue"; -import { LocalNode } from "./node"; -import { CoMap } from "./contentTypes/coMap"; +} from './coValue.js'; +import { LocalNode } from './node.js'; +import { CoMap } from './contentTypes/coMap.js'; -import type { AgentCredential } from "./coValue"; -import type { AgentID, SessionID } from "./ids"; -import type { CoValueID, ContentType } from "./contentType"; -import type { JsonValue } from "./jsonValue"; -import type { SyncMessage } from "./sync"; +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'; type Value = JsonValue | ContentType; diff --git a/src/jsonValue.ts b/src/jsonValue.ts index 67bc29e0c..6a0ebf747 100644 --- a/src/jsonValue.ts +++ b/src/jsonValue.ts @@ -1,4 +1,4 @@ -import { CoValueID, ContentType } from "./contentType"; +import { CoValueID, ContentType } from './contentType.js'; export type JsonAtom = string | number | boolean | null; export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID; diff --git a/src/node.ts b/src/node.ts index 76474f0b0..3810dea1f 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,4 +1,4 @@ -import { createdNowUnique, newRandomKeySecret, seal } from "./crypto"; +import { createdNowUnique, newRandomKeySecret, seal } from './crypto.js'; import { CoValue, AgentCredential, @@ -7,13 +7,12 @@ import { getAgentID, getAgentCoValueHeader, CoValueHeader, - agentIDfromSessionID, newRandomAgentCredential, -} from "./coValue"; -import { Team, expectTeamContent } from "./permissions"; -import { SyncManager } from "./sync"; -import { AgentID, RawCoValueID, SessionID } from "./ids"; -import { CoValueID, ContentType } from "."; +} 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'; export class LocalNode { coValues: { [key: RawCoValueID]: CoValueState } = {}; diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 86c550799..ea25a66c7 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -3,17 +3,17 @@ import { getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./coValue"; -import { LocalNode } from "./node"; -import { expectMap } from "./contentType"; -import { expectTeamContent } from "./permissions"; +} from './coValue.js'; +import { LocalNode } from './node.js'; +import { expectMap } from './contentType.js'; +import { expectTeamContent } from './permissions.js'; import { createdNowUnique, getRecipientID, newRandomKeySecret, seal, sealKeySecret, -} from "./crypto"; +} from './crypto.js'; function teamWithTwoAdmins() { const { team, admin, adminID, node } = newTeam(); diff --git a/src/permissions.ts b/src/permissions.ts index efe8027ac..d1af46acc 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,6 +1,6 @@ -import { ContentType } from "./contentType"; -import { CoMap, MapOpPayload } from "./contentTypes/coMap"; -import { JsonValue } from "./jsonValue"; +import { ContentType } from './contentType.js'; +import { CoMap, MapOpPayload } from './contentTypes/coMap.js'; +import { JsonValue } from './jsonValue.js'; import { Encrypted, KeyID, @@ -12,16 +12,16 @@ import { newRandomKeySecret, seal, sealKeySecret, -} from "./crypto"; +} from './crypto.js'; import { AgentCredential, CoValue, Transaction, TrustingTransaction, agentIDfromSessionID, -} from "./coValue"; -import { LocalNode } from "."; -import { AgentID, RawCoValueID, SessionID, TransactionID } from "./ids"; +} from './coValue.js'; +import { LocalNode } from "./node.js"; +import { AgentID, RawCoValueID, SessionID, TransactionID } from './ids.js'; export type PermissionsDef = | { type: "team"; initialAdmin: AgentID; parentTeams?: RawCoValueID[] } diff --git a/src/sync.test.ts b/src/sync.test.ts index 460a80f1c..91fc4a484 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -3,18 +3,18 @@ import { getAgentID, newRandomAgentCredential, newRandomSessionID, -} from "./coValue"; -import { LocalNode } from "./node"; -import { Peer, PeerID, SyncMessage } from "./sync"; -import { expectMap } from "./contentType"; -import { MapOpPayload } from "./contentTypes/coMap"; -import { Team } from "./permissions"; +} 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"; +import { AgentID } from './ids.js'; test( "Node replies with initial tx and header to empty subscribe", diff --git a/src/sync.ts b/src/sync.ts index cd40324d1..1d580d8b1 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,10 +1,10 @@ -import { Hash, Signature } from "./crypto"; -import { CoValueHeader, Transaction } from "./coValue"; -import { CoValue } from "./coValue"; -import { LocalNode } from "./node"; -import { newLoadingState } from "./node"; +import { Hash, Signature } from './crypto.js'; +import { CoValueHeader, Transaction } from './coValue.js'; +import { CoValue } from './coValue.js'; +import { LocalNode } from './node.js'; +import { newLoadingState } from './node.js'; import { ReadableStream, WritableStream, WritableStreamDefaultWriter } from "isomorphic-streams"; -import { RawCoValueID, SessionID } from "./ids"; +import { RawCoValueID, SessionID } from './ids.js'; export type CoValueKnownState = { coValueID: RawCoValueID; diff --git a/tsconfig.json b/tsconfig.json index a255db408..12d1abe71 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,18 +2,12 @@ "compilerOptions": { "lib": ["ESNext"], "module": "esnext", - "target": "esnext", + "target": "ES2020", "moduleResolution": "bundler", "moduleDetection": "force", - "allowImportingTsExtensions": true, "strict": true, - "downlevelIteration": true, "skipLibCheck": true, - "jsx": "preserve", - "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, - "allowJs": true, - "noEmit": true, "noUncheckedIndexedAccess": true, "esModuleInterop": true, }, diff --git a/yarn.lock b/yarn.lock index de95ff376..774bc2db4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -301,116 +301,6 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@esbuild/android-arm64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.0.tgz#c5ea635bdbe9b83d1f78a711120814e716439029" - integrity sha512-AzsozJnB+RNaDncBCs3Ys5g3kqhPFUueItfEaCpp89JH2naFNX2mYDIvUgPYMqqjm8hiFoo+jklb3QHZyR3ubw== - -"@esbuild/android-arm@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.0.tgz#6eb6e1fbc0dbfafa035aaef8b5ecde25b539fcf9" - integrity sha512-GAkjUyHgWTYuex3evPd5V7uV/XS4LMKr1PWHRPW1xNyy/Jx08x3uTrDFRefBYLKT/KpaWM8/YMQcwbp5a3yIDA== - -"@esbuild/android-x64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.0.tgz#99f154f71f5b92e778468bcf0f425d166c17bf20" - integrity sha512-SUG8/qiVhljBDpdkHQ9DvOWbp7hFFIP0OzxOTptbmVsgBgzY6JWowmMd6yJuOhapfxmj/DrvwKmjRLvVSIAKZg== - -"@esbuild/darwin-arm64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.0.tgz#2fcc11abf95fbabbf9167db6a11d899385bd777b" - integrity sha512-HkxZ8k3Jvcw0FORPNTavA8BMgQjLOB6AajT+iXmil7BwY3gU1hWvJJAyWyEogCmA4LdbGvKF8vEykdmJ4xNJJQ== - -"@esbuild/darwin-x64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.0.tgz#b5bbde35468db093fdf994880b0eb4b62613b67c" - integrity sha512-9IRWJjqpWFHM9a5Qs3r3bK834NCFuDY5ZaLrmTjqE+10B6w65UMQzeZjh794JcxpHolsAHqwsN/33crUXNCM2Q== - -"@esbuild/freebsd-arm64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.0.tgz#3f64c76dc590f79cc40acef6b22dd5eb89fc2125" - integrity sha512-s7i2WcXcK0V1PJHVBe7NsGddsL62a9Vhpz2U7zapPrwKoFuxPP9jybwX8SXnropR/AOj3ppt2ern4ItblU6UQQ== - -"@esbuild/freebsd-x64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.0.tgz#14d497e9e858fba2bb9b16130602b7f5944bc09c" - integrity sha512-NMdBSSdgwHCqCsucU5k1xflIIRU0qi1QZnM6+vdGy5fvxm1c8rKh50VzsWsIVTFUG3l91AtRxVwoz3Lcvy3I5w== - -"@esbuild/linux-arm64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.0.tgz#0f2f4d8889f7dc89681c306d7312aa76445a5f65" - integrity sha512-I4zvE2srSZxRPapFnNqj+NL3sDJ1wkvEZqt903OZUlBBgigrQMvzUowvP/TTTu2OGYe1oweg5MFilfyrElIFag== - -"@esbuild/linux-arm@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.0.tgz#0b0f79dc72884f0ad02c0aabfc969a0bee7f6775" - integrity sha512-2F1+lH7ZBcCcgxiSs8EXQV0PPJJdTNiNcXxDb61vzxTRJJkXX1I/ye9mAhfHyScXzHaEibEXg1Jq9SW586zz7w== - -"@esbuild/linux-ia32@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.0.tgz#dfcece1f5e74d0e7db090475e48b28d9aa270687" - integrity sha512-dz2Q7+P92r1Evc8kEN+cQnB3qqPjmCrOZ+EdBTn8lEc1yN8WDgaDORQQiX+mxaijbH8npXBT9GxUqE52Gt6Y+g== - -"@esbuild/linux-loong64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.0.tgz#710f5bd55db3f5d9ebac8773ea49795261a35ca7" - integrity sha512-IcVJovJVflih4oFahhUw+N7YgNbuMSVFNr38awb0LNzfaiIfdqIh518nOfYaNQU3aVfiJnOIRVJDSAP4k35WxA== - -"@esbuild/linux-mips64el@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.0.tgz#a918b310f9bf31fced3853ca52fee6e7acc09824" - integrity sha512-bZGRAGySMquWsKw0gIdsClwfvgbsSq/7oq5KVu1H1r9Il+WzOcfkV1hguntIuBjRVL8agI95i4AukjdAV2YpUw== - -"@esbuild/linux-ppc64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.0.tgz#104771ef6ce2719ac17031f6b9ed8aa98f8e5faf" - integrity sha512-3LC6H5/gCDorxoRBUdpLV/m7UthYSdar0XcCu+ypycQxMS08MabZ06y1D1yZlDzL/BvOYliRNRWVG/YJJvQdbg== - -"@esbuild/linux-riscv64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.0.tgz#83beafa472ad4224adcd4d7469e3a17ba1fbd976" - integrity sha512-jfvdKjWk+Cp2sgLtEEdSHXO7qckrw2B2eFBaoRdmfhThqZs29GMMg7q/LsQpybA7BxCLLEs4di5ucsWzZC5XPA== - -"@esbuild/linux-s390x@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.0.tgz#edc26cb41d8745716bda9c26bac1f0001eaad029" - integrity sha512-ofcucfNLkoXmcnJaw9ugdEOf40AWKGt09WBFCkpor+vFJVvmk/8OPjl/qRtks2Z7BuZbG3ztJuK1zS9z5Cgx9A== - -"@esbuild/linux-x64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.0.tgz#80a6b5e55ad454e0c0af5bdb267335287e331007" - integrity sha512-Fpf7zNDBti3xrQKQKLdXT0hTyOxgFdRJIMtNy8x1az9ATR9/GJ1brYbB/GLWoXhKiHsoWs+2DLkFVNNMTCLEwA== - -"@esbuild/netbsd-x64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.0.tgz#2e6e8d869b58aea34bab9c0c47f15ae1bda29a90" - integrity sha512-AMQAp/5oENgDOvVhvOlbhVe1pWii7oFAMRHlmTjSEMcpjTpIHtFXhv9uAFgUERHm3eYtNvS9Vf+gT55cwuI6Aw== - -"@esbuild/openbsd-x64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.0.tgz#ca0817d3ab332afb0d8d96a2eb42b4d8ebaa8715" - integrity sha512-fDztEve1QUs3h/Dw2AUmBlWGkNQbhDoD05ppm5jKvzQv+HVuV13so7m5RYeiSMIC2XQy7PAjZh+afkxAnCRZxA== - -"@esbuild/sunos-x64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.0.tgz#8de27de2563cb3eb6c1af066b6d7fcb1229fe3d4" - integrity sha512-bKZzJ2/rvUjDzA5Ddyva2tMk89WzNJEibZEaq+wY6SiqPlwgFbqyQLimouxLHiHh1itb5P3SNCIF1bc2bw5H9w== - -"@esbuild/win32-arm64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.0.tgz#67c2b410ff8862be2cd61145ad21e11be00fb914" - integrity sha512-NQJ+4jmnA79saI+sE+QzcEls19uZkoEmdxo7r//PDOjIpX8pmoWtTnWg6XcbnO7o4fieyAwb5U2LvgWynF4diA== - -"@esbuild/win32-ia32@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.0.tgz#cac8992219c6d943bb22226e4afeb3774a29cca1" - integrity sha512-uyxiZAnsfu9diHm9/rIH2soecF/HWLXYUhJKW4q1+/LLmNQ+55lRjvSUDhUmsgJtSUscRJB/3S4RNiTb9o9mCg== - -"@esbuild/win32-x64@0.19.0": - version "0.19.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.0.tgz#fa5f12c96811cec3233a53bdbf61d1a05ba9018f" - integrity sha512-jl+NXUjK2StMgqnZnqgNjZuerFG8zQqWXMBZdMMv4W/aO1ZKQaYWZBxTrtWKphkCBVEMh0wMVfGgOd2BjOZqUQ== - "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -1326,34 +1216,6 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -esbuild@^0.19.0: - version "0.19.0" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.0.tgz#f187e4ce3bcc7396d13f408a991655efeba65282" - integrity sha512-i7i8TP4vuG55bKeLyqqk5sTPu1ZjPH3wkcLvAj/0X/222iWFo3AJUYRKjbOoY6BWFMH3teizxHEdV9Su5ESl0w== - optionalDependencies: - "@esbuild/android-arm" "0.19.0" - "@esbuild/android-arm64" "0.19.0" - "@esbuild/android-x64" "0.19.0" - "@esbuild/darwin-arm64" "0.19.0" - "@esbuild/darwin-x64" "0.19.0" - "@esbuild/freebsd-arm64" "0.19.0" - "@esbuild/freebsd-x64" "0.19.0" - "@esbuild/linux-arm" "0.19.0" - "@esbuild/linux-arm64" "0.19.0" - "@esbuild/linux-ia32" "0.19.0" - "@esbuild/linux-loong64" "0.19.0" - "@esbuild/linux-mips64el" "0.19.0" - "@esbuild/linux-ppc64" "0.19.0" - "@esbuild/linux-riscv64" "0.19.0" - "@esbuild/linux-s390x" "0.19.0" - "@esbuild/linux-x64" "0.19.0" - "@esbuild/netbsd-x64" "0.19.0" - "@esbuild/openbsd-x64" "0.19.0" - "@esbuild/sunos-x64" "0.19.0" - "@esbuild/win32-arm64" "0.19.0" - "@esbuild/win32-ia32" "0.19.0" - "@esbuild/win32-x64" "0.19.0" - escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -1374,6 +1236,11 @@ escape-string-regexp@^4.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== +eslint-plugin-require-extensions@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/eslint-plugin-require-extensions/-/eslint-plugin-require-extensions-0.1.3.tgz#394aeab433f996797a6ceba0a3f75640d4846bc8" + integrity sha512-T3c1PZ9PIdI3hjV8LdunfYI8gj017UQjzAnCrxuo3wAjneDbTPHdE3oNWInOjMA+z/aBkUtlW5vC0YepYMZIug== + eslint-scope@^7.2.2: version "7.2.2" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" From 9d619358a5c6e8ff211dca2137d7e3a116332eaf Mon Sep 17 00:00:00 2001 From: Anselm Date: Fri, 11 Aug 2023 13:27:01 +0100 Subject: [PATCH 34/47] Handle disconnecting peers --- package.json | 26 +++++++-- src/sync.test.ts | 141 ++++++++++++++++++++++++++++++++++++++++++++++- src/sync.ts | 47 ++++++++++------ 3 files changed, 191 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index c8c485211..82f15a99b 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "cojson", - "module": "dist/index.mjs", - "main": "dist/index.mjs", + "module": "dist/index.js", + "main": "dist/index.js", "types": "src/index.ts", "type": "module", "license": "MIT", - "version": "0.0.8", + "version": "0.0.10", "devDependencies": { "@types/jest": "^29.5.3", "@typescript-eslint/eslint-plugin": "^6.2.1", @@ -32,6 +32,24 @@ }, "jest": { "preset": "ts-jest", - "testEnvironment": "node" + "testEnvironment": "node", + "transform": { + "\\.[jt]sx?$": [ + "ts-jest", + { + "useESM": true + } + ] + }, + "moduleNameMapper": { + "(.+)\\.js": "$1" + }, + "extensionsToTreatAsEsm": [ + ".ts" + ], + "modulePathIgnorePatterns": [ + "/node_modules/", + "/dist/" + ] } } diff --git a/src/sync.test.ts b/src/sync.test.ts index 91fc4a484..ad8e771a6 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -980,6 +980,123 @@ 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 team = node.createTeam(); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.sync.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + role: "server", + }); + + const reader = outRx.getReader(); + expect((await reader.read()).value).toMatchObject({ + action: "subscribe", + coValueID: adminID, + }); + expect((await reader.read()).value).toMatchObject({ + action: "subscribe", + coValueID: team.teamMap.coValue.id, + }); + + const map = team.createMap(); + + const mapSubscribeMsg = await reader.read(); + + expect(mapSubscribeMsg.value).toEqual({ + action: "subscribe", + ...map.coValue.knownState(), + } satisfies SyncMessage); + + expect((await reader.read()).value).toMatchObject(admContEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamContentEx(team)); + + const mapContentMsg = await reader.read(); + + expect(mapContentMsg.value).toEqual({ + action: "newContent", + coValueID: map.coValue.id, + header: map.coValue.header, + newContent: {}, + } satisfies SyncMessage); + + await inTx.abort(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(node.sync.peers["test"]).toBeUndefined(); +}); + +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 team = node.createTeam(); + + const [inRx, inTx] = newStreamPair(); + const [outRx, outTx] = newStreamPair(); + + node.sync.addPeer({ + id: "test", + incoming: inRx, + outgoing: outTx, + role: "server", + }); + + const reader = outRx.getReader(); + expect((await reader.read()).value).toMatchObject({ + action: "subscribe", + coValueID: adminID, + }); + expect((await reader.read()).value).toMatchObject({ + action: "subscribe", + coValueID: team.teamMap.coValue.id, + }); + + const map = team.createMap(); + + const mapSubscribeMsg = await reader.read(); + + expect(mapSubscribeMsg.value).toEqual({ + action: "subscribe", + ...map.coValue.knownState(), + } satisfies SyncMessage); + + expect((await reader.read()).value).toMatchObject(admContEx(adminID)); + expect((await reader.read()).value).toMatchObject(teamContentEx(team)); + + const mapContentMsg = await reader.read(); + + expect(mapContentMsg.value).toEqual({ + action: "newContent", + coValueID: map.coValue.id, + header: map.coValue.header, + newContent: {}, + } satisfies SyncMessage); + + reader.releaseLock(); + await outRx.cancel(); + + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(node.sync.peers["test"]).toBeUndefined(); +}) + function teamContentEx(team: Team) { return { action: "newContent", @@ -1015,10 +1132,17 @@ function newStreamPair(): [ReadableStream, WritableStream] { resolveNextItemReady = resolve; }); + let writerClosed = false; + let readerClosed = false; + const readable = new ReadableStream({ async pull(controller) { let retriesLeft = 3; while (retriesLeft > 0) { + if (writerClosed) { + controller.close(); + return; + } retriesLeft--; if (queue.length > 0) { controller.enqueue(queue.shift()!); @@ -1034,16 +1158,31 @@ function newStreamPair(): [ReadableStream, WritableStream] { } throw new Error("Should only use one retry to get next item in queue.") }, + + cancel(reason) { + console.log("Manually closing reader") + readerClosed = true; + }, }); const writable = new WritableStream({ - write(chunk) { + write(chunk, controller) { + if (readerClosed) { + console.log("Reader closed, not writing chunk", chunk); + throw new Error("Reader closed, not writing chunk"); + } queue.push(chunk); if (queue.length === 1) { // make sure that await write resolves before corresponding read process.nextTick(() => resolveNextItemReady()); } }, + abort(reason) { + console.log("Manually closing writer") + writerClosed = true; + resolveNextItemReady(); + return Promise.resolve(); + }, }); return [readable, writable]; diff --git a/src/sync.ts b/src/sync.ts index 1d580d8b1..22fdef56c 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,10 +1,14 @@ -import { Hash, Signature } from './crypto.js'; -import { CoValueHeader, Transaction } from './coValue.js'; -import { CoValue } from './coValue.js'; -import { LocalNode } from './node.js'; -import { newLoadingState } from './node.js'; -import { ReadableStream, WritableStream, WritableStreamDefaultWriter } from "isomorphic-streams"; -import { RawCoValueID, SessionID } from './ids.js'; +import { Hash, Signature } from "./crypto.js"; +import { CoValueHeader, Transaction } from "./coValue.js"; +import { CoValue } from "./coValue.js"; +import { LocalNode } from "./node.js"; +import { newLoadingState } from "./node.js"; +import { + ReadableStream, + WritableStream, + WritableStreamDefaultWriter, +} from "isomorphic-streams"; +import { RawCoValueID, SessionID } from "./ids.js"; export type CoValueKnownState = { coValueID: RawCoValueID; @@ -162,7 +166,7 @@ export class SyncManager { if (!peer.toldKnownState.has(coValueID)) { peer.toldKnownState.add(coValueID); - await peer.outgoing.write({ + await this.trySendToPeer(peer, { action: "subscribe", ...coValue.knownState(), }); @@ -185,7 +189,7 @@ export class SyncManager { } if (!peer.toldKnownState.has(coValueID)) { - await peer.outgoing.write({ + await this.trySendToPeer(peer, { action: "tellKnownState", asDependencyOf, ...coValue.knownState(), @@ -210,7 +214,7 @@ export class SyncManager { ); if (newContent) { - await peer.outgoing.write(newContent); + await this.trySendToPeer(peer, newContent); peer.optimisticKnownStates[coValueID] = combinedKnownStates( peer.optimisticKnownStates[coValueID] || emptyKnownState(coValueID), @@ -264,11 +268,20 @@ export class SyncManager { ); } } + console.log("Peer disconnected:", peer.id); + delete this.peers[peer.id]; }; void readIncoming(); } + trySendToPeer(peer: PeerState, msg: SyncMessage) { + return peer.outgoing.write(msg).catch((e) => { + console.error("Error writing to peer, disconnecting", e); + delete this.peers[peer.id]; + }); + } + async handleSubscribe(msg: SubscribeMessage, peer: PeerState) { const entry = this.local.coValues[msg.coValueID]; @@ -280,7 +293,7 @@ export class SyncManager { peer.optimisticKnownStates[msg.coValueID] = knownStateIn(msg); peer.toldKnownState.add(msg.coValueID); - await peer.outgoing.write({ + await this.trySendToPeer(peer, { action: "tellKnownState", coValueID: msg.coValueID, header: false, @@ -304,7 +317,8 @@ export class SyncManager { let entry = this.local.coValues[msg.coValueID]; peer.optimisticKnownStates[msg.coValueID] = combinedKnownStates( - peer.optimisticKnownStates[msg.coValueID] || emptyKnownState(msg.coValueID), + peer.optimisticKnownStates[msg.coValueID] || + emptyKnownState(msg.coValueID), knownStateIn(msg) ); @@ -423,7 +437,7 @@ export class SyncManager { await this.syncCoValue(coValue); if (invalidStateAssumed) { - await peer.outgoing.write({ + await this.trySendToPeer(peer, { action: "wrongAssumedKnownState", ...coValue.knownState(), }); @@ -444,7 +458,7 @@ export class SyncManager { const newContent = coValue.newContentSince(msg); if (newContent) { - await peer.outgoing.write(newContent); + await this.trySendToPeer(peer, newContent); } } @@ -466,10 +480,7 @@ export class SyncManager { peer ); } else if (peer.role === "server") { - await this.subscribeToIncludingDependencies( - coValue.id, - peer - ); + await this.subscribeToIncludingDependencies(coValue.id, peer); await this.sendNewContentIncludingDependencies( coValue.id, peer From a82959d0229ed19fec9431ccd5031a71c7a7a5a1 Mon Sep 17 00:00:00 2001 From: Anselm Date: Fri, 11 Aug 2023 14:52:12 +0100 Subject: [PATCH 35/47] Support subscribing to server while loading --- package.json | 2 +- src/sync.test.ts | 32 ++++++++++++++++++++++++++++++++ src/sync.ts | 37 +++++++++++++++++++++++++------------ 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 82f15a99b..de210388f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "types": "src/index.ts", "type": "module", "license": "MIT", - "version": "0.0.10", + "version": "0.0.11", "devDependencies": { "@types/jest": "^29.5.3", "@typescript-eslint/eslint-plugin": "^6.2.1", diff --git a/src/sync.test.ts b/src/sync.test.ts index ad8e771a6..1a2e65b7d 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -1097,6 +1097,38 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async 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 node1 = new LocalNode(admin, newRandomSessionID(adminID)); + + const team = node1.createTeam(); + + const map = team.createMap(); + map.edit((editable) => { + editable.set("hello", "world", "trusting"); + }); + + const node2 = new LocalNode(admin, newRandomSessionID(adminID)); + + const [node1asPeer, node2asPeer] = connectedPeers("peer1", "peer2", {peer1role: 'server', peer2role: 'client', trace: true}); + + node1.sync.addPeer(node2asPeer); + + const mapOnNode2Promise = node2.loadCoValue(map.coValue.id); + + expect(node2.coValues[map.coValue.id]?.state).toEqual("loading"); + + node2.sync.addPeer(node1asPeer); + + const mapOnNode2 = await mapOnNode2Promise; + + expect(expectMap(mapOnNode2.getCurrentContent()).get("hello")).toEqual( + "world" + ); +}) + function teamContentEx(team: Team) { return { action: "newContent", diff --git a/src/sync.ts b/src/sync.ts index 22fdef56c..042632360 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -158,7 +158,25 @@ export class SyncManager { coValueID: RawCoValueID, peer: PeerState ) { - const coValue = this.local.expectCoValueLoaded(coValueID); + const entry = this.local.coValues[coValueID]; + + if (!entry) { + throw new Error( + "Expected coValue entry on subscribe" + ); + } + + if (entry.state === "loading") { + await this.trySendToPeer(peer, { + action: "subscribe", + coValueID, + header: false, + sessions: {}, + }); + return; + } + + const coValue = entry.coValue; for (const coValueID of coValue.getDependedOnCoValues()) { await this.subscribeToIncludingDependencies(coValueID, peer); @@ -236,18 +254,13 @@ export class SyncManager { if (peer.role === "server") { const initialSync = async () => { - for (const entry of Object.values(this.local.coValues)) { - if (entry.state === "loading") { - continue; - } + for (const id of Object.keys( + this.local.coValues + ) as RawCoValueID[]) { + await this.subscribeToIncludingDependencies(id, peerState); - await this.subscribeToIncludingDependencies( - entry.coValue.id, - peerState - ); - - peerState.optimisticKnownStates[entry.coValue.id] = { - coValueID: entry.coValue.id, + peerState.optimisticKnownStates[id] = { + coValueID: id, header: false, sessions: {}, }; From cd48685b72be8d077b1c11d4ba52f60619c188c1 Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 14 Aug 2023 15:10:09 +0100 Subject: [PATCH 36/47] First step to make accounts teams --- src/account.ts | 115 +++++++++ src/coValue.test.ts | 48 ++-- src/coValue.ts | 176 ++++--------- src/contentType.test.ts | 63 ++--- src/crypto.ts | 49 +++- src/ids.ts | 10 +- src/index.ts | 37 +-- src/node.ts | 133 ++++++---- src/permissions.test.ts | 539 ++++++++++++++++------------------------ src/permissions.ts | 67 +++-- src/sync.test.ts | 394 ++++++++++++++--------------- src/testUtils.ts | 79 ++++++ 12 files changed, 854 insertions(+), 856 deletions(-) create mode 100644 src/account.ts create mode 100644 src/testUtils.ts diff --git a/src/account.ts b/src/account.ts new file mode 100644 index 000000000..551568ef2 --- /dev/null +++ b/src/account.ts @@ -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 { RawAgentID } from './ids.js'; +import { CoMap, LocalNode } from './index.js'; +import { Team, TeamContent } from './permissions.js'; + +export function accountHeaderForInitialAgentSecret(agentSecret: AgentSecret): CoValueHeader { + const rawAgentID = getAgentID(agentSecret); + return { + type: "comap", + ruleset: {type: "team", initialAdmin: rawAgentID}, + meta: { + type: "account" + }, + createdAt: null, + uniqueness: null, + } +} + +export class Account extends Team { + get id(): AccountID { + return this.teamMap.id; + } + + getCurrentAgentID(): RawAgentID { + const agents = this.teamMap.keys().filter((k): k is RawAgentID => 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: () => RawAgentID; + currentSignatoryID: () => SignatoryID; + currentSignatorySecret: () => SignatorySecret; + currentRecipientID: () => RecipientID; + currentRecipientSecret: () => RecipientSecret; +} + +export class ControlledAccount extends Account implements GeneralizedControlledAccount { + agentSecret: AgentSecret; + + constructor(agentSecret: AgentSecret, teamMap: CoMap, node: LocalNode) { + super(teamMap, node); + + this.agentSecret = agentSecret; + } + + currentAgentID(): RawAgentID { + 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(): RawAgentID { + return getAgentID(this.agentSecret); + } + + currentAgentID(): RawAgentID { + 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>; + +export type AccountIDOrAgentID = RawAgentID | AccountID; +export type AccountOrAgentID = RawAgentID | Account; +export type AccountOrAgentSecret = AgentSecret | Account; diff --git a/src/coValue.test.ts b/src/coValue.test.ts index 340afb2b8..5d4f8a88d 100644 --- a/src/coValue.test.ts +++ b/src/coValue.test.ts @@ -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); }); diff --git a/src/coValue.ts b/src/coValue.ts index a8ff227b0..e1967e9ac 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -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; +export { SessionID }; diff --git a/src/contentType.test.ts b/src/contentType.test.ts index 3979c0316..5cdb4988e 100644 --- a/src/contentType.test.ts +++ b/src/contentType.test.ts @@ -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"); diff --git a/src/crypto.ts b/src/crypto.ts index d8e23c569..9553207d8 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -5,7 +5,7 @@ import { base58, base64url } from "@scure/base"; import stableStringify from "fast-json-stable-stringify"; import { blake3 } from "@noble/hashes/blake3"; import { randomBytes } from "@noble/ciphers/webcrypto/utils"; -import { RawCoValueID, TransactionID } from './ids.js'; +import { RawAgentID, 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 = `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): RawAgentID { + const [recipientSecret, signatorySecret] = secret.split("/"); + return `${getRecipientID( + recipientSecret as RecipientSecret + )}/${getSignatoryID(signatorySecret as SignatorySecret)}`; +} + +export function getAgentSignatoryID(agentId: RawAgentID): SignatoryID { + return agentId.split("/")[1] as SignatoryID; +} + +export function getAgentSignatorySecret(agentSecret: AgentSecret): SignatorySecret { + return agentSecret.split("/")[1] as SignatorySecret; +} + +export function getAgentRecipientID(agentId: RawAgentID): RecipientID { + return agentId.split("/")[0] as RecipientID; +} + +export function getAgentRecipientSecret(agentSecret: AgentSecret): RecipientSecret { + return agentSecret.split("/")[0] as RecipientSecret; +} + export type SealedSet = { [recipient: RecipientID]: Sealed; }; diff --git a/src/ids.ts b/src/ids.ts index 8d1b6c90e..92c7d39ca 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -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 RawAgentID = `recipient_z${string}/signatory_z${string}`; -export type SessionID = `${AgentID}_session_z${string}`; +export function isRawAgentID(id: string): id is RawAgentID { + return typeof id === "string" && id.startsWith("recipient_") && id.includes("/signatory_"); +} + +export type SessionID = `${AccountIDOrAgentID}_session_z${string}`; diff --git a/src/index.ts b/src/index.ts index 6fb7864d5..4fcfcae34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, }; diff --git a/src/node.ts b/src/node.ts index 3810dea1f..b85992e75 100644 --- a/src/node.ts +++ b/src/node.ts @@ -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 { RawAgentID, RawCoValueID, SessionID, isRawAgentID } 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, + this + ); } - expectAgentLoaded(id: AgentID, expectation?: string): Agent { - const coValue = this.expectCoValueLoaded( - id, - expectation - ); + resolveAccount(id: AccountIDOrAgentID, expectation?: string): RawAgentID { + if (isRawAgentID(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, + 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) diff --git a/src/permissions.test.ts b/src/permissions.test.ts index ea25a66c7..07beca442 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -1,155 +1,84 @@ -import { - getAgent, - getAgentID, - newRandomAgentCredential, - newRandomSessionID, -} from './coValue.js'; -import { LocalNode } from './node.js'; -import { expectMap } from './contentType.js'; -import { expectTeamContent } from './permissions.js'; +import { newRandomSessionID } from "./coValue.js"; +import { LocalNode } from "./node.js"; +import { expectMap } from "./contentType.js"; +import { expectTeamContent } from "./permissions.js"; import { createdNowUnique, getRecipientID, newRandomKeySecret, seal, sealKeySecret, -} from './crypto.js'; - -function teamWithTwoAdmins() { - const { team, admin, adminID, node } = newTeam(); - - const otherAdmin = node.createAgent("otherAdmin"); - const otherAdminID = getAgentID(getAgent(otherAdmin)); - - let content = expectTeamContent(team.getCurrentContent()); - - content.edit((editable) => { - editable.set(otherAdminID, "admin", "trusting"); - expect(editable.get(otherAdminID)).toEqual("admin"); - }); - - content = expectTeamContent(team.getCurrentContent()); - - if (content.type !== "comap") { - throw new Error("Expected map"); - } - - expect(content.get(otherAdminID)).toEqual("admin"); - return { team, admin, adminID, otherAdmin, otherAdminID, node }; -} - -function newTeam() { - const admin = newRandomAgentCredential("admin"); - const adminID = getAgentID(getAgent(admin)); - - const node = new LocalNode(admin, newRandomSessionID(adminID)); - - const team = node.createCoValue({ - type: "comap", - ruleset: { type: "team", initialAdmin: adminID }, - meta: null, - ...createdNowUnique(), - publicNickname: "team", - }); - - const teamContent = expectTeamContent(team.getCurrentContent()); - - teamContent.edit((editable) => { - editable.set(adminID, "admin", "trusting"); - expect(editable.get(adminID)).toEqual("admin"); - }); - - return { node, team, admin, adminID }; -} +} from "./crypto.js"; +import { + newTeam, + newTeamHighLevel, + teamWithTwoAdmins, + teamWithTwoAdminsHighLevel, +} from "./testUtils.js"; test("Initial admin can add another admin to a team", () => { teamWithTwoAdmins(); }); -function newTeamHighLevel() { - const admin = newRandomAgentCredential("admin"); - const adminID = getAgentID(getAgent(admin)); - - const node = new LocalNode(admin, newRandomSessionID(adminID)); - - const team = node.createTeam(); - - return { admin, adminID, node, team }; -} - -function teamWithTwoAdminsHighLevel() { - const { admin, adminID, node, team } = newTeamHighLevel(); - - const otherAdmin = node.createAgent("otherAdmin"); - const otherAdminID = getAgentID(getAgent(otherAdmin)); - - team.addMember(otherAdminID, "admin"); - - return { admin, adminID, node, team, otherAdmin, otherAdminID }; -} - test("Initial admin can add another admin to a team (high level)", () => { teamWithTwoAdminsHighLevel(); }); test("Added admin can add a third admin to a team", () => { - const { team, otherAdmin, otherAdminID, node } = teamWithTwoAdmins(); + const { team, otherAdmin, node } = teamWithTwoAdmins(); - const teamAsOtherAdmin = team.testWithDifferentCredentials( + const teamAsOtherAdmin = team.testWithDifferentAccount( otherAdmin, - newRandomSessionID(otherAdminID) + newRandomSessionID(otherAdmin.id) ); let otherContent = expectTeamContent(teamAsOtherAdmin.getCurrentContent()); - expect(otherContent.get(otherAdminID)).toEqual("admin"); + expect(otherContent.get(otherAdmin.id)).toEqual("admin"); - const thirdAdmin = node.createAgent("thirdAdmin"); - const thirdAdminID = getAgentID(getAgent(thirdAdmin)); + const thirdAdmin = node.createAccount("thirdAdmin"); otherContent.edit((editable) => { - editable.set(thirdAdminID, "admin", "trusting"); - expect(editable.get(thirdAdminID)).toEqual("admin"); + editable.set(thirdAdmin.id, "admin", "trusting"); + expect(editable.get(thirdAdmin.id)).toEqual("admin"); }); otherContent = expectTeamContent(teamAsOtherAdmin.getCurrentContent()); - expect(otherContent.get(thirdAdminID)).toEqual("admin"); + expect(otherContent.get(thirdAdmin.id)).toEqual("admin"); }); test("Added adming can add a third admin to a team (high level)", () => { - const { team, otherAdmin, otherAdminID, node } = - teamWithTwoAdminsHighLevel(); + const { team, otherAdmin, node } = teamWithTwoAdminsHighLevel(); - const teamAsOtherAdmin = team.testWithDifferentCredentials( + const teamAsOtherAdmin = team.testWithDifferentAccount( otherAdmin, - newRandomSessionID(otherAdminID) + newRandomSessionID(otherAdmin.id) ); - const thirdAdmin = node.createAgent("thirdAdmin"); - const thirdAdminID = getAgentID(getAgent(thirdAdmin)); + const thirdAdmin = node.createAccount("thirdAdmin"); - teamAsOtherAdmin.addMember(thirdAdminID, "admin"); + teamAsOtherAdmin.addMember(thirdAdmin.id, "admin"); - expect(teamAsOtherAdmin.teamMap.get(thirdAdminID)).toEqual("admin"); + expect(teamAsOtherAdmin.teamMap.get(thirdAdmin.id)).toEqual("admin"); }); test("Admins can't demote other admins in a team", () => { - const { team, adminID, otherAdmin, otherAdminID } = teamWithTwoAdmins(); + const { team, admin, otherAdmin } = teamWithTwoAdmins(); let teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { - editable.set(otherAdminID, "writer", "trusting"); - expect(editable.get(otherAdminID)).toEqual("admin"); + editable.set(otherAdmin.id, "writer", "trusting"); + expect(editable.get(otherAdmin.id)).toEqual("admin"); }); teamContent = expectTeamContent(team.getCurrentContent()); - expect(teamContent.get(otherAdminID)).toEqual("admin"); + expect(teamContent.get(otherAdmin.id)).toEqual("admin"); - const teamAsOtherAdmin = team.testWithDifferentCredentials( + const teamAsOtherAdmin = team.testWithDifferentAccount( otherAdmin, - newRandomSessionID(otherAdminID) + newRandomSessionID(otherAdmin.id) ); let teamContentAsOtherAdmin = expectTeamContent( @@ -157,185 +86,176 @@ test("Admins can't demote other admins in a team", () => { ); teamContentAsOtherAdmin.edit((editable) => { - editable.set(adminID, "writer", "trusting"); - expect(editable.get(adminID)).toEqual("admin"); + editable.set(admin.id, "writer", "trusting"); + expect(editable.get(admin.id)).toEqual("admin"); }); teamContentAsOtherAdmin = expectTeamContent( teamAsOtherAdmin.getCurrentContent() ); - expect(teamContentAsOtherAdmin.get(adminID)).toEqual("admin"); + expect(teamContentAsOtherAdmin.get(admin.id)).toEqual("admin"); }); test("Admins can't demote other admins in a team (high level)", () => { - const { team, adminID, otherAdmin, otherAdminID } = - teamWithTwoAdminsHighLevel(); + const { team, admin, otherAdmin } = teamWithTwoAdminsHighLevel(); - const teamAsOtherAdmin = team.testWithDifferentCredentials( + const teamAsOtherAdmin = team.testWithDifferentAccount( otherAdmin, - newRandomSessionID(otherAdminID) + newRandomSessionID(otherAdmin.id) ); - expect(() => teamAsOtherAdmin.addMember(adminID, "writer")).toThrow( + expect(() => teamAsOtherAdmin.addMember(admin.id, "writer")).toThrow( "Failed to set role" ); - expect(teamAsOtherAdmin.teamMap.get(adminID)).toEqual("admin"); + expect(teamAsOtherAdmin.teamMap.get(admin.id)).toEqual("admin"); }); test("Admins an add writers to a team, who can't add admins, writers, or readers", () => { const { team, node } = newTeam(); - const writer = node.createAgent("writer"); - const writerID = getAgentID(getAgent(writer)); + const writer = node.createAccount("writer"); let teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { - editable.set(writerID, "writer", "trusting"); - expect(editable.get(writerID)).toEqual("writer"); + editable.set(writer.id, "writer", "trusting"); + expect(editable.get(writer.id)).toEqual("writer"); }); teamContent = expectTeamContent(team.getCurrentContent()); - expect(teamContent.get(writerID)).toEqual("writer"); + expect(teamContent.get(writer.id)).toEqual("writer"); - const teamAsWriter = team.testWithDifferentCredentials( + const teamAsWriter = team.testWithDifferentAccount( writer, - newRandomSessionID(writerID) + newRandomSessionID(writer.id) ); let teamContentAsWriter = expectTeamContent( teamAsWriter.getCurrentContent() ); - expect(teamContentAsWriter.get(writerID)).toEqual("writer"); + expect(teamContentAsWriter.get(writer.id)).toEqual("writer"); - const otherAgent = node.createAgent("otherAgent"); - const otherAgentID = getAgentID(getAgent(otherAgent)); + const otherAgent = node.createAccount("otherAgent"); teamContentAsWriter.edit((editable) => { - editable.set(otherAgentID, "admin", "trusting"); - expect(editable.get(otherAgentID)).toBeUndefined(); + editable.set(otherAgent.id, "admin", "trusting"); + expect(editable.get(otherAgent.id)).toBeUndefined(); - editable.set(otherAgentID, "writer", "trusting"); - expect(editable.get(otherAgentID)).toBeUndefined(); + editable.set(otherAgent.id, "writer", "trusting"); + expect(editable.get(otherAgent.id)).toBeUndefined(); - editable.set(otherAgentID, "reader", "trusting"); - expect(editable.get(otherAgentID)).toBeUndefined(); + editable.set(otherAgent.id, "reader", "trusting"); + expect(editable.get(otherAgent.id)).toBeUndefined(); }); teamContentAsWriter = expectTeamContent(teamAsWriter.getCurrentContent()); - expect(teamContentAsWriter.get(otherAgentID)).toBeUndefined(); + expect(teamContentAsWriter.get(otherAgent.id)).toBeUndefined(); }); test("Admins an add writers to a team, who can't add admins, writers, or readers (high level)", () => { const { team, node } = newTeamHighLevel(); - const writer = node.createAgent("writer"); - const writerID = getAgentID(getAgent(writer)); + const writer = node.createAccount("writer"); - team.addMember(writerID, "writer"); - expect(team.teamMap.get(writerID)).toEqual("writer"); + team.addMember(writer.id, "writer"); + expect(team.teamMap.get(writer.id)).toEqual("writer"); - const teamAsWriter = team.testWithDifferentCredentials( + const teamAsWriter = team.testWithDifferentAccount( writer, - newRandomSessionID(writerID) + newRandomSessionID(writer.id) ); - expect(teamAsWriter.teamMap.get(writerID)).toEqual("writer"); + expect(teamAsWriter.teamMap.get(writer.id)).toEqual("writer"); - const otherAgent = node.createAgent("otherAgent"); - const otherAgentID = getAgentID(getAgent(otherAgent)); + const otherAgent = node.createAccount("otherAgent"); - expect(() => teamAsWriter.addMember(otherAgentID, "admin")).toThrow( + expect(() => teamAsWriter.addMember(otherAgent.id, "admin")).toThrow( "Failed to set role" ); - expect(() => teamAsWriter.addMember(otherAgentID, "writer")).toThrow( + expect(() => teamAsWriter.addMember(otherAgent.id, "writer")).toThrow( "Failed to set role" ); - expect(() => teamAsWriter.addMember(otherAgentID, "reader")).toThrow( + expect(() => teamAsWriter.addMember(otherAgent.id, "reader")).toThrow( "Failed to set role" ); - expect(teamAsWriter.teamMap.get(otherAgentID)).toBeUndefined(); + expect(teamAsWriter.teamMap.get(otherAgent.id)).toBeUndefined(); }); test("Admins can add readers to a team, who can't add admins, writers, or readers", () => { const { team, node } = newTeam(); - const reader = node.createAgent("reader"); - const readerID = getAgentID(getAgent(reader)); + const reader = node.createAccount("reader"); let teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { - editable.set(readerID, "reader", "trusting"); - expect(editable.get(readerID)).toEqual("reader"); + editable.set(reader.id, "reader", "trusting"); + expect(editable.get(reader.id)).toEqual("reader"); }); teamContent = expectTeamContent(team.getCurrentContent()); - expect(teamContent.get(readerID)).toEqual("reader"); + expect(teamContent.get(reader.id)).toEqual("reader"); - const teamAsReader = team.testWithDifferentCredentials( + const teamAsReader = team.testWithDifferentAccount( reader, - newRandomSessionID(readerID) + newRandomSessionID(reader.id) ); let teamContentAsReader = expectTeamContent( teamAsReader.getCurrentContent() ); - expect(teamContentAsReader.get(readerID)).toEqual("reader"); + expect(teamContentAsReader.get(reader.id)).toEqual("reader"); - const otherAgent = node.createAgent("otherAgent"); - const otherAgentID = getAgentID(getAgent(otherAgent)); + const otherAgent = node.createAccount("otherAgent"); teamContentAsReader.edit((editable) => { - editable.set(otherAgentID, "admin", "trusting"); - expect(editable.get(otherAgentID)).toBeUndefined(); + editable.set(otherAgent.id, "admin", "trusting"); + expect(editable.get(otherAgent.id)).toBeUndefined(); - editable.set(otherAgentID, "writer", "trusting"); - expect(editable.get(otherAgentID)).toBeUndefined(); + editable.set(otherAgent.id, "writer", "trusting"); + expect(editable.get(otherAgent.id)).toBeUndefined(); - editable.set(otherAgentID, "reader", "trusting"); - expect(editable.get(otherAgentID)).toBeUndefined(); + editable.set(otherAgent.id, "reader", "trusting"); + expect(editable.get(otherAgent.id)).toBeUndefined(); }); teamContentAsReader = expectTeamContent(teamAsReader.getCurrentContent()); - expect(teamContentAsReader.get(otherAgentID)).toBeUndefined(); + expect(teamContentAsReader.get(otherAgent.id)).toBeUndefined(); }); test("Admins can add readers to a team, who can't add admins, writers, or readers (high level)", () => { const { team, node } = newTeamHighLevel(); - const reader = node.createAgent("reader"); - const readerID = getAgentID(getAgent(reader)); + const reader = node.createAccount("reader"); - team.addMember(readerID, "reader"); - expect(team.teamMap.get(readerID)).toEqual("reader"); + team.addMember(reader.id, "reader"); + expect(team.teamMap.get(reader.id)).toEqual("reader"); - const teamAsReader = team.testWithDifferentCredentials( + const teamAsReader = team.testWithDifferentAccount( reader, - newRandomSessionID(readerID) + newRandomSessionID(reader.id) ); - expect(teamAsReader.teamMap.get(readerID)).toEqual("reader"); + expect(teamAsReader.teamMap.get(reader.id)).toEqual("reader"); - const otherAgent = node.createAgent("otherAgent"); - const otherAgentID = getAgentID(getAgent(otherAgent)); + const otherAgent = node.createAccount("otherAgent"); - expect(() => teamAsReader.addMember(otherAgentID, "admin")).toThrow( + expect(() => teamAsReader.addMember(otherAgent.id, "admin")).toThrow( "Failed to set role" ); - expect(() => teamAsReader.addMember(otherAgentID, "writer")).toThrow( + expect(() => teamAsReader.addMember(otherAgent.id, "writer")).toThrow( "Failed to set role" ); - expect(() => teamAsReader.addMember(otherAgentID, "reader")).toThrow( + expect(() => teamAsReader.addMember(otherAgent.id, "reader")).toThrow( "Failed to set role" ); - expect(teamAsReader.teamMap.get(otherAgentID)).toBeUndefined(); + expect(teamAsReader.teamMap.get(otherAgent.id)).toBeUndefined(); }); test("Admins can write to an object that is owned by their team", () => { @@ -377,12 +297,11 @@ test("Admins can write to an object that is owned by their team (high level)", ( test("Writers can write to an object that is owned by their team", () => { const { node, team } = newTeam(); - const writer = node.createAgent("writer"); - const writerID = getAgentID(getAgent(writer)); + const writer = node.createAccount("writer"); expectTeamContent(team.getCurrentContent()).edit((editable) => { - editable.set(writerID, "writer", "trusting"); - expect(editable.get(writerID)).toEqual("writer"); + editable.set(writer.id, "writer", "trusting"); + expect(editable.get(writer.id)).toEqual("writer"); }); const childObject = node.createCoValue({ @@ -393,9 +312,9 @@ test("Writers can write to an object that is owned by their team", () => { publicNickname: "childObject", }); - const childObjectAsWriter = childObject.testWithDifferentCredentials( + const childObjectAsWriter = childObject.testWithDifferentAccount( writer, - newRandomSessionID(writerID) + newRandomSessionID(writer.id) ); let childContentAsWriter = expectMap( @@ -415,16 +334,15 @@ test("Writers can write to an object that is owned by their team", () => { test("Writers can write to an object that is owned by their team (high level)", () => { const { node, team } = newTeamHighLevel(); - const writer = node.createAgent("writer"); - const writerID = getAgentID(getAgent(writer)); + const writer = node.createAccount("writer"); - team.addMember(writerID, "writer"); + team.addMember(writer.id, "writer"); const childObject = team.createMap(); let childObjectAsWriter = expectMap( childObject.coValue - .testWithDifferentCredentials(writer, newRandomSessionID(writerID)) + .testWithDifferentAccount(writer, newRandomSessionID(writer.id)) .getCurrentContent() ); @@ -439,12 +357,11 @@ test("Writers can write to an object that is owned by their team (high level)", test("Readers can not write to an object that is owned by their team", () => { const { node, team } = newTeam(); - const reader = node.createAgent("reader"); - const readerID = getAgentID(getAgent(reader)); + const reader = node.createAccount("reader"); expectTeamContent(team.getCurrentContent()).edit((editable) => { - editable.set(readerID, "reader", "trusting"); - expect(editable.get(readerID)).toEqual("reader"); + editable.set(reader.id, "reader", "trusting"); + expect(editable.get(reader.id)).toEqual("reader"); }); const childObject = node.createCoValue({ @@ -455,9 +372,9 @@ test("Readers can not write to an object that is owned by their team", () => { publicNickname: "childObject", }); - const childObjectAsReader = childObject.testWithDifferentCredentials( + const childObjectAsReader = childObject.testWithDifferentAccount( reader, - newRandomSessionID(readerID) + newRandomSessionID(reader.id) ); let childContentAsReader = expectMap( @@ -477,16 +394,15 @@ test("Readers can not write to an object that is owned by their team", () => { test("Readers can not write to an object that is owned by their team (high level)", () => { const { node, team } = newTeamHighLevel(); - const reader = node.createAgent("reader"); - const readerID = getAgentID(getAgent(reader)); + const reader = node.createAccount("reader"); - team.addMember(readerID, "reader"); + team.addMember(reader.id, "reader"); const childObject = team.createMap(); let childObjectAsReader = expectMap( childObject.coValue - .testWithDifferentCredentials(reader, newRandomSessionID(readerID)) + .testWithDifferentAccount(reader, newRandomSessionID(reader.id)) .getCurrentContent() ); @@ -499,7 +415,7 @@ test("Readers can not write to an object that is owned by their team (high level }); test("Admins can set team read key and then use it to create and read private transactions in owned objects", () => { - const { node, team, admin, adminID } = newTeam(); + const { node, team, admin } = newTeam(); const teamContent = expectTeamContent(team.getCurrentContent()); @@ -507,8 +423,8 @@ test("Admins can set team read key and then use it to create and read private tr const { secret: readKey, id: readKeyID } = newRandomKeySecret(); const revelation = seal( readKey, - admin.recipientSecret, - new Set([getRecipientID(admin.recipientSecret)]), + admin.currentRecipientSecret(), + new Set([admin.currentRecipientID()]), { in: team.id, tx: team.nextTransactionID(), @@ -557,23 +473,20 @@ test("Admins can set team read key and then use it to create and read private tr test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects", () => { const { node, team, admin } = newTeam(); - const writer = node.createAgent("writer"); - const writerID = getAgentID(getAgent(writer)); + const writer = node.createAccount("writer"); + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { - editable.set(writerID, "writer", "trusting"); - expect(editable.get(writerID)).toEqual("writer"); + editable.set(writer.id, "writer", "trusting"); + expect(editable.get(writer.id)).toEqual("writer"); const revelation = seal( readKey, - admin.recipientSecret, - new Set([ - getRecipientID(admin.recipientSecret), - getRecipientID(writer.recipientSecret), - ]), + admin.currentRecipientSecret(), + new Set([admin.currentRecipientID(), writer.currentRecipientID()]), { in: team.id, tx: team.nextTransactionID(), @@ -590,9 +503,9 @@ test("Admins can set team read key and then writers can use it to create and rea publicNickname: "childObject", }); - const childObjectAsWriter = childObject.testWithDifferentCredentials( + const childObjectAsWriter = childObject.testWithDifferentAccount( writer, - newRandomSessionID(writerID) + newRandomSessionID(writer.id) ); expect(childObject.getCurrentReadKey().secret).toEqual(readKey); @@ -614,16 +527,15 @@ test("Admins can set team read key and then writers can use it to create and rea test("Admins can set team read key and then writers can use it to create and read private transactions in owned objects (high level)", () => { const { node, team, admin } = newTeamHighLevel(); - const writer = node.createAgent("writer"); - const writerID = getAgentID(getAgent(writer)); + const writer = node.createAccount("writer"); - team.addMember(writerID, "writer"); + team.addMember(writer.id, "writer"); const childObject = team.createMap(); let childObjectAsWriter = expectMap( childObject.coValue - .testWithDifferentCredentials(writer, newRandomSessionID(writerID)) + .testWithDifferentAccount(writer, newRandomSessionID(writer.id)) .getCurrentContent() ); @@ -638,23 +550,20 @@ test("Admins can set team read key and then writers can use it to create and rea test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read", () => { const { node, team, admin } = newTeam(); - const reader = node.createAgent("reader"); - const readerID = getAgentID(getAgent(reader)); + const reader = node.createAccount("reader"); + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { - editable.set(readerID, "reader", "trusting"); - expect(editable.get(readerID)).toEqual("reader"); + editable.set(reader.id, "reader", "trusting"); + expect(editable.get(reader.id)).toEqual("reader"); const revelation = seal( readKey, - admin.recipientSecret, - new Set([ - getRecipientID(admin.recipientSecret), - getRecipientID(reader.recipientSecret), - ]), + admin.currentRecipientSecret(), + new Set([admin.currentRecipientID(), reader.currentRecipientID()]), { in: team.id, tx: team.nextTransactionID(), @@ -676,9 +585,9 @@ test("Admins can set team read key and then use it to create private transaction expect(editable.get("foo")).toEqual("bar"); }); - const childObjectAsReader = childObject.testWithDifferentCredentials( + const childObjectAsReader = childObject.testWithDifferentAccount( reader, - newRandomSessionID(readerID) + newRandomSessionID(reader.id) ); expect(childObjectAsReader.getCurrentReadKey().secret).toEqual(readKey); @@ -693,10 +602,9 @@ test("Admins can set team read key and then use it to create private transaction test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read (high level)", () => { const { node, team, admin } = newTeamHighLevel(); - const reader = node.createAgent("reader"); - const readerID = getAgentID(getAgent(reader)); + const reader = node.createAccount("reader"); - team.addMember(readerID, "reader"); + team.addMember(reader.id, "reader"); let childObject = team.createMap(); @@ -707,7 +615,7 @@ test("Admins can set team read key and then use it to create private transaction const childContentAsReader = expectMap( childObject.coValue - .testWithDifferentCredentials(reader, newRandomSessionID(readerID)) + .testWithDifferentAccount(reader, newRandomSessionID(reader.id)) .getCurrentContent() ); @@ -717,25 +625,22 @@ test("Admins can set team read key and then use it to create private transaction test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key", () => { const { node, team, admin } = newTeam(); - const reader1 = node.createAgent("reader1"); - const reader1ID = getAgentID(getAgent(reader1)); - const reader2 = node.createAgent("reader2"); - const reader2ID = getAgentID(getAgent(reader2)); + const reader1 = node.createAccount("reader1"); + + const reader2 = node.createAccount("reader2"); + const { secret: readKey, id: readKeyID } = newRandomKeySecret(); const teamContent = expectTeamContent(team.getCurrentContent()); teamContent.edit((editable) => { - editable.set(reader1ID, "reader", "trusting"); - expect(editable.get(reader1ID)).toEqual("reader"); + editable.set(reader1.id, "reader", "trusting"); + expect(editable.get(reader1.id)).toEqual("reader"); const revelation1 = seal( readKey, - admin.recipientSecret, - new Set([ - getRecipientID(admin.recipientSecret), - getRecipientID(reader1.recipientSecret), - ]), + admin.currentRecipientSecret(), + new Set([admin.currentRecipientID(), reader1.currentRecipientID()]), { in: team.id, tx: team.nextTransactionID(), @@ -749,8 +654,8 @@ test("Admins can set team read key and then use it to create private transaction const revelation2 = seal( readKey, - admin.recipientSecret, - new Set([getRecipientID(reader2.recipientSecret)]), + admin.currentRecipientSecret(), + new Set([reader2.currentRecipientID()]), { in: team.id, tx: team.nextTransactionID(), @@ -776,9 +681,9 @@ test("Admins can set team read key and then use it to create private transaction expect(editable.get("foo")).toEqual("bar"); }); - const childObjectAsReader1 = childObject.testWithDifferentCredentials( + const childObjectAsReader1 = childObject.testWithDifferentAccount( reader1, - newRandomSessionID(reader1ID) + newRandomSessionID(reader1.id) ); expect(childObjectAsReader1.getCurrentReadKey().secret).toEqual(readKey); @@ -789,9 +694,9 @@ test("Admins can set team read key and then use it to create private transaction expect(childContentAsReader1.get("foo")).toEqual("bar"); - const childObjectAsReader2 = childObject.testWithDifferentCredentials( + const childObjectAsReader2 = childObject.testWithDifferentAccount( reader2, - newRandomSessionID(reader2ID) + newRandomSessionID(reader2.id) ); expect(childObjectAsReader2.getCurrentReadKey().secret).toEqual(readKey); @@ -806,12 +711,11 @@ test("Admins can set team read key and then use it to create private transaction test("Admins can set team read key and then use it to create private transactions in owned objects, which readers can read, even with a separate later revelation for the same read key (high level)", () => { const { node, team, admin } = newTeamHighLevel(); - const reader1 = node.createAgent("reader1"); - const reader1ID = getAgentID(getAgent(reader1)); - const reader2 = node.createAgent("reader2"); - const reader2ID = getAgentID(getAgent(reader2)); + const reader1 = node.createAccount("reader1"); - team.addMember(reader1ID, "reader"); + const reader2 = node.createAccount("reader2"); + + team.addMember(reader1.id, "reader"); let childObject = team.createMap(); @@ -822,23 +726,17 @@ test("Admins can set team read key and then use it to create private transaction const childContentAsReader1 = expectMap( childObject.coValue - .testWithDifferentCredentials( - reader1, - newRandomSessionID(reader1ID) - ) + .testWithDifferentAccount(reader1, newRandomSessionID(reader1.id)) .getCurrentContent() ); expect(childContentAsReader1.get("foo")).toEqual("bar"); - team.addMember(reader2ID, "reader"); + team.addMember(reader2.id, "reader"); const childContentAsReader2 = expectMap( childObject.coValue - .testWithDifferentCredentials( - reader2, - newRandomSessionID(reader2ID) - ) + .testWithDifferentAccount(reader2, newRandomSessionID(reader2.id)) .getCurrentContent() ); @@ -846,7 +744,7 @@ test("Admins can set team read key and then use it to create private transaction }); test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, make another private transaction, and both can be read by the admin", () => { - const { node, team, admin, adminID } = newTeam(); + const { node, team, admin } = newTeam(); const teamContent = expectTeamContent(team.getCurrentContent()); @@ -854,8 +752,8 @@ test("Admins can set team read key, make a private transaction in an owned objec const { secret: readKey, id: readKeyID } = newRandomKeySecret(); const revelation = seal( readKey, - admin.recipientSecret, - new Set([getRecipientID(admin.recipientSecret)]), + admin.currentRecipientSecret(), + new Set([admin.currentRecipientID()]), { in: team.id, tx: team.nextTransactionID(), @@ -892,8 +790,8 @@ test("Admins can set team read key, make a private transaction in an owned objec const revelation = seal( readKey2, - admin.recipientSecret, - new Set([getRecipientID(admin.recipientSecret)]), + admin.currentRecipientSecret(), + new Set([admin.currentRecipientID()]), { in: team.id, tx: team.nextTransactionID(), @@ -921,7 +819,7 @@ test("Admins can set team read key, make a private transaction in an owned objec }); test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, make another private transaction, and both can be read by the admin (high level)", () => { - const { node, team, admin, adminID } = newTeamHighLevel(); + const { team } = newTeamHighLevel(); let childObject = team.createMap(); @@ -948,7 +846,7 @@ test("Admins can set team read key, make a private transaction in an owned objec }); test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, add a reader, make another private transaction in the owned object, and both can be read by the reader", () => { - const { node, team, admin, adminID } = newTeam(); + const { node, team, admin } = newTeam(); const childObject = node.createCoValue({ type: "comap", @@ -964,8 +862,8 @@ test("Admins can set team read key, make a private transaction in an owned objec teamContent.edit((editable) => { const revelation = seal( readKey, - admin.recipientSecret, - new Set([getRecipientID(admin.recipientSecret)]), + admin.currentRecipientSecret(), + new Set([admin.currentRecipientID()]), { in: team.id, tx: team.nextTransactionID(), @@ -989,18 +887,15 @@ test("Admins can set team read key, make a private transaction in an owned objec childContent = expectMap(childObject.getCurrentContent()); expect(childContent.get("foo")).toEqual("bar"); - const reader = node.createAgent("reader"); - const readerID = getAgentID(getAgent(reader)); + const reader = node.createAccount("reader"); + const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret(); teamContent.edit((editable) => { const revelation = seal( readKey2, - admin.recipientSecret, - new Set([ - getRecipientID(admin.recipientSecret), - getRecipientID(reader.recipientSecret), - ]), + admin.currentRecipientSecret(), + new Set([admin.currentRecipientID(), reader.currentRecipientID()]), { in: team.id, tx: team.nextTransactionID(), @@ -1027,8 +922,8 @@ test("Admins can set team read key, make a private transaction in an owned objec }); expect(team.getCurrentReadKey().secret).toEqual(readKey2); - editable.set(readerID, "reader", "trusting"); - expect(editable.get(readerID)).toEqual("reader"); + editable.set(reader.id, "reader", "trusting"); + expect(editable.get(reader.id)).toEqual("reader"); }); childContent.edit((editable) => { @@ -1036,9 +931,9 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(editable.get("foo2")).toEqual("bar2"); }); - const childObjectAsReader = childObject.testWithDifferentCredentials( + const childObjectAsReader = childObject.testWithDifferentAccount( reader, - newRandomSessionID(readerID) + newRandomSessionID(reader.id) ); expect(childObjectAsReader.getCurrentReadKey().secret).toEqual(readKey2); @@ -1052,7 +947,7 @@ test("Admins can set team read key, make a private transaction in an owned objec }); test("Admins can set team read key, make a private transaction in an owned object, rotate the read key, add a reader, make another private transaction in the owned object, and both can be read by the reader (high level)", () => { - const { node, team, admin, adminID } = newTeamHighLevel(); + const { node, team } = newTeamHighLevel(); let childObject = team.createMap(); @@ -1069,10 +964,9 @@ test("Admins can set team read key, make a private transaction in an owned objec expect(childObject.coValue.getCurrentReadKey()).not.toEqual(firstReadKey); - const reader = node.createAgent("reader"); - const readerID = getAgentID(getAgent(reader)); + const reader = node.createAccount("reader"); - team.addMember(readerID, "reader"); + team.addMember(reader.id, "reader"); childObject = childObject.edit((editable) => { editable.set("foo2", "bar2", "private"); @@ -1081,7 +975,7 @@ test("Admins can set team read key, make a private transaction in an owned objec const childContentAsReader = expectMap( childObject.coValue - .testWithDifferentCredentials(reader, newRandomSessionID(readerID)) + .testWithDifferentAccount(reader, newRandomSessionID(reader.id)) .getCurrentContent() ); @@ -1090,7 +984,7 @@ test("Admins can set team read key, make a private transaction in an owned objec }); test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions", () => { - const { node, team, admin, adminID } = newTeam(); + const { node, team, admin } = newTeam(); const childObject = node.createCoValue({ type: "comap", @@ -1102,19 +996,18 @@ test("Admins can set team read rey, make a private transaction in an owned objec const teamContent = expectTeamContent(team.getCurrentContent()); const { secret: readKey, id: readKeyID } = newRandomKeySecret(); - const reader = node.createAgent("reader"); - const readerID = getAgentID(getAgent(reader)); - const reader2 = node.createAgent("reader2"); - const reader2ID = getAgentID(getAgent(reader)); + const reader = node.createAccount("reader"); + + const reader2 = node.createAccount("reader2"); teamContent.edit((editable) => { const revelation = seal( readKey, - admin.recipientSecret, + admin.currentRecipientSecret(), new Set([ - getRecipientID(admin.recipientSecret), - getRecipientID(reader.recipientSecret), - getRecipientID(reader2.recipientSecret), + admin.currentRecipientID(), + reader.currentRecipientID(), + reader2.currentRecipientID(), ]), { in: team.id, @@ -1128,10 +1021,10 @@ test("Admins can set team read rey, make a private transaction in an owned objec }); expect(team.getCurrentReadKey().secret).toEqual(readKey); - editable.set(readerID, "reader", "trusting"); - expect(editable.get(readerID)).toEqual("reader"); - editable.set(reader2ID, "reader", "trusting"); - expect(editable.get(reader2ID)).toEqual("reader"); + editable.set(reader.id, "reader", "trusting"); + expect(editable.get(reader.id)).toEqual("reader"); + editable.set(reader2.id, "reader", "trusting"); + expect(editable.get(reader2.id)).toEqual("reader"); }); let childContent = expectMap(childObject.getCurrentContent()); @@ -1144,18 +1037,18 @@ test("Admins can set team read rey, make a private transaction in an owned objec childContent = expectMap(childObject.getCurrentContent()); expect(childContent.get("foo")).toEqual("bar"); - let childObjectAsReader = childObject.testWithDifferentCredentials( + let childObjectAsReader = childObject.testWithDifferentAccount( reader, - newRandomSessionID(readerID) + newRandomSessionID(reader.id) ); expect( expectMap(childObjectAsReader.getCurrentContent()).get("foo") ).toEqual("bar"); - let childObjectAsReader2 = childObject.testWithDifferentCredentials( + let childObjectAsReader2 = childObject.testWithDifferentAccount( reader, - newRandomSessionID(readerID) + newRandomSessionID(reader.id) ); expect( @@ -1167,11 +1060,8 @@ test("Admins can set team read rey, make a private transaction in an owned objec teamContent.edit((editable) => { const revelation = seal( readKey2, - admin.recipientSecret, - new Set([ - getRecipientID(admin.recipientSecret), - getRecipientID(reader2.recipientSecret), - ]), + admin.currentRecipientSecret(), + new Set([admin.currentRecipientID(), reader2.currentRecipientID()]), { in: team.id, tx: team.nextTransactionID(), @@ -1184,8 +1074,8 @@ test("Admins can set team read rey, make a private transaction in an owned objec }); expect(team.getCurrentReadKey().secret).toEqual(readKey2); - editable.set(readerID, "revoked", "trusting"); - // expect(editable.get(readerID)).toEqual("revoked"); + editable.set(reader.id, "revoked", "trusting"); + // expect(editable.get(reader.id)).toEqual("revoked"); }); expect(childObject.getCurrentReadKey().secret).toEqual(readKey2); @@ -1197,13 +1087,13 @@ test("Admins can set team read rey, make a private transaction in an owned objec }); // TODO: make sure these instances of coValues sync between each other so this isn't necessary? - childObjectAsReader = childObject.testWithDifferentCredentials( + childObjectAsReader = childObject.testWithDifferentAccount( reader, - newRandomSessionID(readerID) + newRandomSessionID(reader.id) ); - childObjectAsReader2 = childObject.testWithDifferentCredentials( + childObjectAsReader2 = childObject.testWithDifferentAccount( reader2, - newRandomSessionID(reader2ID) + newRandomSessionID(reader2.id) ); expect( @@ -1215,7 +1105,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec }); test("Admins can set team read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions (high level)", () => { - const { node, team, admin, adminID } = newTeamHighLevel(); + const { node, team } = newTeamHighLevel(); let childObject = team.createMap(); @@ -1230,13 +1120,12 @@ test("Admins can set team read rey, make a private transaction in an owned objec const secondReadKey = childObject.coValue.getCurrentReadKey(); - const reader = node.createAgent("reader"); - const readerID = getAgentID(getAgent(reader)); - const reader2 = node.createAgent("reader2"); - const reader2ID = getAgentID(getAgent(reader2)); + const reader = node.createAccount("reader"); - team.addMember(readerID, "reader"); - team.addMember(reader2ID, "reader"); + const reader2 = node.createAccount("reader2"); + + team.addMember(reader.id, "reader"); + team.addMember(reader2.id, "reader"); childObject = childObject.edit((editable) => { editable.set("foo2", "bar2", "private"); @@ -1246,7 +1135,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec expect(childObject.get("foo")).toEqual("bar"); expect(childObject.get("foo2")).toEqual("bar2"); - team.removeMember(readerID); + team.removeMember(reader.id); expect(childObject.coValue.getCurrentReadKey()).not.toEqual(secondReadKey); @@ -1257,10 +1146,7 @@ test("Admins can set team read rey, make a private transaction in an owned objec const childContentAsReader2 = expectMap( childObject.coValue - .testWithDifferentCredentials( - reader2, - newRandomSessionID(reader2ID) - ) + .testWithDifferentAccount(reader2, newRandomSessionID(reader2.id)) .getCurrentContent() ); @@ -1271,31 +1157,28 @@ test("Admins can set team read rey, make a private transaction in an owned objec expect( expectMap( childObject.coValue - .testWithDifferentCredentials( - reader, - newRandomSessionID(readerID) - ) + .testWithDifferentAccount(reader, newRandomSessionID(reader.id)) .getCurrentContent() ).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 { node, team } = newTeam(); const childObject1 = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - ...createdNowUnique() + ...createdNowUnique(), }); const childObject2 = node.createCoValue({ type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, meta: null, - ...createdNowUnique() + ...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 d1af46acc..a399d7c54 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -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, isRawAgentID } 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 + | MapOpPayload | 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; @@ -230,20 +221,20 @@ export class Team { this.node = node; } - get id(): RawCoValueID { + get id(): CoValueID> { 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_") || isRawAgentID(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; } - 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 diff --git a/src/sync.test.ts b/src/sync.test.ts index 1a2e65b7d..874fd4bdd 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -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(); + const [outRx, outTx] = newStreamPair(); - const [inRx, inTx] = newStreamPair(); - const [outRx, outTx] = newStreamPair(); + 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, + ], + }, + ], + 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, - ], - }, - ], - 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(); const [outRx2, outTx2] = newStreamPair(); @@ -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(): [ReadableStream, WritableStream] { 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(): [ReadableStream, WritableStream] { } }, abort(reason) { - console.log("Manually closing writer") + console.log("Manually closing writer"); writerClosed = true; resolveNextItemReady(); return Promise.resolve(); @@ -1220,7 +1189,10 @@ function newStreamPair(): [ReadableStream, WritableStream] { return [readable, writable]; } -function shouldNotResolve(promise: Promise, ops: { timeout: number }): Promise { +function shouldNotResolve( + promise: Promise, + ops: { timeout: number } +): Promise { return new Promise((resolve, reject) => { promise .then((v) => diff --git a/src/testUtils.ts b/src/testUtils.ts new file mode 100644 index 000000000..ce6a8a860 --- /dev/null +++ b/src/testUtils.ts @@ -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 }; +} \ No newline at end of file From 60add421e6234ee414feaffe83878268498e007a Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 14 Aug 2023 15:13:39 +0100 Subject: [PATCH 37/47] Rename RawAgentID to AgentID --- src/account.ts | 22 +++++++++++----------- src/crypto.ts | 8 ++++---- src/ids.ts | 4 ++-- src/node.ts | 6 +++--- src/permissions.ts | 4 ++-- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/account.ts b/src/account.ts index 551568ef2..a12e50468 100644 --- a/src/account.ts +++ b/src/account.ts @@ -1,15 +1,15 @@ 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 { RawAgentID } from './ids.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 rawAgentID = getAgentID(agentSecret); + const agent = getAgentID(agentSecret); return { type: "comap", - ruleset: {type: "team", initialAdmin: rawAgentID}, + ruleset: {type: "team", initialAdmin: agent}, meta: { type: "account" }, @@ -23,8 +23,8 @@ export class Account extends Team { return this.teamMap.id; } - getCurrentAgentID(): RawAgentID { - const agents = this.teamMap.keys().filter((k): k is RawAgentID => k.startsWith("recipient_")); + 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); @@ -38,7 +38,7 @@ export interface GeneralizedControlledAccount { id: AccountIDOrAgentID; agentSecret: AgentSecret; - currentAgentID: () => RawAgentID; + currentAgentID: () => AgentID; currentSignatoryID: () => SignatoryID; currentSignatorySecret: () => SignatorySecret; currentRecipientID: () => RecipientID; @@ -54,7 +54,7 @@ export class ControlledAccount extends Account implements GeneralizedControlledA this.agentSecret = agentSecret; } - currentAgentID(): RawAgentID { + currentAgentID(): AgentID { return getAgentID(this.agentSecret); } @@ -82,11 +82,11 @@ export class AnonymousControlledAccount implements GeneralizedControlledAccount this.agentSecret = agentSecret; } - get id(): RawAgentID { + get id(): AgentID { return getAgentID(this.agentSecret); } - currentAgentID(): RawAgentID { + currentAgentID(): AgentID { return getAgentID(this.agentSecret); } @@ -110,6 +110,6 @@ export class AnonymousControlledAccount implements GeneralizedControlledAccount export type AccountMeta = {type: "account"}; export type AccountID = CoValueID>; -export type AccountIDOrAgentID = RawAgentID | AccountID; -export type AccountOrAgentID = RawAgentID | Account; +export type AccountIDOrAgentID = AgentID | AccountID; +export type AccountOrAgentID = AgentID | Account; export type AccountOrAgentSecret = AgentSecret | Account; diff --git a/src/crypto.ts b/src/crypto.ts index 9553207d8..97624a0ac 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -5,7 +5,7 @@ import { base58, base64url } from "@scure/base"; import stableStringify from "fast-json-stable-stringify"; import { blake3 } from "@noble/hashes/blake3"; import { randomBytes } from "@noble/ciphers/webcrypto/utils"; -import { RawAgentID, RawCoValueID, TransactionID } from './ids.js'; +import { AgentID, RawCoValueID, TransactionID } from './ids.js'; export type SignatorySecret = `signatorySecret_z${string}`; export type SignatoryID = `signatory_z${string}`; @@ -104,14 +104,14 @@ export function agentSecretFromBytes(bytes: Uint8Array): AgentSecret { return `${recipientSecret}/${signatorySecret}`; } -export function getAgentID(secret: AgentSecret): RawAgentID { +export function getAgentID(secret: AgentSecret): AgentID { const [recipientSecret, signatorySecret] = secret.split("/"); return `${getRecipientID( recipientSecret as RecipientSecret )}/${getSignatoryID(signatorySecret as SignatorySecret)}`; } -export function getAgentSignatoryID(agentId: RawAgentID): SignatoryID { +export function getAgentSignatoryID(agentId: AgentID): SignatoryID { return agentId.split("/")[1] as SignatoryID; } @@ -119,7 +119,7 @@ export function getAgentSignatorySecret(agentSecret: AgentSecret): SignatorySecr return agentSecret.split("/")[1] as SignatorySecret; } -export function getAgentRecipientID(agentId: RawAgentID): RecipientID { +export function getAgentRecipientID(agentId: AgentID): RecipientID { return agentId.split("/")[0] as RecipientID; } diff --git a/src/ids.ts b/src/ids.ts index 92c7d39ca..39f11d26b 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -4,9 +4,9 @@ export type RawCoValueID = `co_z${string}` | `co_${string}_z${string}`; export type TransactionID = { sessionID: SessionID; txIndex: number }; -export type RawAgentID = `recipient_z${string}/signatory_z${string}`; +export type AgentID = `recipient_z${string}/signatory_z${string}`; -export function isRawAgentID(id: string): id is RawAgentID { +export function isAgentID(id: string): id is AgentID { return typeof id === "string" && id.startsWith("recipient_") && id.includes("/signatory_"); } diff --git a/src/node.ts b/src/node.ts index b85992e75..2d4161574 100644 --- a/src/node.ts +++ b/src/node.ts @@ -10,7 +10,7 @@ import { import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js"; import { Team, TeamContent, expectTeamContent } from "./permissions.js"; import { SyncManager } from "./sync.js"; -import { RawAgentID, RawCoValueID, SessionID, isRawAgentID } from "./ids.js"; +import { AgentID, RawCoValueID, SessionID, isAgentID } from "./ids.js"; import { CoValueID, ContentType } from "./contentType.js"; import { Account, @@ -117,8 +117,8 @@ export class LocalNode { ); } - resolveAccount(id: AccountIDOrAgentID, expectation?: string): RawAgentID { - if (isRawAgentID(id)) { + resolveAccount(id: AccountIDOrAgentID, expectation?: string): AgentID { + if (isAgentID(id)) { return id; } diff --git a/src/permissions.ts b/src/permissions.ts index a399d7c54..b1d71f4b3 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -19,7 +19,7 @@ import { accountOrAgentIDfromSessionID, } from './coValue.js'; import { LocalNode } from "./node.js"; -import { RawCoValueID, SessionID, TransactionID, isRawAgentID } from './ids.js'; +import { RawCoValueID, SessionID, TransactionID, isAgentID } from './ids.js'; import { AccountIDOrAgentID, GeneralizedControlledAccount } from './account.js'; export type PermissionsDef = @@ -264,7 +264,7 @@ export class Team { rotateReadKey() { const currentlyPermittedReaders = this.teamMap.keys().filter((key) => { - if (key.startsWith("co_") || isRawAgentID(key)) { + if (key.startsWith("co_") || isAgentID(key)) { const role = this.teamMap.get(key); return ( role === "admin" || role === "writer" || role === "reader" From c406f8ba2cdc18a66fb6556f381100bc8b700e0a Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 14 Aug 2023 17:34:00 +0100 Subject: [PATCH 38/47] Implement simpler team logic without history --- src/coValue.ts | 79 ++++++------ src/contentTypes/coMap.ts | 15 +++ src/crypto.test.ts | 62 +++++----- src/crypto.ts | 91 ++++++-------- src/node.ts | 53 ++++---- src/permissions.test.ts | 249 ++++++++++++++++++++++++++------------ src/permissions.ts | 179 ++++++++++++++------------- src/testUtils.ts | 3 +- 8 files changed, 418 insertions(+), 313 deletions(-) diff --git a/src/coValue.ts b/src/coValue.ts index e1967e9ac..8d2e24db9 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -9,14 +9,14 @@ import { KeySecret, Signature, StreamingHash, - openAs, + unseal, shortHash, sign, verify, encryptForTransaction, decryptForTransaction, KeyID, - unsealKeySecret, + decryptKeySecret, getAgentSignatoryID, getAgentRecipientID, } from "./crypto.js"; @@ -27,6 +27,7 @@ import { Team, determineValidTransactions, expectTeamContent, + isKeyForKeyField, } from "./permissions.js"; import { LocalNode } from "./node.js"; import { CoValueKnownState, NewContentMessage } from "./sync.js"; @@ -158,7 +159,7 @@ export class CoValue { newSignature: Signature ): boolean { const signatoryID = getAgentSignatoryID( - this.node.resolveAccount( + this.node.resolveAccountAgent( accountOrAgentIDfromSessionID(sessionID), "Expected to know signatory of transaction" ) @@ -376,7 +377,7 @@ export class CoValue { if (this.header.ruleset.type === "team") { const content = expectTeamContent(this.getCurrentContent()); - const currentKeyId = content.get("readKey")?.keyID; + const currentKeyId = content.get("readKey"); if (!currentKeyId) { throw new Error("No readKey set"); @@ -403,63 +404,63 @@ export class CoValue { if (this.header.ruleset.type === "team") { const content = expectTeamContent(this.getCurrentContent()); - const readKeyHistory = content.getHistory("readKey"); + // Try to find key revelation for us - // Try to find direct relevation of key for us + const readKeyEntry = content.getLastEntry(`${keyID}_for_${this.node.account.id}`); - for (const entry of readKeyHistory) { - if (entry.value?.keyID === keyID) { - const revealer = accountOrAgentIDfromSessionID( - entry.txID.sessionID - ); - const revealerAgent = this.node.resolveAccount( - revealer, - "Expected to know revealer" - ); + if (readKeyEntry) { + const revealer = accountOrAgentIDfromSessionID( + readKeyEntry.txID.sessionID + ); + const revealerAgent = this.node.resolveAccountAgent( + revealer, + "Expected to know revealer" + ); - const secret = openAs( - entry.value.revelation, - this.node.account.currentRecipientSecret(), - getAgentRecipientID(revealerAgent), - { - in: this.id, - tx: entry.txID, - } - ); + const secret = unseal( + readKeyEntry.value, + this.node.account.currentRecipientSecret(), + getAgentRecipientID(revealerAgent), + { + in: this.id, + tx: readKeyEntry.txID, + } + ); - if (secret) return secret as KeySecret; - } + if (secret) return secret as KeySecret; } // Try to find indirect revelation through previousKeys - for (const entry of readKeyHistory) { - const encryptedPreviousKey = entry.value?.previousKeys?.[keyID]; - if (entry.value && encryptedPreviousKey) { - const sealingKeyID = entry.value.keyID; - const sealingKeySecret = this.getReadKey(sealingKeyID); + for (const field of content.keys()) { + if (isKeyForKeyField(field) && field.startsWith(keyID)) { + const encryptingKeyID = field.split("_for_")[1] as KeyID; + const encryptingKeySecret = this.getReadKey(encryptingKeyID); - if (!sealingKeySecret) { + if (!encryptingKeySecret) { continue; } - const secret = unsealKeySecret( + const encryptedPreviousKey = content.get(field)!; + + const secret = decryptKeySecret( { - sealed: keyID, - sealing: sealingKeyID, + encryptedID: keyID, + encryptingID: encryptingKeyID, encrypted: encryptedPreviousKey, }, - sealingKeySecret + encryptingKeySecret ); if (secret) { return secret; } else { console.error( - `Sealing ${sealingKeyID} key didn't unseal ${keyID}` + `Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}` ); } } + } return undefined; @@ -551,6 +552,4 @@ export class CoValue { ? [this.header.ruleset.team] : []; } -} - -export { SessionID }; +} \ No newline at end of file diff --git a/src/contentTypes/coMap.ts b/src/contentTypes/coMap.ts index a25def337..2454d0213 100644 --- a/src/contentTypes/coMap.ts +++ b/src/contentTypes/coMap.ts @@ -116,6 +116,21 @@ export class CoMap< return lastEntry.txID; } + getLastEntry(key: KK): { at: number; txID: TransactionID; value: 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 { at: lastEntry.madeAt, txID: lastEntry.txID, value: lastEntry.value }; + } + } + getHistory(key: KK): { at: number; txID: TransactionID; value: M[KK] | undefined; }[] { const ops = this.ops[key]; if (!ops) { diff --git a/src/crypto.test.ts b/src/crypto.test.ts index f4a4bdac1..56bf61aff 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -6,14 +6,14 @@ import { newRandomSignatory, seal, sign, - openAs, + unseal, verify, shortHash, newRandomKeySecret, encryptForTransaction, decryptForTransaction, - sealKeySecret, - unsealKeySecret, + encryptKeySecret, + decryptKeySecret, } from './crypto.js'; import { base58, base64url } from "@scure/base"; import { x25519 } from "@noble/curves/ed25519"; @@ -41,12 +41,11 @@ test("Invalid signatures don't verify", () => { expect(verify(wrongSignature, data, getSignatoryID(signatory))).toBe(false); }); -test("Sealing round-trips, but invalid receiver can't unseal", () => { +test("encrypting round-trips, but invalid receiver can't unseal", () => { const data = { b: "world", a: "hello" }; const sender = newRandomRecipient(); - const recipient1 = newRandomRecipient(); - const recipient2 = newRandomRecipient(); - const recipient3 = newRandomRecipient(); + const recipient = newRandomRecipient(); + const wrongRecipient = newRandomRecipient(); const nOnceMaterial = { in: "co_zTEST", @@ -56,34 +55,29 @@ test("Sealing round-trips, but invalid receiver can't unseal", () => { const sealed = seal( data, sender, - new Set([getRecipientID(recipient1), getRecipientID(recipient2)]), + getRecipientID(recipient), nOnceMaterial ); - expect(sealed[getRecipientID(recipient1)]).toMatch(/^sealed_U/); - expect(sealed[getRecipientID(recipient2)]).toMatch(/^sealed_U/); expect( - openAs(sealed, recipient1, getRecipientID(sender), nOnceMaterial) + unseal(sealed, recipient, getRecipientID(sender), nOnceMaterial) ).toEqual(data); expect( - openAs(sealed, recipient2, getRecipientID(sender), nOnceMaterial) - ).toEqual(data); - expect( - openAs(sealed, recipient3, getRecipientID(sender), nOnceMaterial) - ).toBeUndefined(); + () => unseal(sealed, wrongRecipient, getRecipientID(sender), nOnceMaterial) + ).toThrow(/Wrong tag/); // trying with wrong recipient secret, by hand const nOnce = blake3( new TextEncoder().encode(stableStringify(nOnceMaterial)) ).slice(0, 24); const recipient3priv = base58.decode( - recipient3.substring("recipientSecret_z".length) + wrongRecipient.substring("recipientSecret_z".length) ); const senderPub = base58.decode( getRecipientID(sender).substring("recipient_z".length) ); const sealedBytes = base64url.decode( - sealed[getRecipientID(recipient1)]!.substring("sealed_U".length) + sealed.substring("sealed_U".length) ); const sharedSecret = x25519.getSharedSecret(recipient3priv, senderPub); @@ -156,34 +150,34 @@ test("Encryption for transactions doesn't decrypt with a wrong key", () => { }); test("Encryption of keySecrets round-trips", () => { - const toSeal = newRandomKeySecret(); - const sealing = newRandomKeySecret(); + const toEncrypt = newRandomKeySecret(); + const encrypting = newRandomKeySecret(); const keys = { - toSeal, - sealing, + toEncrypt, + encrypting, }; - const sealed = sealKeySecret(keys); + const encrypted = encryptKeySecret(keys); - const unsealed = unsealKeySecret(sealed, sealing.secret); + const decrypted = decryptKeySecret(encrypted, encrypting.secret); - expect(unsealed).toEqual(toSeal.secret); + expect(decrypted).toEqual(toEncrypt.secret); }); -test("Encryption of keySecrets doesn't unseal with a wrong key", () => { - const toSeal = newRandomKeySecret(); - const sealing = newRandomKeySecret(); - const sealingWrong = newRandomKeySecret(); +test("Encryption of keySecrets doesn't decrypt with a wrong key", () => { + const toEncrypt = newRandomKeySecret(); + const encrypting = newRandomKeySecret(); + const encryptingWrong = newRandomKeySecret(); const keys = { - toSeal, - sealing, + toEncrypt, + encrypting, }; - const sealed = sealKeySecret(keys); + const encrypted = encryptKeySecret(keys); - const unsealed = unsealKeySecret(sealed, sealingWrong.secret); + const decrypted = decryptKeySecret(encrypted, encryptingWrong.secret); - expect(unsealed).toBeUndefined(); + expect(decrypted).toBeUndefined(); }); diff --git a/src/crypto.ts b/src/crypto.ts index 97624a0ac..0c0c3a9db 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -127,53 +127,40 @@ export function getAgentRecipientSecret(agentSecret: AgentSecret): RecipientSecr return agentSecret.split("/")[0] as RecipientSecret; } -export type SealedSet = { - [recipient: RecipientID]: Sealed; -}; - export function seal( message: T, from: RecipientSecret, - to: Set, + to: RecipientID, nOnceMaterial: { in: RawCoValueID; tx: TransactionID } -): SealedSet { +): Sealed { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) ).slice(0, 24); - const recipientsSorted = Array.from(to).sort(); - const recipientPubs = recipientsSorted.map((recipient) => { - return base58.decode(recipient.substring("recipient_z".length)); - }); + const recipientPub = base58.decode(to.substring("recipient_z".length)); + const senderPriv = base58.decode( from.substring("recipientSecret_z".length) ); const plaintext = textEncoder.encode(stableStringify(message)); - const sealedSet: SealedSet = {}; + const sharedSecret = x25519.getSharedSecret( + senderPriv, + recipientPub + ); - for (let i = 0; i < recipientsSorted.length; i++) { - const recipient = recipientsSorted[i]!; - const sharedSecret = x25519.getSharedSecret( - senderPriv, - recipientPubs[i]! - ); + const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt( + plaintext + ); - const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt( - plaintext - ); - - sealedSet[recipient] = `sealed_U${base64url.encode( - sealedBytes - )}` as Sealed; - } - - return sealedSet; + return `sealed_U${base64url.encode( + sealedBytes + )}` as Sealed } -export function openAs( - sealedSet: SealedSet, +export function unseal( + sealed: Sealed, recipient: RecipientSecret, from: RecipientID, nOnceMaterial: { in: RawCoValueID; tx: TransactionID } @@ -188,12 +175,6 @@ export function openAs( const senderPub = base58.decode(from.substring("recipient_z".length)); - const sealed = sealedSet[getRecipientID(recipient)]; - - if (!sealed) { - return undefined; - } - const sealedBytes = base64url.decode(sealed.substring("sealed_U".length)); const sharedSecret = x25519.getSharedSecret(recipientPriv, senderPub); @@ -287,25 +268,25 @@ export function encryptForTransaction( return encrypt(value, keySecret, nOnceMaterial); } -export function sealKeySecret(keys: { - toSeal: { id: KeyID; secret: KeySecret }; - sealing: { id: KeyID; secret: KeySecret }; +export function encryptKeySecret(keys: { + toEncrypt: { id: KeyID; secret: KeySecret }; + encrypting: { id: KeyID; secret: KeySecret }; }): { - sealed: KeyID; - sealing: KeyID; - encrypted: Encrypted; + encryptedID: KeyID; + encryptingID: KeyID; + encrypted: Encrypted; } { const nOnceMaterial = { - sealed: keys.toSeal.id, - sealing: keys.sealing.id, + encryptedID: keys.toEncrypt.id, + encryptingID: keys.encrypting.id, }; return { - sealed: keys.toSeal.id, - sealing: keys.sealing.id, + encryptedID: keys.toEncrypt.id, + encryptingID: keys.encrypting.id, encrypted: encrypt( - keys.toSeal.secret, - keys.sealing.secret, + keys.toEncrypt.secret, + keys.encrypting.secret, nOnceMaterial ), }; @@ -343,20 +324,20 @@ export function decryptForTransaction( return decrypt(encrypted, keySecret, nOnceMaterial); } -export function unsealKeySecret( - sealedInfo: { - sealed: KeyID; - sealing: KeyID; - encrypted: Encrypted; +export function decryptKeySecret( + encryptedInfo: { + encryptedID: KeyID; + encryptingID: KeyID; + encrypted: Encrypted; }, sealingSecret: KeySecret ): KeySecret | undefined { const nOnceMaterial = { - sealed: sealedInfo.sealed, - sealing: sealedInfo.sealing, + encryptedID: encryptedInfo.encryptedID, + encryptingID: encryptedInfo.encryptingID, }; - return decrypt(sealedInfo.encrypted, sealingSecret, nOnceMaterial); + return decrypt(encryptedInfo.encrypted, sealingSecret, nOnceMaterial); } export function uniquenessForHeader(): `z${string}` { diff --git a/src/node.ts b/src/node.ts index 2d4161574..538af74da 100644 --- a/src/node.ts +++ b/src/node.ts @@ -87,27 +87,31 @@ export class LocalNode { const account = this.createCoValue( accountHeaderForInitialAgentSecret(agentSecret) - ).testWithDifferentAccount(new AnonymousControlledAccount(agentSecret), newRandomSessionID(getAgentID(agentSecret))); + ).testWithDifferentAccount( + new AnonymousControlledAccount(agentSecret), + newRandomSessionID(getAgentID(agentSecret)) + ); 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 }, + `${readKey.id}_for_${getAgentID(agentSecret)}`, + seal( + readKey.secret, + getAgentRecipientSecret(agentSecret), + getAgentRecipientID(getAgentID(agentSecret)), + { + in: account.id, + tx: account.nextTransactionID(), + } + ), "trusting" ); + + editable.set('readKey', readKey.id, "trusting"); }); return new ControlledAccount( @@ -117,7 +121,7 @@ export class LocalNode { ); } - resolveAccount(id: AccountIDOrAgentID, expectation?: string): AgentID { + resolveAccountAgent(id: AccountIDOrAgentID, expectation?: string): AgentID { if (isAgentID(id)) { return id; } @@ -159,21 +163,22 @@ export class LocalNode { editable.set(this.account.id, "admin", "trusting"); const readKey = newRandomKeySecret(); - const revelation = seal( - readKey.secret, - this.account.currentRecipientSecret(), - new Set([this.account.currentRecipientID()]), - { - in: teamCoValue.id, - tx: teamCoValue.nextTransactionID(), - } - ); editable.set( - "readKey", - { keyID: readKey.id, revelation }, + `${readKey.id}_for_${this.account.id}`, + seal( + readKey.secret, + this.account.currentRecipientSecret(), + this.account.currentRecipientID(), + { + in: teamCoValue.id, + tx: teamCoValue.nextTransactionID(), + } + ), "trusting" ); + + editable.set('readKey', readKey.id, "trusting"); }); return new Team(teamContent, this); diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 07beca442..ed7c830aa 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -7,7 +7,7 @@ import { getRecipientID, newRandomKeySecret, seal, - sealKeySecret, + encryptKeySecret, } from "./crypto.js"; import { newTeam, @@ -424,17 +424,23 @@ test("Admins can set team read key and then use it to create and read private tr const revelation = seal( readKey, admin.currentRecipientSecret(), - new Set([admin.currentRecipientID()]), + admin.currentRecipientID(), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); - expect(editable.get("readKey")).toEqual({ - keyID: readKeyID, - revelation, - }); + + editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting"); + + expect(editable.get(`${readKeyID}_for_${admin.id}`)).toEqual( + revelation + ); + + editable.set("readKey", readKeyID, "trusting"); + + expect(editable.get("readKey")).toEqual(readKeyID); + expect(team.getCurrentReadKey().secret).toEqual(readKey); }); @@ -483,16 +489,31 @@ test("Admins can set team read key and then writers can use it to create and rea editable.set(writer.id, "writer", "trusting"); expect(editable.get(writer.id)).toEqual("writer"); - const revelation = seal( + const revelation1 = seal( readKey, admin.currentRecipientSecret(), - new Set([admin.currentRecipientID(), writer.currentRecipientID()]), + admin.currentRecipientID(), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); + + editable.set(`${readKeyID}_for_${admin.id}`, revelation1, "trusting"); + + const revelation2 = seal( + readKey, + admin.currentRecipientSecret(), + writer.currentRecipientID(), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + + editable.set(`${readKeyID}_for_${writer.id}`, revelation2, "trusting"); + + editable.set("readKey", readKeyID, "trusting"); }); const childObject = node.createCoValue({ @@ -560,16 +581,31 @@ test("Admins can set team read key and then use it to create private transaction editable.set(reader.id, "reader", "trusting"); expect(editable.get(reader.id)).toEqual("reader"); - const revelation = seal( + const revelation1 = seal( readKey, admin.currentRecipientSecret(), - new Set([admin.currentRecipientID(), reader.currentRecipientID()]), + admin.currentRecipientID(), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); + + editable.set(`${readKeyID}_for_${admin.id}`, revelation1, "trusting"); + + const revelation2 = seal( + readKey, + admin.currentRecipientSecret(), + reader.currentRecipientID(), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + + editable.set(`${readKeyID}_for_${reader.id}`, revelation2, "trusting"); + + editable.set("readKey", readKeyID, "trusting"); }); const childObject = node.createCoValue({ @@ -640,32 +676,28 @@ test("Admins can set team read key and then use it to create private transaction const revelation1 = seal( readKey, admin.currentRecipientSecret(), - new Set([admin.currentRecipientID(), reader1.currentRecipientID()]), + admin.currentRecipientID(), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set( - "readKey", - { keyID: readKeyID, revelation: revelation1 }, - "trusting" - ); + + editable.set(`${readKeyID}_for_${admin.id}`, revelation1, "trusting"); const revelation2 = seal( readKey, admin.currentRecipientSecret(), - new Set([reader2.currentRecipientID()]), + reader1.currentRecipientID(), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set( - "readKey", - { keyID: readKeyID, revelation: revelation2 }, - "trusting" - ); + + editable.set(`${readKeyID}_for_${reader1.id}`, revelation2, "trusting"); + + editable.set("readKey", readKeyID, "trusting"); }); const childObject = node.createCoValue({ @@ -694,6 +726,20 @@ test("Admins can set team read key and then use it to create private transaction expect(childContentAsReader1.get("foo")).toEqual("bar"); + teamContent.edit((editable) => { + const revelation3 = seal( + readKey, + admin.currentRecipientSecret(), + reader2.currentRecipientID(), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + + editable.set(`${readKeyID}_for_${reader2.id}`, revelation3, "trusting"); + }); + const childObjectAsReader2 = childObject.testWithDifferentAccount( reader2, newRandomSessionID(reader2.id) @@ -753,17 +799,17 @@ test("Admins can set team read key, make a private transaction in an owned objec const revelation = seal( readKey, admin.currentRecipientSecret(), - new Set([admin.currentRecipientID()]), + admin.currentRecipientID(), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); - expect(editable.get("readKey")).toEqual({ - keyID: readKeyID, - revelation, - }); + + editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting"); + + editable.set("readKey", readKeyID, "trusting"); + expect(editable.get("readKey")).toEqual(readKeyID); expect(team.getCurrentReadKey().secret).toEqual(readKey); }); @@ -791,18 +837,17 @@ test("Admins can set team read key, make a private transaction in an owned objec const revelation = seal( readKey2, admin.currentRecipientSecret(), - new Set([admin.currentRecipientID()]), + admin.currentRecipientID(), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID2, revelation }, "trusting"); - expect(editable.get("readKey")).toEqual({ - keyID: readKeyID2, - revelation, - }); + editable.set(`${readKeyID2}_for_${admin.id}`, revelation, "trusting"); + + editable.set("readKey", readKeyID2, "trusting"); + expect(editable.get("readKey")).toEqual(readKeyID2); expect(team.getCurrentReadKey().secret).toEqual(readKey2); }); @@ -863,17 +908,17 @@ test("Admins can set team read key, make a private transaction in an owned objec const revelation = seal( readKey, admin.currentRecipientSecret(), - new Set([admin.currentRecipientID()]), + admin.currentRecipientID(), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); - expect(editable.get("readKey")).toEqual({ - keyID: readKeyID, - revelation, - }); + + editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting"); + + editable.set("readKey", readKeyID, "trusting"); + expect(editable.get("readKey")).toEqual(readKeyID); expect(team.getCurrentReadKey().secret).toEqual(readKey); }); @@ -892,34 +937,42 @@ test("Admins can set team read key, make a private transaction in an owned objec const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret(); teamContent.edit((editable) => { - const revelation = seal( + const revelation2 = seal( readKey2, admin.currentRecipientSecret(), - new Set([admin.currentRecipientID(), reader.currentRecipientID()]), + admin.currentRecipientID(), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set( - "readKey", + editable.set(`${readKeyID2}_for_${admin.id}`, revelation2, "trusting"); + + const revelation3 = seal( + readKey2, + admin.currentRecipientSecret(), + reader.currentRecipientID(), { - keyID: readKeyID2, - revelation, - previousKeys: { - [readKeyID]: sealKeySecret({ - toSeal: { id: readKeyID, secret: readKey }, - sealing: { id: readKeyID2, secret: readKey2 }, - }).encrypted, - }, - }, + in: team.id, + tx: team.nextTransactionID(), + } + ); + + editable.set(`${readKeyID2}_for_${reader.id}`, revelation3, "trusting"); + + editable.set( + `${readKeyID}_for_${readKeyID2}`, + encryptKeySecret({ + toEncrypt: { id: readKeyID, secret: readKey }, + encrypting: { id: readKeyID2, secret: readKey2 }, + }).encrypted, "trusting" ); - expect(editable.get("readKey")).toMatchObject({ - keyID: readKeyID2, - revelation, - }); + + editable.set("readKey", readKeyID2, "trusting"); + + expect(editable.get("readKey")).toEqual(readKeyID2); expect(team.getCurrentReadKey().secret).toEqual(readKey2); editable.set(reader.id, "reader", "trusting"); @@ -1001,24 +1054,44 @@ test("Admins can set team read rey, make a private transaction in an owned objec const reader2 = node.createAccount("reader2"); teamContent.edit((editable) => { - const revelation = seal( + const revelation1 = seal( readKey, admin.currentRecipientSecret(), - new Set([ - admin.currentRecipientID(), - reader.currentRecipientID(), - reader2.currentRecipientID(), - ]), + admin.currentRecipientID(), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID, revelation }, "trusting"); - expect(editable.get("readKey")).toEqual({ - keyID: readKeyID, - revelation, - }); + + editable.set(`${readKeyID}_for_${admin.id}`, revelation1, "trusting"); + + const revelation2 = seal( + readKey, + admin.currentRecipientSecret(), + reader.currentRecipientID(), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + + editable.set(`${readKeyID}_for_${reader.id}`, revelation2, "trusting"); + + const revelation3 = seal( + readKey, + admin.currentRecipientSecret(), + reader2.currentRecipientID(), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + + editable.set(`${readKeyID}_for_${reader2.id}`, revelation3, "trusting"); + + editable.set("readKey", readKeyID, "trusting"); + expect(editable.get("readKey")).toEqual(readKeyID); expect(team.getCurrentReadKey().secret).toEqual(readKey); editable.set(reader.id, "reader", "trusting"); @@ -1058,20 +1131,40 @@ test("Admins can set team read rey, make a private transaction in an owned objec const { secret: readKey2, id: readKeyID2 } = newRandomKeySecret(); teamContent.edit((editable) => { - const revelation = seal( + const newRevelation1 = seal( readKey2, admin.currentRecipientSecret(), - new Set([admin.currentRecipientID(), reader2.currentRecipientID()]), + admin.currentRecipientID(), { in: team.id, tx: team.nextTransactionID(), } ); - editable.set("readKey", { keyID: readKeyID2, revelation }, "trusting"); - expect(editable.get("readKey")).toEqual({ - keyID: readKeyID2, - revelation, - }); + + editable.set( + `${readKeyID2}_for_${admin.id}`, + newRevelation1, + "trusting" + ); + + const newRevelation2 = seal( + readKey2, + admin.currentRecipientSecret(), + reader2.currentRecipientID(), + { + in: team.id, + tx: team.nextTransactionID(), + } + ); + + editable.set( + `${readKeyID2}_for_${reader2.id}`, + newRevelation2, + "trusting" + ); + + editable.set("readKey", readKeyID2, "trusting"); + expect(editable.get("readKey")).toEqual(readKeyID2); expect(team.getCurrentReadKey().secret).toEqual(readKey2); editable.set(reader.id, "revoked", "trusting"); diff --git a/src/permissions.ts b/src/permissions.ts index b1d71f4b3..e36c0a101 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,29 +1,29 @@ -import { CoValueID, ContentType } from './contentType.js'; -import { CoMap, MapOpPayload } from './contentTypes/coMap.js'; -import { JsonValue } from './jsonValue.js'; +import { CoValueID, ContentType } from "./contentType.js"; +import { CoMap, MapOpPayload } from "./contentTypes/coMap.js"; +import { JsonValue } from "./jsonValue.js"; import { Encrypted, KeyID, KeySecret, - SealedSet, createdNowUnique, newRandomKeySecret, seal, - sealKeySecret, - getAgentRecipientID -} from './crypto.js'; + encryptKeySecret, + getAgentRecipientID, + Sealed, +} from "./crypto.js"; import { CoValue, Transaction, TrustingTransaction, accountOrAgentIDfromSessionID, -} from './coValue.js'; +} from "./coValue.js"; import { LocalNode } from "./node.js"; -import { RawCoValueID, SessionID, TransactionID, isAgentID } from './ids.js'; -import { AccountIDOrAgentID, GeneralizedControlledAccount } from './account.js'; +import { RawCoValueID, SessionID, TransactionID, isAgentID } from "./ids.js"; +import { AccountIDOrAgentID, GeneralizedControlledAccount } from "./account.js"; export type PermissionsDef = - | { type: "team"; initialAdmin: AccountIDOrAgentID; } + | { type: "team"; initialAdmin: AccountIDOrAgentID } | { type: "ownedByTeam"; team: RawCoValueID } | { type: "unsafeAllowAll" }; @@ -94,6 +94,14 @@ export function determineValidTransactions( continue; } + validTransactions.push({ txID: { sessionID, txIndex }, tx }); + continue; + } else if (isKeyForKeyField(change.key) || isKeyForAccountField(change.key)) { + if (memberState[transactor] !== "admin") { + console.warn("Only admins can reveal keys"); + continue; + } + // TODO: check validity of agents who the key is revealed to? validTransactions.push({ txID: { sessionID, txIndex }, tx }); @@ -146,11 +154,12 @@ export function determineValidTransactions( return validTransactions; } else if (coValue.header.ruleset.type === "ownedByTeam") { - const teamContent = - coValue.node.expectCoValueLoaded( + const teamContent = coValue.node + .expectCoValueLoaded( coValue.header.ruleset.team, "Determining valid transaction in owned object but its team wasn't loaded" - ).getCurrentContent(); + ) + .getCurrentContent(); if (teamContent.type !== "comap") { throw new Error("Team must be a map"); @@ -158,7 +167,9 @@ export function determineValidTransactions( return Object.entries(coValue.sessions).flatMap( ([sessionID, sessionLog]) => { - const transactor = accountOrAgentIDfromSessionID(sessionID as SessionID); + const transactor = accountOrAgentIDfromSessionID( + sessionID as SessionID + ); return sessionLog.transactions .filter((tx) => { const transactorRoleAtTxTime = teamContent.getAtTime( @@ -187,24 +198,25 @@ export function determineValidTransactions( } ); } else { - throw new Error("Unknown ruleset type " + (coValue.header.ruleset as any).type); + throw new Error( + "Unknown ruleset type " + (coValue.header.ruleset as any).type + ); } } -export type TeamContent = { [key: AccountIDOrAgentID]: Role } & { - readKey: { - keyID: KeyID; - revelation: SealedSet; - previousKeys?: { - [key: KeyID]: Encrypted< - KeySecret, - { sealed: KeyID; sealing: KeyID } - >; - }; - }; +export type TeamContent = { + [key: AccountIDOrAgentID]: Role; + readKey: KeyID; + [revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed; + [oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted< + KeySecret, + { encryptedID: KeyID; encryptingID: KeyID } + >; }; -export function expectTeamContent(content: ContentType): CoMap { +export function expectTeamContent( + content: ContentType +): CoMap { if (content.type !== "comap") { throw new Error("Expected map"); } @@ -227,36 +239,34 @@ export class Team { addMember(accountID: AccountIDOrAgentID, role: Role) { this.teamMap = this.teamMap.edit((map) => { - const agent = this.node.resolveAccount(accountID, "Expected to know agent to add them to team"); - - if (!agent) { - throw new Error("Unknown account/agent " + accountID); - } - - map.set(accountID, role, "trusting"); - if (map.get(accountID) !== role) { - throw new Error("Failed to set role"); - } - const currentReadKey = this.teamMap.coValue.getCurrentReadKey(); if (!currentReadKey.secret) { throw new Error("Can't add member without read key secret"); } - const revelation = seal( - currentReadKey.secret, - this.teamMap.coValue.node.account.currentRecipientSecret(), - new Set([getAgentRecipientID(agent)]), - { - in: this.teamMap.coValue.id, - tx: this.teamMap.coValue.nextTransactionID(), - } + const agent = this.node.resolveAccountAgent( + accountID, + "Expected to know agent to add them to team" ); + map.set(accountID, role, "trusting"); + + if (map.get(accountID) !== role) { + throw new Error("Failed to set role"); + } + map.set( - "readKey", - { keyID: currentReadKey.id, revelation }, + `${currentReadKey.id}_for_${accountID}`, + seal( + currentReadKey.secret, + this.teamMap.coValue.node.account.currentRecipientSecret(), + getAgentRecipientID(agent), + { + in: this.teamMap.coValue.id, + tx: this.teamMap.coValue.nextTransactionID(), + } + ), "trusting" ); }); @@ -277,7 +287,9 @@ export class Team { const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey(); if (!maybeCurrentReadKey.secret) { - throw new Error("Can't rotate read key secret we don't have access to"); + throw new Error( + "Can't rotate read key secret we don't have access to" + ); } const currentReadKey = { @@ -287,41 +299,38 @@ export class Team { const newReadKey = newRandomKeySecret(); - const newReadKeyRevelation = seal( - newReadKey.secret, - this.teamMap.coValue.node.account.currentRecipientSecret(), - new Set( - currentlyPermittedReaders.map( - (reader) => { - const readerAgent = this.node.resolveAccount(reader, "Expected to know currently permitted reader"); - if (!readerAgent) { - throw new Error("Unknown agent " + reader); - } - return getAgentRecipientID(readerAgent) - } - ) - ), - { - in: this.teamMap.coValue.id, - tx: this.teamMap.coValue.nextTransactionID(), - } - ); - this.teamMap = this.teamMap.edit((map) => { + for (const readerID of currentlyPermittedReaders) { + const reader = this.node.resolveAccountAgent( + readerID, + "Expected to know currently permitted reader" + ); + + map.set( + `${newReadKey.id}_for_${readerID}`, + seal( + newReadKey.secret, + this.teamMap.coValue.node.account.currentRecipientSecret(), + getAgentRecipientID(reader), + { + in: this.teamMap.coValue.id, + tx: this.teamMap.coValue.nextTransactionID(), + } + ), + "trusting" + ); + } + map.set( - "readKey", - { - keyID: newReadKey.id, - revelation: newReadKeyRevelation, - previousKeys: { - [currentReadKey.id]: sealKeySecret({ - sealing: newReadKey, - toSeal: currentReadKey, - }).encrypted, - }, - }, + `${currentReadKey.id}_for_${newReadKey.id}`, + encryptKeySecret({ + encrypting: newReadKey, + toEncrypt: currentReadKey, + }).encrypted, "trusting" ); + + map.set("readKey", newReadKey.id, "trusting"); }); } @@ -364,3 +373,11 @@ export class Team { ); } } + +export function isKeyForKeyField(field: string): field is `${KeyID}_for_${KeyID}` { + return field.startsWith("key_") && field.includes("_for_key"); +} + +export function isKeyForAccountField(field: string): field is `${KeyID}_for_${AccountIDOrAgentID}` { + return field.startsWith("key_") && (field.includes("_for_recipient") || field.includes("_for_co")); +} \ No newline at end of file diff --git a/src/testUtils.ts b/src/testUtils.ts index ce6a8a860..9102e1b58 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -1,8 +1,9 @@ import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "./crypto.js"; -import { SessionID, newRandomSessionID } from "./coValue.js"; +import { newRandomSessionID } from "./coValue.js"; import { LocalNode } from "./node.js"; import { expectTeamContent } from "./permissions.js"; import { AnonymousControlledAccount } from "./account.js"; +import { SessionID } from "./ids.js"; export function randomAnonymousAccountAndSessionID(): [AnonymousControlledAccount, SessionID] { const agentSecret = newRandomAgentSecret(); From 8c122235e790f6a34d7ab2abd8be03d341fd7ae3 Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 14 Aug 2023 17:43:15 +0100 Subject: [PATCH 39/47] Rename signatory -> signer and recipient -> sealer --- README.md | 4 +- src/account.ts | 44 ++++++++-------- src/coValue.test.ts | 8 +-- src/coValue.ts | 20 +++---- src/crypto.test.ts | 44 ++++++++-------- src/crypto.ts | 114 ++++++++++++++++++++-------------------- src/ids.ts | 4 +- src/node.ts | 12 ++--- src/permissions.test.ts | 74 +++++++++++++------------- src/permissions.ts | 12 ++--- 10 files changed, 168 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index bd08d7c76..bc124440b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ THIS IS WORK IN PROGRESS - boolean - number - string - - stringly-encoded CoJSON identifiers & data (`CoValueID`, `AgentID`, `SessionID`, `SignatoryID`, `SignatorySecret`, `Signature`, `RecipientID`, `RecipientSecret`, `Sealed`, `Hash`, `ShortHash`, `KeySecret`, `KeyID`, `Encrypted`, `Role`) + - stringly-encoded CoJSON identifiers & data (`CoValueID`, `AgentID`, `SessionID`, `SignerID`, `SignerSecret`, `Signature`, `SealerID`, `SealerSecret`, `Sealed`, `Hash`, `ShortHash`, `KeySecret`, `KeyID`, `Encrypted`, `Role`) - array - object @@ -29,7 +29,7 @@ THIS IS WORK IN PROGRESS - CoMap (`string` → `Immutable`, last-writer-wins per key) - Team (`AgentID` → `Role`) - CoList (`Immutable[]`, addressable positions, insertAfter semantics) - - Agent (`{signatoryID, recipientID}[]`) + - Agent (`{signerID, sealerID}[]`) - CoStream (independent per-session streams of `Immutable`s) - Static (single addressable `Immutable`) diff --git a/src/account.ts b/src/account.ts index a12e50468..bdd012c28 100644 --- a/src/account.ts +++ b/src/account.ts @@ -1,6 +1,6 @@ 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 { AgentSecret, SealerID, SealerSecret, SignerID, SignerSecret, getAgentID, getAgentSealerID, getAgentSealerSecret, getAgentSignerID, getAgentSignerSecret } from './crypto.js'; import { AgentID } from './ids.js'; import { CoMap, LocalNode } from './index.js'; import { Team, TeamContent } from './permissions.js'; @@ -24,7 +24,7 @@ export class Account extends Team { } getCurrentAgentID(): AgentID { - const agents = this.teamMap.keys().filter((k): k is AgentID => k.startsWith("recipient_")); + const agents = this.teamMap.keys().filter((k): k is AgentID => k.startsWith("sealer_")); if (agents.length !== 1) { throw new Error("Expected exactly one agent in account, got " + agents.length); @@ -39,10 +39,10 @@ export interface GeneralizedControlledAccount { agentSecret: AgentSecret; currentAgentID: () => AgentID; - currentSignatoryID: () => SignatoryID; - currentSignatorySecret: () => SignatorySecret; - currentRecipientID: () => RecipientID; - currentRecipientSecret: () => RecipientSecret; + currentSignerID: () => SignerID; + currentSignerSecret: () => SignerSecret; + currentSealerID: () => SealerID; + currentSealerSecret: () => SealerSecret; } export class ControlledAccount extends Account implements GeneralizedControlledAccount { @@ -58,20 +58,20 @@ export class ControlledAccount extends Account implements GeneralizedControlledA return getAgentID(this.agentSecret); } - currentSignatoryID(): SignatoryID { - return getAgentSignatoryID(this.currentAgentID()); + currentSignerID(): SignerID { + return getAgentSignerID(this.currentAgentID()); } - currentSignatorySecret(): SignatorySecret { - return getAgentSignatorySecret(this.agentSecret); + currentSignerSecret(): SignerSecret { + return getAgentSignerSecret(this.agentSecret); } - currentRecipientID(): RecipientID { - return getAgentRecipientID(this.currentAgentID()); + currentSealerID(): SealerID { + return getAgentSealerID(this.currentAgentID()); } - currentRecipientSecret(): RecipientSecret { - return getAgentRecipientSecret(this.agentSecret); + currentSealerSecret(): SealerSecret { + return getAgentSealerSecret(this.agentSecret); } } @@ -90,20 +90,20 @@ export class AnonymousControlledAccount implements GeneralizedControlledAccount return getAgentID(this.agentSecret); } - currentSignatoryID(): SignatoryID { - return getAgentSignatoryID(this.currentAgentID()); + currentSignerID(): SignerID { + return getAgentSignerID(this.currentAgentID()); } - currentSignatorySecret(): SignatorySecret { - return getAgentSignatorySecret(this.agentSecret); + currentSignerSecret(): SignerSecret { + return getAgentSignerSecret(this.agentSecret); } - currentRecipientID(): RecipientID { - return getAgentRecipientID(this.currentAgentID()); + currentSealerID(): SealerID { + return getAgentSealerID(this.currentAgentID()); } - currentRecipientSecret(): RecipientSecret { - return getAgentRecipientSecret(this.agentSecret); + currentSealerSecret(): SealerSecret { + return getAgentSealerSecret(this.agentSecret); } } diff --git a/src/coValue.test.ts b/src/coValue.test.ts index 5d4f8a88d..9fd388ef9 100644 --- a/src/coValue.test.ts +++ b/src/coValue.test.ts @@ -1,6 +1,6 @@ import { Transaction } from "./coValue.js"; import { LocalNode } from "./node.js"; -import { createdNowUnique, getAgentSignatorySecret, newRandomAgentSecret, sign } from "./crypto.js"; +import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js"; import { randomAnonymousAccountAndSessionID } from "./testUtils.js"; test("Can create coValue with new agent credentials and add transaction to it", () => { @@ -34,7 +34,7 @@ test("Can create coValue with new agent credentials and add transaction to it", node.ownSessionID, [transaction], expectedNewHash, - sign(account.currentSignatorySecret(), expectedNewHash) + sign(account.currentSignerSecret(), expectedNewHash) ) ).toBe(true); }); @@ -71,7 +71,7 @@ test("transactions with wrong signature are rejected", () => { node.ownSessionID, [transaction], expectedNewHash, - sign(getAgentSignatorySecret(wrongAgent), expectedNewHash) + sign(getAgentSignerSecret(wrongAgent), expectedNewHash) ) ).toBe(false); }); @@ -117,7 +117,7 @@ test("transactions with correctly signed, but wrong hash are rejected", () => { node.ownSessionID, [transaction], expectedNewHash, - sign(account.currentSignatorySecret(), expectedNewHash) + sign(account.currentSignerSecret(), expectedNewHash) ) ).toBe(false); }); diff --git a/src/coValue.ts b/src/coValue.ts index 8d2e24db9..f79b9ce00 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -17,8 +17,8 @@ import { decryptForTransaction, KeyID, decryptKeySecret, - getAgentSignatoryID, - getAgentRecipientID, + getAgentSignerID, + getAgentSealerID, } from "./crypto.js"; import { JsonObject, JsonValue } from "./jsonValue.js"; import { base58 } from "@scure/base"; @@ -158,14 +158,14 @@ export class CoValue { newHash: Hash, newSignature: Signature ): boolean { - const signatoryID = getAgentSignatoryID( + const signerID = getAgentSignerID( this.node.resolveAccountAgent( accountOrAgentIDfromSessionID(sessionID), - "Expected to know signatory of transaction" + "Expected to know signer of transaction" ) ); - if (!signatoryID) { + if (!signerID) { console.warn( "Unknown agent", accountOrAgentIDfromSessionID(sessionID) @@ -183,12 +183,12 @@ export class CoValue { return false; } - if (!verify(newSignature, newHash, signatoryID)) { + if (!verify(newSignature, newHash, signerID)) { console.warn( "Invalid signature", newSignature, newHash, - signatoryID + signerID ); return false; } @@ -284,7 +284,7 @@ export class CoValue { ]); const signature = sign( - this.node.account.currentSignatorySecret(), + this.node.account.currentSignerSecret(), expectedNewHash ); @@ -419,8 +419,8 @@ export class CoValue { const secret = unseal( readKeyEntry.value, - this.node.account.currentRecipientSecret(), - getAgentRecipientID(revealerAgent), + this.node.account.currentSealerSecret(), + getAgentSealerID(revealerAgent), { in: this.id, tx: readKeyEntry.txID, diff --git a/src/crypto.test.ts b/src/crypto.test.ts index 56bf61aff..3b31afafa 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -1,9 +1,9 @@ import { - getRecipientID, - getSignatoryID, + getSealerID, + getSignerID, secureHash, - newRandomRecipient, - newRandomSignatory, + newRandomSealer, + newRandomSigner, seal, sign, unseal, @@ -23,29 +23,29 @@ import stableStringify from "fast-json-stable-stringify"; test("Signatures round-trip and use stable stringify", () => { const data = { b: "world", a: "hello" }; - const signatory = newRandomSignatory(); - const signature = sign(signatory, data); + const signer = newRandomSigner(); + const signature = sign(signer, data); expect(signature).toMatch(/^signature_z/); expect( - verify(signature, { a: "hello", b: "world" }, getSignatoryID(signatory)) + verify(signature, { a: "hello", b: "world" }, getSignerID(signer)) ).toBe(true); }); test("Invalid signatures don't verify", () => { const data = { b: "world", a: "hello" }; - const signatory = newRandomSignatory(); - const signatory2 = newRandomSignatory(); - const wrongSignature = sign(signatory2, data); + const signer = newRandomSigner(); + const signer2 = newRandomSigner(); + const wrongSignature = sign(signer2, data); - expect(verify(wrongSignature, data, getSignatoryID(signatory))).toBe(false); + expect(verify(wrongSignature, data, getSignerID(signer))).toBe(false); }); test("encrypting round-trips, but invalid receiver can't unseal", () => { const data = { b: "world", a: "hello" }; - const sender = newRandomRecipient(); - const recipient = newRandomRecipient(); - const wrongRecipient = newRandomRecipient(); + const sender = newRandomSealer(); + const sealer = newRandomSealer(); + const wrongSealer = newRandomSealer(); const nOnceMaterial = { in: "co_zTEST", @@ -55,31 +55,31 @@ test("encrypting round-trips, but invalid receiver can't unseal", () => { const sealed = seal( data, sender, - getRecipientID(recipient), + getSealerID(sealer), nOnceMaterial ); expect( - unseal(sealed, recipient, getRecipientID(sender), nOnceMaterial) + unseal(sealed, sealer, getSealerID(sender), nOnceMaterial) ).toEqual(data); expect( - () => unseal(sealed, wrongRecipient, getRecipientID(sender), nOnceMaterial) + () => unseal(sealed, wrongSealer, getSealerID(sender), nOnceMaterial) ).toThrow(/Wrong tag/); - // trying with wrong recipient secret, by hand + // trying with wrong sealer secret, by hand const nOnce = blake3( new TextEncoder().encode(stableStringify(nOnceMaterial)) ).slice(0, 24); - const recipient3priv = base58.decode( - wrongRecipient.substring("recipientSecret_z".length) + const sealer3priv = base58.decode( + wrongSealer.substring("sealerSecret_z".length) ); const senderPub = base58.decode( - getRecipientID(sender).substring("recipient_z".length) + getSealerID(sender).substring("sealer_z".length) ); const sealedBytes = base64url.decode( sealed.substring("sealed_U".length) ); - const sharedSecret = x25519.getSharedSecret(recipient3priv, senderPub); + const sharedSecret = x25519.getSharedSecret(sealer3priv, senderPub); expect(() => { const _ = xsalsa20_poly1305(sharedSecret, nOnce).decrypt(sealedBytes); diff --git a/src/crypto.ts b/src/crypto.ts index 0c0c3a9db..89f53b490 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -7,45 +7,45 @@ import { blake3 } from "@noble/hashes/blake3"; import { randomBytes } from "@noble/ciphers/webcrypto/utils"; import { AgentID, RawCoValueID, TransactionID } from './ids.js'; -export type SignatorySecret = `signatorySecret_z${string}`; -export type SignatoryID = `signatory_z${string}`; +export type SignerSecret = `signerSecret_z${string}`; +export type SignerID = `signer_z${string}`; export type Signature = `signature_z${string}`; -export type RecipientSecret = `recipientSecret_z${string}`; -export type RecipientID = `recipient_z${string}`; +export type SealerSecret = `sealerSecret_z${string}`; +export type SealerID = `sealer_z${string}`; export type Sealed = `sealed_U${string}` & { __type: T }; -export type AgentSecret = `${RecipientSecret}/${SignatorySecret}`; +export type AgentSecret = `${SealerSecret}/${SignerSecret}`; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); -export function newRandomSignatory(): SignatorySecret { - return `signatorySecret_z${base58.encode( +export function newRandomSigner(): SignerSecret { + return `signerSecret_z${base58.encode( ed25519.utils.randomPrivateKey() )}`; } -export function signatorySecretToBytes(secret: SignatorySecret): Uint8Array { - return base58.decode(secret.substring("signatorySecret_z".length)); +export function signerSecretToBytes(secret: SignerSecret): Uint8Array { + return base58.decode(secret.substring("signerSecret_z".length)); } -export function signatorySecretFromBytes(bytes: Uint8Array): SignatorySecret { - return `signatorySecret_z${base58.encode(bytes)}`; +export function signerSecretFromBytes(bytes: Uint8Array): SignerSecret { + return `signerSecret_z${base58.encode(bytes)}`; } -export function getSignatoryID(secret: SignatorySecret): SignatoryID { - return `signatory_z${base58.encode( +export function getSignerID(secret: SignerSecret): SignerID { + return `signer_z${base58.encode( ed25519.getPublicKey( - base58.decode(secret.substring("signatorySecret_z".length)) + base58.decode(secret.substring("signerSecret_z".length)) ) )}`; } -export function sign(secret: SignatorySecret, message: JsonValue): Signature { +export function sign(secret: SignerSecret, message: JsonValue): Signature { const signature = ed25519.sign( textEncoder.encode(stableStringify(message)), - base58.decode(secret.substring("signatorySecret_z".length)) + base58.decode(secret.substring("signerSecret_z".length)) ); return `signature_z${base58.encode(signature)}`; } @@ -53,101 +53,101 @@ export function sign(secret: SignatorySecret, message: JsonValue): Signature { export function verify( signature: Signature, message: JsonValue, - id: SignatoryID + id: SignerID ): boolean { return ed25519.verify( base58.decode(signature.substring("signature_z".length)), textEncoder.encode(stableStringify(message)), - base58.decode(id.substring("signatory_z".length)) + base58.decode(id.substring("signer_z".length)) ); } -export function newRandomRecipient(): RecipientSecret { - return `recipientSecret_z${base58.encode(x25519.utils.randomPrivateKey())}`; +export function newRandomSealer(): SealerSecret { + return `sealerSecret_z${base58.encode(x25519.utils.randomPrivateKey())}`; } -export function recipientSecretToBytes(secret: RecipientSecret): Uint8Array { - return base58.decode(secret.substring("recipientSecret_z".length)); +export function sealerSecretToBytes(secret: SealerSecret): Uint8Array { + return base58.decode(secret.substring("sealerSecret_z".length)); } -export function recipientSecretFromBytes(bytes: Uint8Array): RecipientSecret { - return `recipientSecret_z${base58.encode(bytes)}`; +export function sealerSecretFromBytes(bytes: Uint8Array): SealerSecret { + return `sealerSecret_z${base58.encode(bytes)}`; } -export function getRecipientID(secret: RecipientSecret): RecipientID { - return `recipient_z${base58.encode( +export function getSealerID(secret: SealerSecret): SealerID { + return `sealer_z${base58.encode( x25519.getPublicKey( - base58.decode(secret.substring("recipientSecret_z".length)) + base58.decode(secret.substring("sealerSecret_z".length)) ) )}`; } export function newRandomAgentSecret(): AgentSecret { - return `${newRandomRecipient()}/${newRandomSignatory()}`; + return `${newRandomSealer()}/${newRandomSigner()}`; } export function agentSecretToBytes(secret: AgentSecret): Uint8Array { - const [recipientSecret, signatorySecret] = secret.split("/"); + const [sealerSecret, signerSecret] = secret.split("/"); return new Uint8Array([ - ...recipientSecretToBytes(recipientSecret as RecipientSecret), - ...signatorySecretToBytes(signatorySecret as SignatorySecret), + ...sealerSecretToBytes(sealerSecret as SealerSecret), + ...signerSecretToBytes(signerSecret as SignerSecret), ]); } export function agentSecretFromBytes(bytes: Uint8Array): AgentSecret { - const recipientSecret = recipientSecretFromBytes( + const sealerSecret = sealerSecretFromBytes( bytes.slice(0, 32) ); - const signatorySecret = signatorySecretFromBytes( + const signerSecret = signerSecretFromBytes( bytes.slice(32) ); - return `${recipientSecret}/${signatorySecret}`; + return `${sealerSecret}/${signerSecret}`; } export function getAgentID(secret: AgentSecret): AgentID { - const [recipientSecret, signatorySecret] = secret.split("/"); - return `${getRecipientID( - recipientSecret as RecipientSecret - )}/${getSignatoryID(signatorySecret as SignatorySecret)}`; + const [sealerSecret, signerSecret] = secret.split("/"); + return `${getSealerID( + sealerSecret as SealerSecret + )}/${getSignerID(signerSecret as SignerSecret)}`; } -export function getAgentSignatoryID(agentId: AgentID): SignatoryID { - return agentId.split("/")[1] as SignatoryID; +export function getAgentSignerID(agentId: AgentID): SignerID { + return agentId.split("/")[1] as SignerID; } -export function getAgentSignatorySecret(agentSecret: AgentSecret): SignatorySecret { - return agentSecret.split("/")[1] as SignatorySecret; +export function getAgentSignerSecret(agentSecret: AgentSecret): SignerSecret { + return agentSecret.split("/")[1] as SignerSecret; } -export function getAgentRecipientID(agentId: AgentID): RecipientID { - return agentId.split("/")[0] as RecipientID; +export function getAgentSealerID(agentId: AgentID): SealerID { + return agentId.split("/")[0] as SealerID; } -export function getAgentRecipientSecret(agentSecret: AgentSecret): RecipientSecret { - return agentSecret.split("/")[0] as RecipientSecret; +export function getAgentSealerSecret(agentSecret: AgentSecret): SealerSecret { + return agentSecret.split("/")[0] as SealerSecret; } export function seal( message: T, - from: RecipientSecret, - to: RecipientID, + from: SealerSecret, + to: SealerID, nOnceMaterial: { in: RawCoValueID; tx: TransactionID } ): Sealed { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) ).slice(0, 24); - const recipientPub = base58.decode(to.substring("recipient_z".length)); + const sealerPub = base58.decode(to.substring("sealer_z".length)); const senderPriv = base58.decode( - from.substring("recipientSecret_z".length) + from.substring("sealerSecret_z".length) ); const plaintext = textEncoder.encode(stableStringify(message)); const sharedSecret = x25519.getSharedSecret( senderPriv, - recipientPub + sealerPub ); const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt( @@ -161,23 +161,23 @@ export function seal( export function unseal( sealed: Sealed, - recipient: RecipientSecret, - from: RecipientID, + sealer: SealerSecret, + from: SealerID, nOnceMaterial: { in: RawCoValueID; tx: TransactionID } ): T | undefined { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) ).slice(0, 24); - const recipientPriv = base58.decode( - recipient.substring("recipientSecret_z".length) + const sealerPriv = base58.decode( + sealer.substring("sealerSecret_z".length) ); - const senderPub = base58.decode(from.substring("recipient_z".length)); + const senderPub = base58.decode(from.substring("sealer_z".length)); const sealedBytes = base64url.decode(sealed.substring("sealed_U".length)); - const sharedSecret = x25519.getSharedSecret(recipientPriv, senderPub); + const sharedSecret = x25519.getSharedSecret(sealerPriv, senderPub); const plaintext = xsalsa20_poly1305(sharedSecret, nOnce).decrypt( sealedBytes diff --git a/src/ids.ts b/src/ids.ts index 39f11d26b..de15bddc1 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -4,10 +4,10 @@ export type RawCoValueID = `co_z${string}` | `co_${string}_z${string}`; export type TransactionID = { sessionID: SessionID; txIndex: number }; -export type AgentID = `recipient_z${string}/signatory_z${string}`; +export type AgentID = `sealer_z${string}/signer_z${string}`; export function isAgentID(id: string): id is AgentID { - return typeof id === "string" && id.startsWith("recipient_") && id.includes("/signatory_"); + return typeof id === "string" && id.startsWith("sealer_") && id.includes("/signer_"); } export type SessionID = `${AccountIDOrAgentID}_session_z${string}`; diff --git a/src/node.ts b/src/node.ts index 538af74da..8ed08b7f3 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,8 +1,8 @@ import { createdNowUnique, getAgentID, - getAgentRecipientID, - getAgentRecipientSecret, + getAgentSealerID, + getAgentSealerSecret, newRandomAgentSecret, newRandomKeySecret, seal, @@ -101,8 +101,8 @@ export class LocalNode { `${readKey.id}_for_${getAgentID(agentSecret)}`, seal( readKey.secret, - getAgentRecipientSecret(agentSecret), - getAgentRecipientID(getAgentID(agentSecret)), + getAgentSealerSecret(agentSecret), + getAgentSealerID(getAgentID(agentSecret)), { in: account.id, tx: account.nextTransactionID(), @@ -168,8 +168,8 @@ export class LocalNode { `${readKey.id}_for_${this.account.id}`, seal( readKey.secret, - this.account.currentRecipientSecret(), - this.account.currentRecipientID(), + this.account.currentSealerSecret(), + this.account.currentSealerID(), { in: teamCoValue.id, tx: teamCoValue.nextTransactionID(), diff --git a/src/permissions.test.ts b/src/permissions.test.ts index ed7c830aa..8f8ada28e 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -4,7 +4,7 @@ import { expectMap } from "./contentType.js"; import { expectTeamContent } from "./permissions.js"; import { createdNowUnique, - getRecipientID, + getSealerID, newRandomKeySecret, seal, encryptKeySecret, @@ -423,8 +423,8 @@ test("Admins can set team read key and then use it to create and read private tr const { secret: readKey, id: readKeyID } = newRandomKeySecret(); const revelation = seal( readKey, - admin.currentRecipientSecret(), - admin.currentRecipientID(), + admin.currentSealerSecret(), + admin.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -491,8 +491,8 @@ test("Admins can set team read key and then writers can use it to create and rea const revelation1 = seal( readKey, - admin.currentRecipientSecret(), - admin.currentRecipientID(), + admin.currentSealerSecret(), + admin.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -503,8 +503,8 @@ test("Admins can set team read key and then writers can use it to create and rea const revelation2 = seal( readKey, - admin.currentRecipientSecret(), - writer.currentRecipientID(), + admin.currentSealerSecret(), + writer.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -583,8 +583,8 @@ test("Admins can set team read key and then use it to create private transaction const revelation1 = seal( readKey, - admin.currentRecipientSecret(), - admin.currentRecipientID(), + admin.currentSealerSecret(), + admin.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -595,8 +595,8 @@ test("Admins can set team read key and then use it to create private transaction const revelation2 = seal( readKey, - admin.currentRecipientSecret(), - reader.currentRecipientID(), + admin.currentSealerSecret(), + reader.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -675,8 +675,8 @@ test("Admins can set team read key and then use it to create private transaction const revelation1 = seal( readKey, - admin.currentRecipientSecret(), - admin.currentRecipientID(), + admin.currentSealerSecret(), + admin.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -687,8 +687,8 @@ test("Admins can set team read key and then use it to create private transaction const revelation2 = seal( readKey, - admin.currentRecipientSecret(), - reader1.currentRecipientID(), + admin.currentSealerSecret(), + reader1.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -729,8 +729,8 @@ test("Admins can set team read key and then use it to create private transaction teamContent.edit((editable) => { const revelation3 = seal( readKey, - admin.currentRecipientSecret(), - reader2.currentRecipientID(), + admin.currentSealerSecret(), + reader2.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -798,8 +798,8 @@ test("Admins can set team read key, make a private transaction in an owned objec const { secret: readKey, id: readKeyID } = newRandomKeySecret(); const revelation = seal( readKey, - admin.currentRecipientSecret(), - admin.currentRecipientID(), + admin.currentSealerSecret(), + admin.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -836,8 +836,8 @@ test("Admins can set team read key, make a private transaction in an owned objec const revelation = seal( readKey2, - admin.currentRecipientSecret(), - admin.currentRecipientID(), + admin.currentSealerSecret(), + admin.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -907,8 +907,8 @@ test("Admins can set team read key, make a private transaction in an owned objec teamContent.edit((editable) => { const revelation = seal( readKey, - admin.currentRecipientSecret(), - admin.currentRecipientID(), + admin.currentSealerSecret(), + admin.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -939,8 +939,8 @@ test("Admins can set team read key, make a private transaction in an owned objec teamContent.edit((editable) => { const revelation2 = seal( readKey2, - admin.currentRecipientSecret(), - admin.currentRecipientID(), + admin.currentSealerSecret(), + admin.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -951,8 +951,8 @@ test("Admins can set team read key, make a private transaction in an owned objec const revelation3 = seal( readKey2, - admin.currentRecipientSecret(), - reader.currentRecipientID(), + admin.currentSealerSecret(), + reader.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -1056,8 +1056,8 @@ test("Admins can set team read rey, make a private transaction in an owned objec teamContent.edit((editable) => { const revelation1 = seal( readKey, - admin.currentRecipientSecret(), - admin.currentRecipientID(), + admin.currentSealerSecret(), + admin.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -1068,8 +1068,8 @@ test("Admins can set team read rey, make a private transaction in an owned objec const revelation2 = seal( readKey, - admin.currentRecipientSecret(), - reader.currentRecipientID(), + admin.currentSealerSecret(), + reader.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -1080,8 +1080,8 @@ test("Admins can set team read rey, make a private transaction in an owned objec const revelation3 = seal( readKey, - admin.currentRecipientSecret(), - reader2.currentRecipientID(), + admin.currentSealerSecret(), + reader2.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -1133,8 +1133,8 @@ test("Admins can set team read rey, make a private transaction in an owned objec teamContent.edit((editable) => { const newRevelation1 = seal( readKey2, - admin.currentRecipientSecret(), - admin.currentRecipientID(), + admin.currentSealerSecret(), + admin.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), @@ -1149,8 +1149,8 @@ test("Admins can set team read rey, make a private transaction in an owned objec const newRevelation2 = seal( readKey2, - admin.currentRecipientSecret(), - reader2.currentRecipientID(), + admin.currentSealerSecret(), + reader2.currentSealerID(), { in: team.id, tx: team.nextTransactionID(), diff --git a/src/permissions.ts b/src/permissions.ts index e36c0a101..1a2e3a391 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -9,7 +9,7 @@ import { newRandomKeySecret, seal, encryptKeySecret, - getAgentRecipientID, + getAgentSealerID, Sealed, } from "./crypto.js"; import { @@ -260,8 +260,8 @@ export class Team { `${currentReadKey.id}_for_${accountID}`, seal( currentReadKey.secret, - this.teamMap.coValue.node.account.currentRecipientSecret(), - getAgentRecipientID(agent), + this.teamMap.coValue.node.account.currentSealerSecret(), + getAgentSealerID(agent), { in: this.teamMap.coValue.id, tx: this.teamMap.coValue.nextTransactionID(), @@ -310,8 +310,8 @@ export class Team { `${newReadKey.id}_for_${readerID}`, seal( newReadKey.secret, - this.teamMap.coValue.node.account.currentRecipientSecret(), - getAgentRecipientID(reader), + this.teamMap.coValue.node.account.currentSealerSecret(), + getAgentSealerID(reader), { in: this.teamMap.coValue.id, tx: this.teamMap.coValue.nextTransactionID(), @@ -379,5 +379,5 @@ export function isKeyForKeyField(field: string): field is `${KeyID}_for_${KeyID} } export function isKeyForAccountField(field: string): field is `${KeyID}_for_${AccountIDOrAgentID}` { - return field.startsWith("key_") && (field.includes("_for_recipient") || field.includes("_for_co")); + return field.startsWith("key_") && (field.includes("_for_sealer") || field.includes("_for_co")); } \ No newline at end of file From 813767145eec198e4d86cd76a4254944627299ce Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 14 Aug 2023 17:47:21 +0100 Subject: [PATCH 40/47] Get rid of public nickname feature --- src/coValue.ts | 9 +-------- src/crypto.test.ts | 18 +++++++++--------- src/ids.ts | 2 +- src/node.ts | 1 - src/permissions.test.ts | 10 ---------- src/permissions.ts | 1 - src/sync.test.ts | 3 +-- src/testUtils.ts | 1 - 8 files changed, 12 insertions(+), 33 deletions(-) diff --git a/src/coValue.ts b/src/coValue.ts index f79b9ce00..ea0b8f57e 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -45,18 +45,11 @@ export type CoValueHeader = { meta: JsonObject | null; createdAt: `2${string}` | null; uniqueness: `z${string}` | null; - publicNickname?: string; }; export function coValueIDforHeader(header: CoValueHeader): RawCoValueID { const hash = shortHash(header); - if (header.publicNickname) { - return `co_${header.publicNickname}_z${hash.slice( - "shortHash_z".length - )}`; - } else { - return `co_z${hash.slice("shortHash_z".length)}`; - } + return `co_z${hash.slice("shortHash_z".length)}`; } export function accountOrAgentIDfromSessionID( diff --git a/src/crypto.test.ts b/src/crypto.test.ts index 3b31afafa..2248d6b58 100644 --- a/src/crypto.test.ts +++ b/src/crypto.test.ts @@ -49,7 +49,7 @@ test("encrypting round-trips, but invalid receiver can't unseal", () => { const nOnceMaterial = { in: "co_zTEST", - tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 }, + tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 }, } as const; const sealed = seal( @@ -101,22 +101,22 @@ test("Encryption for transactions round-trips", () => { const encrypted1 = encryptForTransaction({ a: "hello" }, secret, { in: "co_zTEST", - tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 }, + tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 }, }); const encrypted2 = encryptForTransaction({ b: "world" }, secret, { in: "co_zTEST", - tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 }, + tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 }, }); const decrypted1 = decryptForTransaction(encrypted1, secret, { in: "co_zTEST", - tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 }, + tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 }, }); const decrypted2 = decryptForTransaction(encrypted2, secret, { in: "co_zTEST", - tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 }, + tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 }, }); expect([decrypted1, decrypted2]).toEqual([{ a: "hello" }, { b: "world" }]); @@ -128,22 +128,22 @@ test("Encryption for transactions doesn't decrypt with a wrong key", () => { const encrypted1 = encryptForTransaction({ a: "hello" }, secret, { in: "co_zTEST", - tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 }, + tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 }, }); const encrypted2 = encryptForTransaction({ b: "world" }, secret, { in: "co_zTEST", - tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 }, + tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 }, }); const decrypted1 = decryptForTransaction(encrypted1, secret2, { in: "co_zTEST", - tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 0 }, + tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 }, }); const decrypted2 = decryptForTransaction(encrypted2, secret2, { in: "co_zTEST", - tx: { sessionID: "co_agent_zTEST_session_zTEST", txIndex: 1 }, + tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 }, }); expect([decrypted1, decrypted2]).toEqual([undefined, undefined]); diff --git a/src/ids.ts b/src/ids.ts index de15bddc1..bceba43aa 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -1,6 +1,6 @@ import { AccountIDOrAgentID } from './account.js'; -export type RawCoValueID = `co_z${string}` | `co_${string}_z${string}`; +export type RawCoValueID = `co_z${string}`; export type TransactionID = { sessionID: SessionID; txIndex: number }; diff --git a/src/node.ts b/src/node.ts index 8ed08b7f3..153bae8a2 100644 --- a/src/node.ts +++ b/src/node.ts @@ -154,7 +154,6 @@ export class LocalNode { ruleset: { type: "team", initialAdmin: this.account.id }, meta: null, ...createdNowUnique(), - publicNickname: "team", }); let teamContent = expectTeamContent(teamCoValue.getCurrentContent()); diff --git a/src/permissions.test.ts b/src/permissions.test.ts index 8f8ada28e..9c07956e0 100644 --- a/src/permissions.test.ts +++ b/src/permissions.test.ts @@ -266,7 +266,6 @@ test("Admins can write to an object that is owned by their team", () => { ruleset: { type: "ownedByTeam", team: team.id }, meta: null, ...createdNowUnique(), - publicNickname: "childObject", }); let childContent = expectMap(childObject.getCurrentContent()); @@ -309,7 +308,6 @@ test("Writers can write to an object that is owned by their team", () => { ruleset: { type: "ownedByTeam", team: team.id }, meta: null, ...createdNowUnique(), - publicNickname: "childObject", }); const childObjectAsWriter = childObject.testWithDifferentAccount( @@ -369,7 +367,6 @@ test("Readers can not write to an object that is owned by their team", () => { ruleset: { type: "ownedByTeam", team: team.id }, meta: null, ...createdNowUnique(), - publicNickname: "childObject", }); const childObjectAsReader = childObject.testWithDifferentAccount( @@ -449,7 +446,6 @@ test("Admins can set team read key and then use it to create and read private tr ruleset: { type: "ownedByTeam", team: team.id }, meta: null, ...createdNowUnique(), - publicNickname: "childObject", }); let childContent = expectMap(childObject.getCurrentContent()); @@ -521,7 +517,6 @@ test("Admins can set team read key and then writers can use it to create and rea ruleset: { type: "ownedByTeam", team: team.id }, meta: null, ...createdNowUnique(), - publicNickname: "childObject", }); const childObjectAsWriter = childObject.testWithDifferentAccount( @@ -613,7 +608,6 @@ test("Admins can set team read key and then use it to create private transaction ruleset: { type: "ownedByTeam", team: team.id }, meta: null, ...createdNowUnique(), - publicNickname: "childObject", }); expectMap(childObject.getCurrentContent()).edit((editable) => { @@ -705,7 +699,6 @@ test("Admins can set team read key and then use it to create private transaction ruleset: { type: "ownedByTeam", team: team.id }, meta: null, ...createdNowUnique(), - publicNickname: "childObject", }); expectMap(childObject.getCurrentContent()).edit((editable) => { @@ -818,7 +811,6 @@ test("Admins can set team read key, make a private transaction in an owned objec ruleset: { type: "ownedByTeam", team: team.id }, meta: null, ...createdNowUnique(), - publicNickname: "childObject", }); let childContent = expectMap(childObject.getCurrentContent()); @@ -898,7 +890,6 @@ test("Admins can set team read key, make a private transaction in an owned objec ruleset: { type: "ownedByTeam", team: team.id }, meta: null, ...createdNowUnique(), - publicNickname: "childObject", }); const teamContent = expectTeamContent(team.getCurrentContent()); @@ -1044,7 +1035,6 @@ test("Admins can set team read rey, make a private transaction in an owned objec ruleset: { type: "ownedByTeam", team: team.id }, meta: null, ...createdNowUnique(), - publicNickname: "childObject", }); const teamContent = expectTeamContent(team.getCurrentContent()); diff --git a/src/permissions.ts b/src/permissions.ts index 1a2e3a391..948d4b558 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -354,7 +354,6 @@ export class Team { }, meta: meta || null, ...createdNowUnique(), - publicNickname: "map", }) .getCurrentContent() as CoMap; } diff --git a/src/sync.test.ts b/src/sync.test.ts index 874fd4bdd..eb01be4a9 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -68,7 +68,6 @@ test("Node replies with initial tx and header to empty subscribe", async () => { meta: null, createdAt: map.coValue.header.createdAt, uniqueness: map.coValue.header.uniqueness, - publicNickname: "map", }, newContent: { [node.ownSessionID]: { @@ -1062,7 +1061,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async }); 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, session] = randomAnonymousAccountAndSessionID(); + const [admin, session] = randomAnonymousAccountAndSessionID(); const node1 = new LocalNode(admin, session); diff --git a/src/testUtils.ts b/src/testUtils.ts index 9102e1b58..1264798a5 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -23,7 +23,6 @@ export function newTeam() { ruleset: { type: "team", initialAdmin: admin.id }, meta: null, ...createdNowUnique(), - publicNickname: "team", }); const teamContent = expectTeamContent(team.getCurrentContent()); From 823fe0b2a74d5eef746193789390338017d946a2 Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 14 Aug 2023 18:00:56 +0100 Subject: [PATCH 41/47] Rename CoValueID to CoID --- README.md | 2 +- src/account.ts | 4 +- src/coValue.ts | 16 ++--- src/contentType.ts | 4 +- src/contentTypes/coList.ts | 6 +- src/contentTypes/coMap.ts | 6 +- src/contentTypes/coStream.ts | 6 +- src/contentTypes/static.ts | 6 +- src/crypto.ts | 14 ++--- src/ids.ts | 2 +- src/index.ts | 4 +- src/jsonValue.ts | 4 +- src/node.ts | 12 ++-- src/permissions.ts | 8 +-- src/sync.test.ts | 74 +++++++++++----------- src/sync.ts | 116 +++++++++++++++++------------------ 16 files changed, 142 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index bc124440b..13f65fee6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ THIS IS WORK IN PROGRESS - boolean - number - string - - stringly-encoded CoJSON identifiers & data (`CoValueID`, `AgentID`, `SessionID`, `SignerID`, `SignerSecret`, `Signature`, `SealerID`, `SealerSecret`, `Sealed`, `Hash`, `ShortHash`, `KeySecret`, `KeyID`, `Encrypted`, `Role`) + - stringly-encoded CoJSON identifiers & data (`CoID`, `AgentID`, `SessionID`, `SignerID`, `SignerSecret`, `Signature`, `SealerID`, `SealerSecret`, `Sealed`, `Hash`, `ShortHash`, `KeySecret`, `KeyID`, `Encrypted`, `Role`) - array - object diff --git a/src/account.ts b/src/account.ts index bdd012c28..03010d4f6 100644 --- a/src/account.ts +++ b/src/account.ts @@ -1,5 +1,5 @@ import { CoValueHeader } from './coValue.js'; -import { CoValueID } from './contentType.js'; +import { CoID } from './contentType.js'; import { AgentSecret, SealerID, SealerSecret, SignerID, SignerSecret, getAgentID, getAgentSealerID, getAgentSealerSecret, getAgentSignerID, getAgentSignerSecret } from './crypto.js'; import { AgentID } from './ids.js'; import { CoMap, LocalNode } from './index.js'; @@ -108,7 +108,7 @@ export class AnonymousControlledAccount implements GeneralizedControlledAccount } export type AccountMeta = {type: "account"}; -export type AccountID = CoValueID>; +export type AccountID = CoID>; export type AccountIDOrAgentID = AgentID | AccountID; export type AccountOrAgentID = AgentID | Account; diff --git a/src/coValue.ts b/src/coValue.ts index ea0b8f57e..f440002c2 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -31,7 +31,7 @@ import { } from "./permissions.js"; import { LocalNode } from "./node.js"; import { CoValueKnownState, NewContentMessage } from "./sync.js"; -import { RawCoValueID, SessionID, TransactionID } from "./ids.js"; +import { RawCoID, SessionID, TransactionID } from "./ids.js"; import { CoList } from "./contentTypes/coList.js"; import { AccountID, @@ -47,7 +47,7 @@ export type CoValueHeader = { uniqueness: `z${string}` | null; }; -export function coValueIDforHeader(header: CoValueHeader): RawCoValueID { +export function idforHeader(header: CoValueHeader): RawCoID { const hash = shortHash(header); return `co_z${hash.slice("shortHash_z".length)}`; } @@ -75,7 +75,7 @@ export type PrivateTransaction = { keyUsed: KeyID; encryptedChanges: Encrypted< JsonValue[], - { in: RawCoValueID; tx: TransactionID } + { in: RawCoID; tx: TransactionID } >; }; @@ -94,7 +94,7 @@ export type DecryptedTransaction = { }; export class CoValue { - id: RawCoValueID; + id: RawCoID; node: LocalNode; header: CoValueHeader; sessions: { [key: SessionID]: SessionLog }; @@ -102,7 +102,7 @@ export class CoValue { listeners: Set<(content?: ContentType) => void> = new Set(); constructor(header: CoValueHeader, node: LocalNode) { - this.id = coValueIDforHeader(header); + this.id = idforHeader(header); this.header = header; this.sessions = {}; this.node = node; @@ -122,7 +122,7 @@ export class CoValue { knownState(): CoValueKnownState { return { - coValueID: this.id, + id: this.id, header: true, sessions: Object.fromEntries( Object.entries(this.sessions).map(([k, v]) => [ @@ -492,7 +492,7 @@ export class CoValue { ): NewContentMessage | undefined { const newContent: NewContentMessage = { action: "newContent", - coValueID: this.id, + id: this.id, header: knownState?.header ? undefined : this.header, newContent: Object.fromEntries( Object.entries(this.sessions) @@ -536,7 +536,7 @@ export class CoValue { return newContent; } - getDependedOnCoValues(): RawCoValueID[] { + getDependedOnCoValues(): RawCoID[] { return this.header.ruleset.type === "team" ? expectTeamContent(this.getCurrentContent()) .keys() diff --git a/src/contentType.ts b/src/contentType.ts index 7c3d9529a..0909c7fc0 100644 --- a/src/contentType.ts +++ b/src/contentType.ts @@ -1,11 +1,11 @@ import { JsonValue } from './jsonValue.js'; -import { RawCoValueID } from './ids.js'; +import { RawCoID } from './ids.js'; import { CoMap } from './contentTypes/coMap.js'; import { CoStream } from './contentTypes/coStream.js'; import { Static } from './contentTypes/static.js'; import { CoList } from './contentTypes/coList.js'; -export type CoValueID = RawCoValueID & { +export type CoID = RawCoID & { readonly __type: T; }; diff --git a/src/contentTypes/coList.ts b/src/contentTypes/coList.ts index 8dad1b52e..41c4d60e1 100644 --- a/src/contentTypes/coList.ts +++ b/src/contentTypes/coList.ts @@ -1,14 +1,14 @@ import { JsonObject, JsonValue } from '../jsonValue.js'; -import { CoValueID } from '../contentType.js'; +import { CoID } from '../contentType.js'; import { CoValue } from '../coValue.js'; export class CoList { - id: CoValueID>; + id: CoID>; type = "colist" as const; coValue: CoValue; constructor(coValue: CoValue) { - this.id = coValue.id as CoValueID>; + this.id = coValue.id as CoID>; this.coValue = coValue; } diff --git a/src/contentTypes/coMap.ts b/src/contentTypes/coMap.ts index 2454d0213..523030700 100644 --- a/src/contentTypes/coMap.ts +++ b/src/contentTypes/coMap.ts @@ -1,6 +1,6 @@ import { JsonObject, JsonValue } from '../jsonValue.js'; import { TransactionID } from '../ids.js'; -import { CoValueID } from '../contentType.js'; +import { CoID } from '../contentType.js'; import { CoValue } from '../coValue.js'; type MapOp = { @@ -29,7 +29,7 @@ export class CoMap< [KK in K]: M[KK]; } > { - id: CoValueID>; + id: CoID>; coValue: CoValue; type = "comap" as const; ops: { @@ -37,7 +37,7 @@ export class CoMap< }; constructor(coValue: CoValue) { - this.id = coValue.id as CoValueID>; + this.id = coValue.id as CoID>; this.coValue = coValue; this.ops = {}; diff --git a/src/contentTypes/coStream.ts b/src/contentTypes/coStream.ts index daa040d91..52ddd481e 100644 --- a/src/contentTypes/coStream.ts +++ b/src/contentTypes/coStream.ts @@ -1,14 +1,14 @@ import { JsonObject, JsonValue } from '../jsonValue.js'; -import { CoValueID } from '../contentType.js'; +import { CoID } from '../contentType.js'; import { CoValue } from '../coValue.js'; export class CoStream { - id: CoValueID>; + id: CoID>; type = "costream" as const; coValue: CoValue; constructor(coValue: CoValue) { - this.id = coValue.id as CoValueID>; + this.id = coValue.id as CoID>; this.coValue = coValue; } diff --git a/src/contentTypes/static.ts b/src/contentTypes/static.ts index fafc82e57..ffa815e66 100644 --- a/src/contentTypes/static.ts +++ b/src/contentTypes/static.ts @@ -1,14 +1,14 @@ import { JsonObject, JsonValue } from '../jsonValue.js'; -import { CoValueID } from '../contentType.js'; +import { CoID } from '../contentType.js'; import { CoValue } from '../coValue.js'; export class Static { - id: CoValueID>; + id: CoID>; type = "static" as const; coValue: CoValue; constructor(coValue: CoValue) { - this.id = coValue.id as CoValueID>; + this.id = coValue.id as CoID>; this.coValue = coValue; } diff --git a/src/crypto.ts b/src/crypto.ts index 89f53b490..f3cdb03f9 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -5,7 +5,7 @@ import { base58, base64url } from "@scure/base"; import stableStringify from "fast-json-stable-stringify"; import { blake3 } from "@noble/hashes/blake3"; import { randomBytes } from "@noble/ciphers/webcrypto/utils"; -import { AgentID, RawCoValueID, TransactionID } from './ids.js'; +import { AgentID, RawCoID, TransactionID } from './ids.js'; export type SignerSecret = `signerSecret_z${string}`; export type SignerID = `signer_z${string}`; @@ -131,7 +131,7 @@ export function seal( message: T, from: SealerSecret, to: SealerID, - nOnceMaterial: { in: RawCoValueID; tx: TransactionID } + nOnceMaterial: { in: RawCoID; tx: TransactionID } ): Sealed { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) @@ -163,7 +163,7 @@ export function unseal( sealed: Sealed, sealer: SealerSecret, from: SealerID, - nOnceMaterial: { in: RawCoValueID; tx: TransactionID } + nOnceMaterial: { in: RawCoID; tx: TransactionID } ): T | undefined { const nOnce = blake3( textEncoder.encode(stableStringify(nOnceMaterial)) @@ -263,8 +263,8 @@ function encrypt( export function encryptForTransaction( value: T, keySecret: KeySecret, - nOnceMaterial: { in: RawCoValueID; tx: TransactionID } -): Encrypted { + nOnceMaterial: { in: RawCoID; tx: TransactionID } +): Encrypted { return encrypt(value, keySecret, nOnceMaterial); } @@ -317,9 +317,9 @@ function decrypt( } export function decryptForTransaction( - encrypted: Encrypted, + encrypted: Encrypted, keySecret: KeySecret, - nOnceMaterial: { in: RawCoValueID; tx: TransactionID } + nOnceMaterial: { in: RawCoID; tx: TransactionID } ): T | undefined { return decrypt(encrypted, keySecret, nOnceMaterial); } diff --git a/src/ids.ts b/src/ids.ts index bceba43aa..d34a2a474 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -1,6 +1,6 @@ import { AccountIDOrAgentID } from './account.js'; -export type RawCoValueID = `co_z${string}`; +export type RawCoID = `co_z${string}`; export type TransactionID = { sessionID: SessionID; txIndex: number }; diff --git a/src/index.ts b/src/index.ts index 4fcfcae34..b8709ed02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { CoMap } from "./contentTypes/coMap.js"; import { agentSecretFromBytes, agentSecretToBytes } from "./crypto.js"; import type { SessionID } from "./ids.js"; -import type { CoValueID, ContentType } from "./contentType.js"; +import type { CoID, ContentType } from "./contentType.js"; import type { JsonValue } from "./jsonValue.js"; import type { SyncMessage } from "./sync.js"; import type { AgentSecret } from "./crypto.js"; @@ -23,7 +23,7 @@ export type { Value, JsonValue, ContentType, - CoValueID, + CoID, AgentSecret, SessionID, SyncMessage, diff --git a/src/jsonValue.ts b/src/jsonValue.ts index 6a0ebf747..1c2474080 100644 --- a/src/jsonValue.ts +++ b/src/jsonValue.ts @@ -1,6 +1,6 @@ -import { CoValueID, ContentType } from './contentType.js'; +import { CoID, ContentType } from './contentType.js'; export type JsonAtom = string | number | boolean | null; -export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID; +export type JsonValue = JsonAtom | JsonArray | JsonObject | CoID; export type JsonArray = JsonValue[]; export type JsonObject = { [key: string]: JsonValue; }; diff --git a/src/node.ts b/src/node.ts index 153bae8a2..69f5d22ac 100644 --- a/src/node.ts +++ b/src/node.ts @@ -10,8 +10,8 @@ import { 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 { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js"; +import { CoID, ContentType } from "./contentType.js"; import { Account, AccountMeta, @@ -24,7 +24,7 @@ import { import { CoMap } from "./index.js"; export class LocalNode { - coValues: { [key: RawCoValueID]: CoValueState } = {}; + coValues: { [key: RawCoID]: CoValueState } = {}; account: GeneralizedControlledAccount; ownSessionID: SessionID; sync = new SyncManager(this); @@ -46,7 +46,7 @@ export class LocalNode { return coValue; } - loadCoValue(id: RawCoValueID): Promise { + loadCoValue(id: RawCoID): Promise { let entry = this.coValues[id]; if (!entry) { entry = newLoadingState(); @@ -61,11 +61,11 @@ export class LocalNode { return entry.done; } - async load(id: CoValueID): Promise { + async load(id: CoID): Promise { return (await this.loadCoValue(id)).getCurrentContent() as T; } - expectCoValueLoaded(id: RawCoValueID, expectation?: string): CoValue { + expectCoValueLoaded(id: RawCoID, expectation?: string): CoValue { const entry = this.coValues[id]; if (!entry) { throw new Error( diff --git a/src/permissions.ts b/src/permissions.ts index 948d4b558..d130ca033 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,4 +1,4 @@ -import { CoValueID, ContentType } from "./contentType.js"; +import { CoID, ContentType } from "./contentType.js"; import { CoMap, MapOpPayload } from "./contentTypes/coMap.js"; import { JsonValue } from "./jsonValue.js"; import { @@ -19,12 +19,12 @@ import { accountOrAgentIDfromSessionID, } from "./coValue.js"; import { LocalNode } from "./node.js"; -import { RawCoValueID, SessionID, TransactionID, isAgentID } from "./ids.js"; +import { RawCoID, SessionID, TransactionID, isAgentID } from "./ids.js"; import { AccountIDOrAgentID, GeneralizedControlledAccount } from "./account.js"; export type PermissionsDef = | { type: "team"; initialAdmin: AccountIDOrAgentID } - | { type: "ownedByTeam"; team: RawCoValueID } + | { type: "ownedByTeam"; team: RawCoID } | { type: "unsafeAllowAll" }; export type Role = "reader" | "writer" | "admin" | "revoked"; @@ -233,7 +233,7 @@ export class Team { this.node = node; } - get id(): CoValueID> { + get id(): CoID> { return this.teamMap.id; } diff --git a/src/sync.test.ts b/src/sync.test.ts index eb01be4a9..1d6528172 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -38,7 +38,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => { await writer.write({ action: "subscribe", - coValueID: map.coValue.id, + id: map.coValue.id, header: false, sessions: {}, }); @@ -61,7 +61,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => { expect(newContentMsg.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, header: { type: "comap", ruleset: { type: "ownedByTeam", team: team.id }, @@ -121,7 +121,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( await writer.write({ action: "subscribe", - coValueID: map.coValue.id, + id: map.coValue.id, header: true, sessions: { [node.ownSessionID]: 1, @@ -146,7 +146,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( expect(mapNewContentMsg.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, header: undefined, newContent: { [node.ownSessionID]: { @@ -199,7 +199,7 @@ test("After subscribing, node sends own known state and new txs to peer", async await writer.write({ action: "subscribe", - coValueID: map.coValue.id, + id: map.coValue.id, header: false, sessions: { [node.ownSessionID]: 0, @@ -224,7 +224,7 @@ test("After subscribing, node sends own known state and new txs to peer", async expect(mapNewContentHeaderOnlyMsg.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, header: map.coValue.header, newContent: {}, } satisfies SyncMessage); @@ -237,7 +237,7 @@ test("After subscribing, node sends own known state and new txs to peer", async expect(mapEditMsg1.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, newContent: { [node.ownSessionID]: { after: 0, @@ -270,7 +270,7 @@ test("After subscribing, node sends own known state and new txs to peer", async expect(mapEditMsg2.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, newContent: { [node.ownSessionID]: { after: 1, @@ -326,7 +326,7 @@ test("Client replies with known new content to tellKnownState from server", asyn await writer.write({ action: "tellKnownState", - coValueID: map.coValue.id, + id: map.coValue.id, header: false, sessions: { [node.ownSessionID]: 0, @@ -349,7 +349,7 @@ test("Client replies with known new content to tellKnownState from server", asyn expect(mapNewContentMsg.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, header: map.coValue.header, newContent: { [node.ownSessionID]: { @@ -398,7 +398,7 @@ test("No matter the optimistic known state, node respects invalid known state me await writer.write({ action: "subscribe", - coValueID: map.coValue.id, + id: map.coValue.id, header: false, sessions: { [node.ownSessionID]: 0, @@ -423,7 +423,7 @@ test("No matter the optimistic known state, node respects invalid known state me expect(mapNewContentHeaderOnlyMsg.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, header: map.coValue.header, newContent: {}, } satisfies SyncMessage); @@ -441,7 +441,7 @@ test("No matter the optimistic known state, node respects invalid known state me await writer.write({ action: "wrongAssumedKnownState", - coValueID: map.coValue.id, + id: map.coValue.id, header: true, sessions: { [node.ownSessionID]: 1, @@ -452,7 +452,7 @@ test("No matter the optimistic known state, node respects invalid known state me expect(newContentAfterWrongAssumedState.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, header: undefined, newContent: { [node.ownSessionID]: { @@ -529,18 +529,18 @@ 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, + // id: adminID, // }); expect((await reader.read()).value).toMatchObject({ action: "subscribe", - coValueID: team.teamMap.coValue.id, + id: team.teamMap.coValue.id, }); const mapSubscribeMsg = await reader.read(); expect(mapSubscribeMsg.value).toEqual({ action: "subscribe", - coValueID: map.coValue.id, + id: map.coValue.id, header: true, sessions: {}, } satisfies SyncMessage); @@ -556,7 +556,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even expect(mapNewContentMsg.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, header: map.coValue.header, newContent: { [node.ownSessionID]: { @@ -602,11 +602,11 @@ 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: admin.id, + // id: admin.id, // }); expect((await reader.read()).value).toMatchObject({ action: "subscribe", - coValueID: team.teamMap.coValue.id, + id: team.teamMap.coValue.id, }); const map = team.createMap(); @@ -625,7 +625,7 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a expect(mapContentMsg.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, header: map.coValue.header, newContent: {}, } satisfies SyncMessage); @@ -693,7 +693,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe await writer.write({ action: "subscribe", - coValueID: map.coValue.id, + id: map.coValue.id, header: true, sessions: { [node.ownSessionID]: 1, @@ -751,12 +751,12 @@ 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: admin.id, + id: admin.id, }); const teamSubscribeMsg = await from1.read(); expect(teamSubscribeMsg.value).toMatchObject({ action: "subscribe", - coValueID: team.teamMap.coValue.id, + id: team.teamMap.coValue.id, }); await to2.write(adminSubscribeMessage.value!); @@ -791,13 +791,13 @@ test.skip("When replaying creation and transactions of a coValue as new content, const mapSubscriptionMsg = await from1.read(); expect(mapSubscriptionMsg.value).toMatchObject({ action: "subscribe", - coValueID: map.coValue.id, + id: map.coValue.id, }); const mapNewContentMsg = await from1.read(); expect(mapNewContentMsg.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, header: map.coValue.header, newContent: {}, } satisfies SyncMessage); @@ -807,7 +807,7 @@ test.skip("When replaying creation and transactions of a coValue as new content, const mapTellKnownStateMsg = await from2.read(); expect(mapTellKnownStateMsg.value).toEqual({ action: "tellKnownState", - coValueID: map.coValue.id, + id: map.coValue.id, header: false, sessions: {}, } satisfies SyncMessage); @@ -966,11 +966,11 @@ 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: admin.id, + // id: admin.id, // }); expect((await reader.read()).value).toMatchObject({ action: "subscribe", - coValueID: team.teamMap.coValue.id, + id: team.teamMap.coValue.id, }); const map = team.createMap(); @@ -989,7 +989,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async expect(mapContentMsg.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, header: map.coValue.header, newContent: {}, } satisfies SyncMessage); @@ -1020,11 +1020,11 @@ 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: admin.id, + // id: admin.id, // }); expect((await reader.read()).value).toMatchObject({ action: "subscribe", - coValueID: team.teamMap.coValue.id, + id: team.teamMap.coValue.id, }); const map = team.createMap(); @@ -1043,7 +1043,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async expect(mapContentMsg.value).toEqual({ action: "newContent", - coValueID: map.coValue.id, + id: map.coValue.id, header: map.coValue.header, newContent: {}, } satisfies SyncMessage); @@ -1098,28 +1098,28 @@ test("If we start loading a coValue before connecting to a peer that has it, it function teamContentEx(team: Team) { return { action: "newContent", - coValueID: team.teamMap.coValue.id, + id: team.teamMap.coValue.id, }; } function admContEx(adminID: AccountID) { return { action: "newContent", - coValueID: adminID, + id: adminID, }; } function teamStateEx(team: Team) { return { action: "tellKnownState", - coValueID: team.teamMap.coValue.id, + id: team.teamMap.coValue.id, }; } function admStateEx(adminID: AccountID) { return { action: "tellKnownState", - coValueID: adminID, + id: adminID, }; } diff --git a/src/sync.ts b/src/sync.ts index 042632360..d65a4930c 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -8,17 +8,17 @@ import { WritableStream, WritableStreamDefaultWriter, } from "isomorphic-streams"; -import { RawCoValueID, SessionID } from "./ids.js"; +import { RawCoID, SessionID } from "./ids.js"; export type CoValueKnownState = { - coValueID: RawCoValueID; + id: RawCoID; header: boolean; sessions: { [sessionID: SessionID]: number }; }; -export function emptyKnownState(coValueID: RawCoValueID): CoValueKnownState { +export function emptyKnownState(id: RawCoID): CoValueKnownState { return { - coValueID, + id, header: false, sessions: {}, }; @@ -37,12 +37,12 @@ export type SubscribeMessage = { export type TellKnownStateMessage = { action: "tellKnownState"; - asDependencyOf?: RawCoValueID; + asDependencyOf?: RawCoID; } & CoValueKnownState; export type NewContentMessage = { action: "newContent"; - coValueID: RawCoValueID; + id: RawCoID; header?: CoValueHeader; newContent: { [sessionID: SessionID]: SessionNewContent; @@ -63,7 +63,7 @@ export type WrongAssumedKnownStateMessage = { export type UnsubscribeMessage = { action: "unsubscribe"; - coValueID: RawCoValueID; + id: RawCoID; }; export type PeerID = string; @@ -77,8 +77,8 @@ export interface Peer { export interface PeerState { id: PeerID; - optimisticKnownStates: { [coValueID: RawCoValueID]: CoValueKnownState }; - toldKnownState: Set; + optimisticKnownStates: { [id: RawCoID]: CoValueKnownState }; + toldKnownState: Set; incoming: ReadableStream; outgoing: WritableStreamDefaultWriter; role: "peer" | "server" | "client"; @@ -103,7 +103,7 @@ export function combinedKnownStates( } return { - coValueID: stateA.coValueID, + id: stateA.id, header: stateA.header || stateB.header, sessions: sessionStates, }; @@ -117,12 +117,12 @@ export class SyncManager { this.local = local; } - loadFromPeers(id: RawCoValueID) { + loadFromPeers(id: RawCoID) { for (const peer of Object.values(this.peers)) { peer.outgoing .write({ action: "subscribe", - coValueID: id, + id: id, header: false, sessions: {}, }) @@ -155,10 +155,10 @@ export class SyncManager { } async subscribeToIncludingDependencies( - coValueID: RawCoValueID, + id: RawCoID, peer: PeerState ) { - const entry = this.local.coValues[coValueID]; + const entry = this.local.coValues[id]; if (!entry) { throw new Error( @@ -169,7 +169,7 @@ export class SyncManager { if (entry.state === "loading") { await this.trySendToPeer(peer, { action: "subscribe", - coValueID, + id, header: false, sessions: {}, }); @@ -178,12 +178,12 @@ export class SyncManager { const coValue = entry.coValue; - for (const coValueID of coValue.getDependedOnCoValues()) { - await this.subscribeToIncludingDependencies(coValueID, peer); + for (const id of coValue.getDependedOnCoValues()) { + await this.subscribeToIncludingDependencies(id, peer); } - if (!peer.toldKnownState.has(coValueID)) { - peer.toldKnownState.add(coValueID); + if (!peer.toldKnownState.has(id)) { + peer.toldKnownState.add(id); await this.trySendToPeer(peer, { action: "subscribe", ...coValue.knownState(), @@ -192,50 +192,50 @@ export class SyncManager { } async tellUntoldKnownStateIncludingDependencies( - coValueID: RawCoValueID, + id: RawCoID, peer: PeerState, - asDependencyOf?: RawCoValueID + asDependencyOf?: RawCoID ) { - const coValue = this.local.expectCoValueLoaded(coValueID); + const coValue = this.local.expectCoValueLoaded(id); - for (const dependentCoValueID of coValue.getDependedOnCoValues()) { + for (const dependentCoID of coValue.getDependedOnCoValues()) { await this.tellUntoldKnownStateIncludingDependencies( - dependentCoValueID, + dependentCoID, peer, - asDependencyOf || coValueID + asDependencyOf || id ); } - if (!peer.toldKnownState.has(coValueID)) { + if (!peer.toldKnownState.has(id)) { await this.trySendToPeer(peer, { action: "tellKnownState", asDependencyOf, ...coValue.knownState(), }); - peer.toldKnownState.add(coValueID); + peer.toldKnownState.add(id); } } async sendNewContentIncludingDependencies( - coValueID: RawCoValueID, + id: RawCoID, peer: PeerState ) { - const coValue = this.local.expectCoValueLoaded(coValueID); + const coValue = this.local.expectCoValueLoaded(id); - for (const coValueID of coValue.getDependedOnCoValues()) { - await this.sendNewContentIncludingDependencies(coValueID, peer); + for (const id of coValue.getDependedOnCoValues()) { + await this.sendNewContentIncludingDependencies(id, peer); } const newContent = coValue.newContentSince( - peer.optimisticKnownStates[coValueID] + peer.optimisticKnownStates[id] ); if (newContent) { await this.trySendToPeer(peer, newContent); - peer.optimisticKnownStates[coValueID] = combinedKnownStates( - peer.optimisticKnownStates[coValueID] || - emptyKnownState(coValueID), + peer.optimisticKnownStates[id] = combinedKnownStates( + peer.optimisticKnownStates[id] || + emptyKnownState(id), coValue.knownState() ); } @@ -256,11 +256,11 @@ export class SyncManager { const initialSync = async () => { for (const id of Object.keys( this.local.coValues - ) as RawCoValueID[]) { + ) as RawCoID[]) { await this.subscribeToIncludingDependencies(id, peerState); peerState.optimisticKnownStates[id] = { - coValueID: id, + id: id, header: false, sessions: {}, }; @@ -296,19 +296,19 @@ export class SyncManager { } async handleSubscribe(msg: SubscribeMessage, peer: PeerState) { - const entry = this.local.coValues[msg.coValueID]; + const entry = this.local.coValues[msg.id]; if (!entry || entry.state === "loading") { if (!entry) { - this.local.coValues[msg.coValueID] = newLoadingState(); + this.local.coValues[msg.id] = newLoadingState(); } - peer.optimisticKnownStates[msg.coValueID] = knownStateIn(msg); - peer.toldKnownState.add(msg.coValueID); + peer.optimisticKnownStates[msg.id] = knownStateIn(msg); + peer.toldKnownState.add(msg.id); await this.trySendToPeer(peer, { action: "tellKnownState", - coValueID: msg.coValueID, + id: msg.id, header: false, sessions: {}, }); @@ -316,22 +316,22 @@ export class SyncManager { return; } - peer.optimisticKnownStates[msg.coValueID] = knownStateIn(msg); + peer.optimisticKnownStates[msg.id] = knownStateIn(msg); await this.tellUntoldKnownStateIncludingDependencies( - msg.coValueID, + msg.id, peer ); - await this.sendNewContentIncludingDependencies(msg.coValueID, peer); + await this.sendNewContentIncludingDependencies(msg.id, peer); } async handleTellKnownState(msg: TellKnownStateMessage, peer: PeerState) { - let entry = this.local.coValues[msg.coValueID]; + let entry = this.local.coValues[msg.id]; - peer.optimisticKnownStates[msg.coValueID] = combinedKnownStates( - peer.optimisticKnownStates[msg.coValueID] || - emptyKnownState(msg.coValueID), + peer.optimisticKnownStates[msg.id] = combinedKnownStates( + peer.optimisticKnownStates[msg.id] || + emptyKnownState(msg.id), knownStateIn(msg) ); @@ -340,7 +340,7 @@ export class SyncManager { if (this.local.coValues[msg.asDependencyOf]) { entry = newLoadingState(); - this.local.coValues[msg.coValueID] = entry; + this.local.coValues[msg.id] = entry; } else { throw new Error( "Expected coValue dependency entry to be created, missing subscribe?" @@ -358,14 +358,14 @@ export class SyncManager { } await this.tellUntoldKnownStateIncludingDependencies( - msg.coValueID, + msg.id, peer ); - await this.sendNewContentIncludingDependencies(msg.coValueID, peer); + await this.sendNewContentIncludingDependencies(msg.id, peer); } async handleNewContent(msg: NewContentMessage, peer: PeerState) { - let entry = this.local.coValues[msg.coValueID]; + let entry = this.local.coValues[msg.id]; if (!entry) { throw new Error( @@ -376,7 +376,7 @@ export class SyncManager { let resolveAfterDone: ((coValue: CoValue) => void) | undefined; const peerOptimisticKnownState = - peer.optimisticKnownStates[msg.coValueID]; + peer.optimisticKnownStates[msg.id]; if (!peerOptimisticKnownState) { throw new Error( @@ -400,7 +400,7 @@ export class SyncManager { coValue: coValue, }; - this.local.coValues[msg.coValueID] = entry; + this.local.coValues[msg.id] = entry; } const coValue = entry.coValue; @@ -461,9 +461,9 @@ export class SyncManager { msg: WrongAssumedKnownStateMessage, peer: PeerState ) { - const coValue = this.local.expectCoValueLoaded(msg.coValueID); + const coValue = this.local.expectCoValueLoaded(msg.id); - peer.optimisticKnownStates[msg.coValueID] = combinedKnownStates( + peer.optimisticKnownStates[msg.id] = combinedKnownStates( msg, coValue.knownState() ); @@ -510,7 +510,7 @@ function knownStateIn( | WrongAssumedKnownStateMessage ) { return { - coValueID: msg.coValueID, + id: msg.id, header: msg.header, sessions: msg.sessions, }; From eb01fc5ae5893ccefef70346300521b69ed2e4a0 Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 14 Aug 2023 18:14:12 +0100 Subject: [PATCH 42/47] Simplify meta generics and content type types --- src/account.ts | 2 +- src/contentType.ts | 26 ++++++++++-------- src/contentTypes/coList.ts | 2 +- src/contentTypes/coMap.ts | 53 +++++++++++++++++------------------- src/contentTypes/coStream.ts | 2 +- src/contentTypes/static.ts | 4 +-- src/permissions.ts | 14 +++++----- 7 files changed, 51 insertions(+), 52 deletions(-) diff --git a/src/account.ts b/src/account.ts index 03010d4f6..ab8eea08e 100644 --- a/src/account.ts +++ b/src/account.ts @@ -48,7 +48,7 @@ export interface GeneralizedControlledAccount { export class ControlledAccount extends Account implements GeneralizedControlledAccount { agentSecret: AgentSecret; - constructor(agentSecret: AgentSecret, teamMap: CoMap, node: LocalNode) { + constructor(agentSecret: AgentSecret, teamMap: CoMap, node: LocalNode) { super(teamMap, node); this.agentSecret = agentSecret; diff --git a/src/contentType.ts b/src/contentType.ts index 0909c7fc0..6b4eb268c 100644 --- a/src/contentType.ts +++ b/src/contentType.ts @@ -1,24 +1,26 @@ -import { JsonValue } from './jsonValue.js'; -import { RawCoID } from './ids.js'; -import { CoMap } from './contentTypes/coMap.js'; -import { CoStream } from './contentTypes/coStream.js'; -import { Static } from './contentTypes/static.js'; -import { CoList } from './contentTypes/coList.js'; +import { JsonObject, JsonValue } from "./jsonValue.js"; +import { RawCoID } from "./ids.js"; +import { CoMap } from "./contentTypes/coMap.js"; +import { CoStream } from "./contentTypes/coStream.js"; +import { Static } from "./contentTypes/static.js"; +import { CoList } from "./contentTypes/coList.js"; export type CoID = RawCoID & { readonly __type: T; }; export type ContentType = - | CoMap<{[key: string]: JsonValue}, JsonValue> - | CoList - | CoStream - | Static; + | CoMap<{ [key: string]: JsonValue }, JsonObject | null> + | CoList + | CoStream + | Static; -export function expectMap(content: ContentType): CoMap<{ [key: string]: string }, {}> { +export function expectMap( + content: ContentType +): CoMap<{ [key: string]: string }, JsonObject | null> { if (content.type !== "comap") { throw new Error("Expected map"); } - return content as CoMap<{ [key: string]: string }, {}>; + return content as CoMap<{ [key: string]: string }, JsonObject | null>; } diff --git a/src/contentTypes/coList.ts b/src/contentTypes/coList.ts index 41c4d60e1..730ed3937 100644 --- a/src/contentTypes/coList.ts +++ b/src/contentTypes/coList.ts @@ -2,7 +2,7 @@ import { JsonObject, JsonValue } from '../jsonValue.js'; import { CoID } from '../contentType.js'; import { CoValue } from '../coValue.js'; -export class CoList { +export class CoList { id: CoID>; type = "colist" as const; coValue: CoValue; diff --git a/src/contentTypes/coMap.ts b/src/contentTypes/coMap.ts index 523030700..254397745 100644 --- a/src/contentTypes/coMap.ts +++ b/src/contentTypes/coMap.ts @@ -20,24 +20,25 @@ export type MapOpPayload = { key: K; }; +export type MapK = keyof M & string; +export type MapV = M[MapK]; +export type MapM = { + [KK in MapK]: M[KK]; +} + 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]; - } + Meta extends JsonObject | null = null, > { - id: CoID>; + id: CoID, Meta>>; coValue: CoValue; type = "comap" as const; ops: { - [KK in K]?: MapOp[]; + [KK in MapK]?: MapOp[]; }; constructor(coValue: CoValue) { - this.id = coValue.id as CoID>; + this.id = coValue.id as CoID, Meta>>; this.coValue = coValue; this.ops = {}; @@ -51,7 +52,7 @@ export class CoMap< for (const [changeIdx, changeUntyped] of ( changes ).entries()) { - const change = changeUntyped as MapOpPayload; + const change = changeUntyped as MapOpPayload, MapV>; let entries = this.ops[change.key]; if (!entries) { entries = []; @@ -61,17 +62,17 @@ export class CoMap< txID, madeAt, changeIdx, - ...(change as MapOpPayload), + ...(change as MapOpPayload, MapV>), }); } } } - keys(): K[] { - return Object.keys(this.ops) as K[]; + keys(): MapK[] { + return Object.keys(this.ops) as MapK[]; } - get(key: KK): M[KK] | undefined { + get>(key: K): M[K] | undefined { const ops = this.ops[key]; if (!ops) { return undefined; @@ -86,7 +87,7 @@ export class CoMap< } } - getAtTime(key: KK, time: number): M[KK] | undefined { + getAtTime>(key: K, time: number): M[K] | undefined { const ops = this.ops[key]; if (!ops) { return undefined; @@ -105,7 +106,7 @@ export class CoMap< } } - getLastTxID(key: KK): TransactionID | undefined { + getLastTxID>(key: K): TransactionID | undefined { const ops = this.ops[key]; if (!ops) { return undefined; @@ -116,7 +117,7 @@ export class CoMap< return lastEntry.txID; } - getLastEntry(key: KK): { at: number; txID: TransactionID; value: M[KK]; } | undefined { + getLastEntry>(key: K): { at: number; txID: TransactionID; value: M[K]; } | undefined { const ops = this.ops[key]; if (!ops) { return undefined; @@ -131,13 +132,13 @@ export class CoMap< } } - getHistory(key: KK): { at: number; txID: TransactionID; value: M[KK] | undefined; }[] { + getHistory>(key: K): { at: number; txID: TransactionID; value: M[K] | undefined; }[] { const ops = this.ops[key]; if (!ops) { return []; } - const history: { at: number; txID: TransactionID; value: M[KK] | undefined; }[] = []; + const history: { at: number; txID: TransactionID; value: M[K] | undefined; }[] = []; for (const op of ops) { if (op.op === "delete") { @@ -178,14 +179,10 @@ export class 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 { + Meta extends JsonObject | null = null, + +> extends CoMap { + set>(key: K, value: M[K], privacy: "private" | "trusting" = "private"): void { this.coValue.makeTransaction([ { op: "insert", @@ -197,7 +194,7 @@ export class WriteableCoMap< this.fillOpsFromCoValue(); } - delete(key: K, privacy: "private" | "trusting" = "private"): void { + delete(key: MapK, privacy: "private" | "trusting" = "private"): void { this.coValue.makeTransaction([ { op: "delete", diff --git a/src/contentTypes/coStream.ts b/src/contentTypes/coStream.ts index 52ddd481e..be479f398 100644 --- a/src/contentTypes/coStream.ts +++ b/src/contentTypes/coStream.ts @@ -2,7 +2,7 @@ import { JsonObject, JsonValue } from '../jsonValue.js'; import { CoID } from '../contentType.js'; import { CoValue } from '../coValue.js'; -export class CoStream { +export class CoStream { id: CoID>; type = "costream" as const; coValue: CoValue; diff --git a/src/contentTypes/static.ts b/src/contentTypes/static.ts index ffa815e66..47c5fdc85 100644 --- a/src/contentTypes/static.ts +++ b/src/contentTypes/static.ts @@ -1,8 +1,8 @@ -import { JsonObject, JsonValue } from '../jsonValue.js'; +import { JsonObject } from '../jsonValue.js'; import { CoID } from '../contentType.js'; import { CoValue } from '../coValue.js'; -export class Static { +export class Static { id: CoID>; type = "static" as const; coValue: CoValue; diff --git a/src/permissions.ts b/src/permissions.ts index d130ca033..87085831c 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -1,6 +1,6 @@ import { CoID, ContentType } from "./contentType.js"; import { CoMap, MapOpPayload } from "./contentTypes/coMap.js"; -import { JsonValue } from "./jsonValue.js"; +import { JsonObject, JsonValue } from "./jsonValue.js"; import { Encrypted, KeyID, @@ -216,24 +216,24 @@ export type TeamContent = { export function expectTeamContent( content: ContentType -): CoMap { +): CoMap { if (content.type !== "comap") { throw new Error("Expected map"); } - return content as CoMap; + return content as CoMap; } export class Team { - teamMap: CoMap; + teamMap: CoMap; node: LocalNode; - constructor(teamMap: CoMap, node: LocalNode) { + constructor(teamMap: CoMap, node: LocalNode) { this.teamMap = teamMap; this.node = node; } - get id(): CoID> { + get id(): CoID> { return this.teamMap.id; } @@ -342,7 +342,7 @@ export class Team { this.rotateReadKey(); } - createMap( + createMap( meta?: M ): CoMap { return this.node From 4336d2aa7dd7acbdf3ad5ede5a0fcf307771680c Mon Sep 17 00:00:00 2001 From: Anselm Date: Mon, 14 Aug 2023 18:27:01 +0100 Subject: [PATCH 43/47] Review sync message properties and names --- src/coValue.ts | 6 +-- src/sync.test.ts | 123 ++++++++++++++++++++++++----------------------- src/sync.ts | 77 ++++++++++++++--------------- 3 files changed, 102 insertions(+), 104 deletions(-) diff --git a/src/coValue.ts b/src/coValue.ts index f440002c2..afea7cbeb 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -491,10 +491,10 @@ export class CoValue { knownState: CoValueKnownState | undefined ): NewContentMessage | undefined { const newContent: NewContentMessage = { - action: "newContent", + action: "content", id: this.id, header: knownState?.header ? undefined : this.header, - newContent: Object.fromEntries( + new: Object.fromEntries( Object.entries(this.sessions) .map(([sessionID, log]) => { const newTransactions = log.transactions.slice( @@ -528,7 +528,7 @@ export class CoValue { if ( !newContent.header && - Object.keys(newContent.newContent).length === 0 + Object.keys(newContent.new).length === 0 ) { return undefined; } diff --git a/src/sync.test.ts b/src/sync.test.ts index 1d6528172..d85143d15 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -37,7 +37,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => { const writer = inTx.getWriter(); await writer.write({ - action: "subscribe", + action: "load", id: map.coValue.id, header: false, sessions: {}, @@ -50,7 +50,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => { const mapTellKnownStateMsg = await reader.read(); expect(mapTellKnownStateMsg.value).toEqual({ - action: "tellKnownState", + action: "known", ...map.coValue.knownState(), } satisfies SyncMessage); @@ -60,7 +60,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => { const newContentMsg = await reader.read(); expect(newContentMsg.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, header: { type: "comap", @@ -69,7 +69,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => { createdAt: map.coValue.header.createdAt, uniqueness: map.coValue.header.uniqueness, }, - newContent: { + new: { [node.ownSessionID]: { after: 0, newTransactions: [ @@ -120,7 +120,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( const writer = inTx.getWriter(); await writer.write({ - action: "subscribe", + action: "load", id: map.coValue.id, header: true, sessions: { @@ -135,7 +135,7 @@ test("Node replies with only new tx to subscribe with some known state", async ( const mapTellKnownStateMsg = await reader.read(); expect(mapTellKnownStateMsg.value).toEqual({ - action: "tellKnownState", + action: "known", ...map.coValue.knownState(), } satisfies SyncMessage); @@ -145,10 +145,10 @@ test("Node replies with only new tx to subscribe with some known state", async ( const mapNewContentMsg = await reader.read(); expect(mapNewContentMsg.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, header: undefined, - newContent: { + new: { [node.ownSessionID]: { after: 1, newTransactions: [ @@ -198,7 +198,7 @@ test("After subscribing, node sends own known state and new txs to peer", async const writer = inTx.getWriter(); await writer.write({ - action: "subscribe", + action: "load", id: map.coValue.id, header: false, sessions: { @@ -213,7 +213,7 @@ test("After subscribing, node sends own known state and new txs to peer", async const mapTellKnownStateMsg = await reader.read(); expect(mapTellKnownStateMsg.value).toEqual({ - action: "tellKnownState", + action: "known", ...map.coValue.knownState(), } satisfies SyncMessage); @@ -223,10 +223,10 @@ test("After subscribing, node sends own known state and new txs to peer", async const mapNewContentHeaderOnlyMsg = await reader.read(); expect(mapNewContentHeaderOnlyMsg.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, header: map.coValue.header, - newContent: {}, + new: {}, } satisfies SyncMessage); map.edit((editable) => { @@ -236,9 +236,9 @@ test("After subscribing, node sends own known state and new txs to peer", async const mapEditMsg1 = await reader.read(); expect(mapEditMsg1.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, - newContent: { + new: { [node.ownSessionID]: { after: 0, newTransactions: [ @@ -269,9 +269,9 @@ test("After subscribing, node sends own known state and new txs to peer", async const mapEditMsg2 = await reader.read(); expect(mapEditMsg2.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, - newContent: { + new: { [node.ownSessionID]: { after: 1, newTransactions: [ @@ -325,7 +325,7 @@ test("Client replies with known new content to tellKnownState from server", asyn const writer = inTx.getWriter(); await writer.write({ - action: "tellKnownState", + action: "known", id: map.coValue.id, header: false, sessions: { @@ -338,7 +338,7 @@ test("Client replies with known new content to tellKnownState from server", asyn const mapTellKnownStateMsg = await reader.read(); expect(mapTellKnownStateMsg.value).toEqual({ - action: "tellKnownState", + action: "known", ...map.coValue.knownState(), } satisfies SyncMessage); @@ -348,10 +348,10 @@ test("Client replies with known new content to tellKnownState from server", asyn const mapNewContentMsg = await reader.read(); expect(mapNewContentMsg.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, header: map.coValue.header, - newContent: { + new: { [node.ownSessionID]: { after: 0, newTransactions: [ @@ -397,7 +397,7 @@ test("No matter the optimistic known state, node respects invalid known state me const writer = inTx.getWriter(); await writer.write({ - action: "subscribe", + action: "load", id: map.coValue.id, header: false, sessions: { @@ -412,7 +412,7 @@ test("No matter the optimistic known state, node respects invalid known state me const mapTellKnownStateMsg = await reader.read(); expect(mapTellKnownStateMsg.value).toEqual({ - action: "tellKnownState", + action: "known", ...map.coValue.knownState(), } satisfies SyncMessage); @@ -422,10 +422,10 @@ test("No matter the optimistic known state, node respects invalid known state me const mapNewContentHeaderOnlyMsg = await reader.read(); expect(mapNewContentHeaderOnlyMsg.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, header: map.coValue.header, - newContent: {}, + new: {}, } satisfies SyncMessage); map.edit((editable) => { @@ -440,7 +440,8 @@ test("No matter the optimistic known state, node respects invalid known state me const _mapEditMsg2 = await reader.read(); await writer.write({ - action: "wrongAssumedKnownState", + action: "known", + isCorrection: true, id: map.coValue.id, header: true, sessions: { @@ -451,10 +452,10 @@ test("No matter the optimistic known state, node respects invalid known state me const newContentAfterWrongAssumedState = await reader.read(); expect(newContentAfterWrongAssumedState.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, header: undefined, - newContent: { + new: { [node.ownSessionID]: { after: 1, newTransactions: [ @@ -528,18 +529,18 @@ 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", + // action: "load", // id: adminID, // }); expect((await reader.read()).value).toMatchObject({ - action: "subscribe", + action: "load", id: team.teamMap.coValue.id, }); const mapSubscribeMsg = await reader.read(); expect(mapSubscribeMsg.value).toEqual({ - action: "subscribe", + action: "load", id: map.coValue.id, header: true, sessions: {}, @@ -555,10 +556,10 @@ test("If we add a server peer, all updates to all coValues are sent to it, even const mapNewContentMsg = await reader.read(); expect(mapNewContentMsg.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, header: map.coValue.header, - newContent: { + new: { [node.ownSessionID]: { after: 0, newTransactions: [ @@ -601,11 +602,11 @@ 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", + // action: "load", // id: admin.id, // }); expect((await reader.read()).value).toMatchObject({ - action: "subscribe", + action: "load", id: team.teamMap.coValue.id, }); @@ -614,7 +615,7 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a const mapSubscribeMsg = await reader.read(); expect(mapSubscribeMsg.value).toEqual({ - action: "subscribe", + action: "load", ...map.coValue.knownState(), } satisfies SyncMessage); @@ -624,10 +625,10 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a const mapContentMsg = await reader.read(); expect(mapContentMsg.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, header: map.coValue.header, - newContent: {}, + new: {}, } satisfies SyncMessage); }); @@ -659,14 +660,14 @@ test("When we connect a new server peer, we try to sync all existing coValues to const teamSubscribeMessage = await reader.read(); expect(teamSubscribeMessage.value).toEqual({ - action: "subscribe", + action: "load", ...team.teamMap.coValue.knownState(), } satisfies SyncMessage); const secondMessage = await reader.read(); expect(secondMessage.value).toEqual({ - action: "subscribe", + action: "load", ...map.coValue.knownState(), } satisfies SyncMessage); }); @@ -692,7 +693,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe const writer = inTx.getWriter(); await writer.write({ - action: "subscribe", + action: "load", id: map.coValue.id, header: true, sessions: { @@ -707,7 +708,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe const mapTellKnownState = await reader.read(); expect(mapTellKnownState.value).toEqual({ - action: "tellKnownState", + action: "known", ...map.coValue.knownState(), } satisfies SyncMessage); }); @@ -750,12 +751,12 @@ test.skip("When replaying creation and transactions of a coValue as new content, const adminSubscribeMessage = await from1.read(); expect(adminSubscribeMessage.value).toMatchObject({ - action: "subscribe", + action: "load", id: admin.id, }); const teamSubscribeMsg = await from1.read(); expect(teamSubscribeMsg.value).toMatchObject({ - action: "subscribe", + action: "load", id: team.teamMap.coValue.id, }); @@ -790,23 +791,23 @@ test.skip("When replaying creation and transactions of a coValue as new content, const mapSubscriptionMsg = await from1.read(); expect(mapSubscriptionMsg.value).toMatchObject({ - action: "subscribe", + action: "load", id: map.coValue.id, }); const mapNewContentMsg = await from1.read(); expect(mapNewContentMsg.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, header: map.coValue.header, - newContent: {}, + new: {}, } satisfies SyncMessage); await to2.write(mapSubscriptionMsg.value!); const mapTellKnownStateMsg = await from2.read(); expect(mapTellKnownStateMsg.value).toEqual({ - action: "tellKnownState", + action: "known", id: map.coValue.id, header: false, sessions: {}, @@ -965,11 +966,11 @@ 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", + // action: "load", // id: admin.id, // }); expect((await reader.read()).value).toMatchObject({ - action: "subscribe", + action: "load", id: team.teamMap.coValue.id, }); @@ -978,7 +979,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async const mapSubscribeMsg = await reader.read(); expect(mapSubscribeMsg.value).toEqual({ - action: "subscribe", + action: "load", ...map.coValue.knownState(), } satisfies SyncMessage); @@ -988,10 +989,10 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async const mapContentMsg = await reader.read(); expect(mapContentMsg.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, header: map.coValue.header, - newContent: {}, + new: {}, } satisfies SyncMessage); await inTx.abort(); @@ -1019,11 +1020,11 @@ 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", + // action: "load", // id: admin.id, // }); expect((await reader.read()).value).toMatchObject({ - action: "subscribe", + action: "load", id: team.teamMap.coValue.id, }); @@ -1032,7 +1033,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async const mapSubscribeMsg = await reader.read(); expect(mapSubscribeMsg.value).toEqual({ - action: "subscribe", + action: "load", ...map.coValue.knownState(), } satisfies SyncMessage); @@ -1042,10 +1043,10 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async const mapContentMsg = await reader.read(); expect(mapContentMsg.value).toEqual({ - action: "newContent", + action: "content", id: map.coValue.id, header: map.coValue.header, - newContent: {}, + new: {}, } satisfies SyncMessage); reader.releaseLock(); @@ -1097,28 +1098,28 @@ test("If we start loading a coValue before connecting to a peer that has it, it function teamContentEx(team: Team) { return { - action: "newContent", + action: "content", id: team.teamMap.coValue.id, }; } function admContEx(adminID: AccountID) { return { - action: "newContent", + action: "content", id: adminID, }; } function teamStateEx(team: Team) { return { - action: "tellKnownState", + action: "known", id: team.teamMap.coValue.id, }; } function admStateEx(adminID: AccountID) { return { - action: "tellKnownState", + action: "known", id: adminID, }; } diff --git a/src/sync.ts b/src/sync.ts index d65a4930c..2241806c5 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -25,26 +25,26 @@ export function emptyKnownState(id: RawCoID): CoValueKnownState { } export type SyncMessage = - | SubscribeMessage - | TellKnownStateMessage + | LoadMessage + | KnownStateMessage | NewContentMessage - | WrongAssumedKnownStateMessage - | UnsubscribeMessage; + | DoneMessage; -export type SubscribeMessage = { - action: "subscribe"; +export type LoadMessage = { + action: "load"; } & CoValueKnownState; -export type TellKnownStateMessage = { - action: "tellKnownState"; +export type KnownStateMessage = { + action: "known"; asDependencyOf?: RawCoID; + isCorrection?: boolean; } & CoValueKnownState; export type NewContentMessage = { - action: "newContent"; + action: "content"; id: RawCoID; header?: CoValueHeader; - newContent: { + new: { [sessionID: SessionID]: SessionNewContent; }; }; @@ -56,13 +56,8 @@ export type SessionNewContent = { lastHash: Hash; lastSignature: Signature; }; - -export type WrongAssumedKnownStateMessage = { - action: "wrongAssumedKnownState"; -} & CoValueKnownState; - -export type UnsubscribeMessage = { - action: "unsubscribe"; +export type DoneMessage = { + action: "done"; id: RawCoID; }; @@ -121,7 +116,7 @@ export class SyncManager { for (const peer of Object.values(this.peers)) { peer.outgoing .write({ - action: "subscribe", + action: "load", id: id, header: false, sessions: {}, @@ -135,15 +130,17 @@ export class SyncManager { async handleSyncMessage(msg: SyncMessage, peer: PeerState) { // TODO: validate switch (msg.action) { - case "subscribe": - return await this.handleSubscribe(msg, peer); - case "tellKnownState": - return await this.handleTellKnownState(msg, peer); - case "newContent": + case "load": + return await this.handleLoad(msg, peer); + case "known": + if (msg.isCorrection) { + return await this.handleCorrection(msg, peer); + } else { + return await this.handleKnownState(msg, peer); + } + case "content": return await this.handleNewContent(msg, peer); - case "wrongAssumedKnownState": - return await this.handleWrongAssumedKnownState(msg, peer); - case "unsubscribe": + case "done": return await this.handleUnsubscribe(msg); default: throw new Error( @@ -168,7 +165,7 @@ export class SyncManager { if (entry.state === "loading") { await this.trySendToPeer(peer, { - action: "subscribe", + action: "load", id, header: false, sessions: {}, @@ -185,7 +182,7 @@ export class SyncManager { if (!peer.toldKnownState.has(id)) { peer.toldKnownState.add(id); await this.trySendToPeer(peer, { - action: "subscribe", + action: "load", ...coValue.knownState(), }); } @@ -208,7 +205,7 @@ export class SyncManager { if (!peer.toldKnownState.has(id)) { await this.trySendToPeer(peer, { - action: "tellKnownState", + action: "known", asDependencyOf, ...coValue.knownState(), }); @@ -295,7 +292,7 @@ export class SyncManager { }); } - async handleSubscribe(msg: SubscribeMessage, peer: PeerState) { + async handleLoad(msg: LoadMessage, peer: PeerState) { const entry = this.local.coValues[msg.id]; if (!entry || entry.state === "loading") { @@ -307,7 +304,7 @@ export class SyncManager { peer.toldKnownState.add(msg.id); await this.trySendToPeer(peer, { - action: "tellKnownState", + action: "known", id: msg.id, header: false, sessions: {}, @@ -326,7 +323,7 @@ export class SyncManager { await this.sendNewContentIncludingDependencies(msg.id, peer); } - async handleTellKnownState(msg: TellKnownStateMessage, peer: PeerState) { + async handleKnownState(msg: KnownStateMessage, peer: PeerState) { let entry = this.local.coValues[msg.id]; peer.optimisticKnownStates[msg.id] = combinedKnownStates( @@ -408,7 +405,7 @@ export class SyncManager { let invalidStateAssumed = false; for (const [sessionID, newContentForSession] of Object.entries( - msg.newContent + msg.new ) as [SessionID, SessionNewContent][]) { const ourKnownTxIdx = coValue.sessions[sessionID]?.transactions.length; @@ -451,14 +448,15 @@ export class SyncManager { if (invalidStateAssumed) { await this.trySendToPeer(peer, { - action: "wrongAssumedKnownState", + action: "known", + isCorrection: true, ...coValue.knownState(), }); } } - async handleWrongAssumedKnownState( - msg: WrongAssumedKnownStateMessage, + async handleCorrection( + msg: KnownStateMessage, peer: PeerState ) { const coValue = this.local.expectCoValueLoaded(msg.id); @@ -475,7 +473,7 @@ export class SyncManager { } } - handleUnsubscribe(_msg: UnsubscribeMessage) { + handleUnsubscribe(_msg: DoneMessage) { throw new Error("Method not implemented."); } @@ -505,9 +503,8 @@ export class SyncManager { function knownStateIn( msg: - | SubscribeMessage - | TellKnownStateMessage - | WrongAssumedKnownStateMessage + | LoadMessage + | KnownStateMessage ) { return { id: msg.id, From 94d0012da03c8c11e6cb3f7303ffd292b164621c Mon Sep 17 00:00:00 2001 From: Anselm Date: Tue, 15 Aug 2023 11:18:53 +0100 Subject: [PATCH 44/47] Basic account and profile creation --- src/account.test.ts | 32 +++++++++++++++ src/account.ts | 73 +++++++++++++++++++++++++--------- src/contentTypes/coMap.ts | 16 +++++++- src/node.ts | 84 +++++++++++++++++++++++++++++++++++---- src/permissions.ts | 16 ++++++-- 5 files changed, 192 insertions(+), 29 deletions(-) create mode 100644 src/account.test.ts diff --git a/src/account.test.ts b/src/account.test.ts new file mode 100644 index 000000000..640aacccd --- /dev/null +++ b/src/account.test.ts @@ -0,0 +1,32 @@ +import { LocalNode } from "./node.js"; + +test("Can create a node while creating a new account with profile", async () => { + const { node, account, accountSecret, sessionID } = + LocalNode.withNewlyCreatedAccount("Hermes Puggington"); + + expect(node).not.toBeNull(); + expect(account).not.toBeNull(); + expect(accountSecret).not.toBeNull(); + expect(sessionID).not.toBeNull(); + + expect((node.expectProfileLoaded(account)).get("name")).toEqual("Hermes Puggington"); + expect((await node.loadProfile(account)).get("name")).toEqual("Hermes Puggington"); +}); + +test("A node with an account can create teams and and objects within them", async () => { + const { node, account } = + LocalNode.withNewlyCreatedAccount("Hermes Puggington"); + + const team = await node.createTeam(); + expect(team).not.toBeNull(); + + let map = team.createMap(); + map = map.edit((edit) => { + edit.set("foo", "bar", "private"); + expect(edit.get("foo")).toEqual("bar"); + }); + + expect(map.get("foo")).toEqual("bar"); + + expect(map.getLastEditor("foo")).toEqual(account); +}); \ No newline at end of file diff --git a/src/account.ts b/src/account.ts index ab8eea08e..9eeb144ae 100644 --- a/src/account.ts +++ b/src/account.ts @@ -1,33 +1,50 @@ -import { CoValueHeader } from './coValue.js'; -import { CoID } from './contentType.js'; -import { AgentSecret, SealerID, SealerSecret, SignerID, SignerSecret, getAgentID, getAgentSealerID, getAgentSealerSecret, getAgentSignerID, getAgentSignerSecret } from './crypto.js'; -import { AgentID } from './ids.js'; -import { CoMap, LocalNode } from './index.js'; -import { Team, TeamContent } from './permissions.js'; +import { CoValueHeader } from "./coValue.js"; +import { CoID } from "./contentType.js"; +import { + AgentSecret, + SealerID, + SealerSecret, + SignerID, + SignerSecret, + getAgentID, + getAgentSealerID, + getAgentSealerSecret, + getAgentSignerID, + getAgentSignerSecret, +} 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 { +export function accountHeaderForInitialAgentSecret( + agentSecret: AgentSecret +): CoValueHeader { const agent = getAgentID(agentSecret); return { type: "comap", - ruleset: {type: "team", initialAdmin: agent}, + ruleset: { type: "team", initialAdmin: agent }, meta: { - type: "account" + type: "account", }, createdAt: null, uniqueness: null, - } + }; } export class Account extends Team { get id(): AccountID { - return this.teamMap.id; + return this.teamMap.id as AccountID; } getCurrentAgentID(): AgentID { - const agents = this.teamMap.keys().filter((k): k is AgentID => k.startsWith("sealer_")); + const agents = this.teamMap + .keys() + .filter((k): k is AgentID => k.startsWith("sealer_")); if (agents.length !== 1) { - throw new Error("Expected exactly one agent in account, got " + agents.length); + throw new Error( + "Expected exactly one agent in account, got " + agents.length + ); } return agents[0]!; @@ -45,10 +62,17 @@ export interface GeneralizedControlledAccount { currentSealerSecret: () => SealerSecret; } -export class ControlledAccount extends Account implements GeneralizedControlledAccount { +export class ControlledAccount + extends Account + implements GeneralizedControlledAccount +{ agentSecret: AgentSecret; - constructor(agentSecret: AgentSecret, teamMap: CoMap, node: LocalNode) { + constructor( + agentSecret: AgentSecret, + teamMap: CoMap, + node: LocalNode + ) { super(teamMap, node); this.agentSecret = agentSecret; @@ -75,7 +99,9 @@ export class ControlledAccount extends Account implements GeneralizedControlledA } } -export class AnonymousControlledAccount implements GeneralizedControlledAccount { +export class AnonymousControlledAccount + implements GeneralizedControlledAccount +{ agentSecret: AgentSecret; constructor(agentSecret: AgentSecret) { @@ -107,9 +133,20 @@ export class AnonymousControlledAccount implements GeneralizedControlledAccount } } -export type AccountMeta = {type: "account"}; -export type AccountID = CoID>; +export type AccountContent = TeamContent & { profile: CoID }; +export type AccountMeta = { type: "account" }; +export type AccountID = CoID>; export type AccountIDOrAgentID = AgentID | AccountID; export type AccountOrAgentID = AgentID | Account; export type AccountOrAgentSecret = AgentSecret | Account; + +export function isAccountID(id: AccountIDOrAgentID): id is AccountID { + return id.startsWith("co_"); +} + +export type ProfileContent = { + name: string; +}; +export type ProfileMeta = { type: "profile" }; +export type Profile = CoMap; diff --git a/src/contentTypes/coMap.ts b/src/contentTypes/coMap.ts index 254397745..8965c5f40 100644 --- a/src/contentTypes/coMap.ts +++ b/src/contentTypes/coMap.ts @@ -1,7 +1,8 @@ import { JsonObject, JsonValue } from '../jsonValue.js'; import { TransactionID } from '../ids.js'; import { CoID } from '../contentType.js'; -import { CoValue } from '../coValue.js'; +import { CoValue, accountOrAgentIDfromSessionID } from '../coValue.js'; +import { AccountID, isAccountID } from '../account.js'; type MapOp = { txID: TransactionID; @@ -106,6 +107,19 @@ export class CoMap< } } + getLastEditor>(key: K): AccountID | undefined { + const tx = this.getLastTxID(key); + if (!tx) { + return undefined; + } + const accountID = accountOrAgentIDfromSessionID(tx.sessionID); + if (isAccountID(accountID)) { + return accountID; + } else { + return undefined; + } + } + getLastTxID>(key: K): TransactionID | undefined { const ops = this.ops[key]; if (!ops) { diff --git a/src/node.ts b/src/node.ts index 69f5d22ac..1778fabbb 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,4 +1,5 @@ import { + AgentSecret, createdNowUnique, getAgentID, getAgentSealerID, @@ -20,6 +21,11 @@ import { GeneralizedControlledAccount, ControlledAccount, AnonymousControlledAccount, + AccountID, + Profile, + AccountContent, + ProfileContent, + ProfileMeta, } from "./account.js"; import { CoMap } from "./index.js"; @@ -37,6 +43,33 @@ export class LocalNode { this.ownSessionID = ownSessionID; } + static withNewlyCreatedAccount(name: string): { + node: LocalNode; + account: AccountID; + accountSecret: AgentSecret; + sessionID: SessionID; + } { + const throwawayAgent = newRandomAgentSecret(); + const setupNode = new LocalNode( + new AnonymousControlledAccount(throwawayAgent), + newRandomSessionID(getAgentID(throwawayAgent)) + ); + + const account = setupNode.createAccount(name); + + const nodeWithAccount = account.node.testWithDifferentAccount( + account, + newRandomSessionID(account.id) + ); + + return { + node: nodeWithAccount, + account: account.id, + accountSecret: account.agentSecret, + sessionID: nodeWithAccount.ownSessionID, + }; + } + createCoValue(header: CoValueHeader): CoValue { const coValue = new CoValue(header, this); this.coValues[coValue.id] = { state: "loaded", coValue: coValue }; @@ -65,6 +98,16 @@ export class LocalNode { return (await this.loadCoValue(id)).getCurrentContent() as T; } + async loadProfile(id: AccountID): Promise { + const account = await this.load>(id); + const profileID = account.get("profile"); + + if (!profileID) { + throw new Error(`Account ${id} has no profile`); + } + return (await this.loadCoValue(profileID)).getCurrentContent() as Profile; + } + expectCoValueLoaded(id: RawCoID, expectation?: string): CoValue { const entry = this.coValues[id]; if (!entry) { @@ -82,7 +125,20 @@ export class LocalNode { return entry.coValue; } - createAccount(_publicNickname: string): ControlledAccount { + expectProfileLoaded(id: AccountID, expectation?: string): Profile { + const account = this.expectCoValueLoaded(id, expectation); + const profileID = expectTeamContent(account.getCurrentContent()).get("profile"); + if (!profileID) { + throw new Error( + `${ + expectation ? expectation + ": " : "" + }Account ${id} has no profile` + ); + } + return this.expectCoValueLoaded(profileID, expectation).getCurrentContent() as Profile; + } + + createAccount(name: string): ControlledAccount { const agentSecret = newRandomAgentSecret(); const account = this.createCoValue( @@ -92,7 +148,9 @@ export class LocalNode { newRandomSessionID(getAgentID(agentSecret)) ); - expectTeamContent(account.getCurrentContent()).edit((editable) => { + const accountAsTeam = new Team(expectTeamContent(account.getCurrentContent()), account.node); + + accountAsTeam.teamMap.edit((editable) => { editable.set(getAgentID(agentSecret), "admin", "trusting"); const readKey = newRandomKeySecret(); @@ -111,14 +169,26 @@ export class LocalNode { "trusting" ); - editable.set('readKey', readKey.id, "trusting"); + editable.set("readKey", readKey.id, "trusting"); }); - return new ControlledAccount( + const controlledAccount = new ControlledAccount( agentSecret, - account.getCurrentContent() as CoMap, - this + account.getCurrentContent() as CoMap, + account.node ); + + const profile = accountAsTeam.createMap({ type: "profile" }); + + profile.edit((editable) => { + editable.set("name", name, "trusting"); + }); + + accountAsTeam.teamMap.edit((editable) => { + editable.set("profile", profile.id, "trusting"); + }); + + return controlledAccount; } resolveAccountAgent(id: AccountIDOrAgentID, expectation?: string): AgentID { @@ -177,7 +247,7 @@ export class LocalNode { "trusting" ); - editable.set('readKey', readKey.id, "trusting"); + editable.set("readKey", readKey.id, "trusting"); }); return new Team(teamContent, this); diff --git a/src/permissions.ts b/src/permissions.ts index 87085831c..cf61e3e30 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -20,7 +20,7 @@ import { } from "./coValue.js"; import { LocalNode } from "./node.js"; import { RawCoID, SessionID, TransactionID, isAgentID } from "./ids.js"; -import { AccountIDOrAgentID, GeneralizedControlledAccount } from "./account.js"; +import { AccountIDOrAgentID, GeneralizedControlledAccount, Profile } from "./account.js"; export type PermissionsDef = | { type: "team"; initialAdmin: AccountIDOrAgentID } @@ -77,7 +77,8 @@ export function determineValidTransactions( const change = tx.changes[0] as | MapOpPayload - | MapOpPayload<"readKey", JsonValue>; + | MapOpPayload<"readKey", JsonValue> + | MapOpPayload<"profile", CoID>; if (tx.changes.length !== 1) { console.warn("Team transaction must have exactly one change"); continue; @@ -94,6 +95,14 @@ export function determineValidTransactions( continue; } + validTransactions.push({ txID: { sessionID, txIndex }, tx }); + continue; + } else if (change.key === 'profile') { + if (memberState[transactor] !== "admin") { + console.warn("Only admins can set profile"); + continue; + } + validTransactions.push({ txID: { sessionID, txIndex }, tx }); continue; } else if (isKeyForKeyField(change.key) || isKeyForAccountField(change.key)) { @@ -205,6 +214,7 @@ export function determineValidTransactions( } export type TeamContent = { + profile: CoID | null; [key: AccountIDOrAgentID]: Role; readKey: KeyID; [revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed; @@ -343,7 +353,7 @@ export class Team { } createMap( - meta?: M + meta?: Meta ): CoMap { return this.node .createCoValue({ From 4617b6a125f62ea793d21f9a6e037c94f66b1b9c Mon Sep 17 00:00:00 2001 From: Anselm Date: Tue, 15 Aug 2023 12:20:13 +0100 Subject: [PATCH 45/47] Add and implement loading an account on node creation --- src/account.test.ts | 49 ++++++++++++--- src/node.ts | 25 +++++++- src/sync.test.ts | 148 +------------------------------------------ src/testUtils.ts | 150 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 157 deletions(-) diff --git a/src/account.test.ts b/src/account.test.ts index 640aacccd..12a1acc18 100644 --- a/src/account.test.ts +++ b/src/account.test.ts @@ -1,20 +1,26 @@ +import { newRandomSessionID } from "./coValue.js"; import { LocalNode } from "./node.js"; +import { connectedPeers } from "./testUtils.js"; test("Can create a node while creating a new account with profile", async () => { - const { node, account, accountSecret, sessionID } = + const { node, accountID, accountSecret, sessionID } = LocalNode.withNewlyCreatedAccount("Hermes Puggington"); expect(node).not.toBeNull(); - expect(account).not.toBeNull(); + expect(accountID).not.toBeNull(); expect(accountSecret).not.toBeNull(); expect(sessionID).not.toBeNull(); - expect((node.expectProfileLoaded(account)).get("name")).toEqual("Hermes Puggington"); - expect((await node.loadProfile(account)).get("name")).toEqual("Hermes Puggington"); + expect(node.expectProfileLoaded(accountID).get("name")).toEqual( + "Hermes Puggington" + ); + expect((await node.loadProfile(accountID)).get("name")).toEqual( + "Hermes Puggington" + ); }); test("A node with an account can create teams and and objects within them", async () => { - const { node, account } = + const { node, accountID } = LocalNode.withNewlyCreatedAccount("Hermes Puggington"); const team = await node.createTeam(); @@ -28,5 +34,34 @@ test("A node with an account can create teams and and objects within them", asyn expect(map.get("foo")).toEqual("bar"); - expect(map.getLastEditor("foo")).toEqual(account); -}); \ No newline at end of file + expect(map.getLastEditor("foo")).toEqual(accountID); +}); + +test("Can create account with one node, and then load it on another", async () => { + const { node, accountID, accountSecret } = + LocalNode.withNewlyCreatedAccount("Hermes Puggington"); + + const team = await node.createTeam(); + expect(team).not.toBeNull(); + + let map = team.createMap(); + map = map.edit((edit) => { + edit.set("foo", "bar", "private"); + expect(edit.get("foo")).toEqual("bar"); + }); + + const [node1asPeer, node2asPeer] = connectedPeers("node1", "node2", {trace: true, peer1role: "server", peer2role: "client"}); + + node.sync.addPeer(node2asPeer); + + const node2 = await LocalNode.withLoadedAccount( + accountID, + accountSecret, + newRandomSessionID(accountID), + [node1asPeer] + ); + + const map2 = await node2.load(map.id); + + expect(map2.get("foo")).toEqual("bar"); +}); diff --git a/src/node.ts b/src/node.ts index 1778fabbb..7279cc245 100644 --- a/src/node.ts +++ b/src/node.ts @@ -10,7 +10,7 @@ import { } from "./crypto.js"; import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js"; import { Team, TeamContent, expectTeamContent } from "./permissions.js"; -import { SyncManager } from "./sync.js"; +import { Peer, SyncManager } from "./sync.js"; import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js"; import { CoID, ContentType } from "./contentType.js"; import { @@ -45,7 +45,7 @@ export class LocalNode { static withNewlyCreatedAccount(name: string): { node: LocalNode; - account: AccountID; + accountID: AccountID; accountSecret: AgentSecret; sessionID: SessionID; } { @@ -64,12 +64,31 @@ export class LocalNode { return { node: nodeWithAccount, - account: account.id, + accountID: account.id, accountSecret: account.agentSecret, sessionID: nodeWithAccount.ownSessionID, }; } + static async withLoadedAccount(accountID: AccountID, accountSecret: AgentSecret, sessionID: SessionID, peersToLoadFrom: Peer[]): Promise { + const loadingNode = new LocalNode(new AnonymousControlledAccount(accountSecret), newRandomSessionID(accountID)); + + const accountPromise = loadingNode.load(accountID); + + for (const peer of peersToLoadFrom) { + loadingNode.sync.addPeer(peer); + } + + const account = await accountPromise; + + // since this is all synchronous, we can just swap out nodes for the SyncManager + const node = loadingNode.testWithDifferentAccount(new ControlledAccount(accountSecret, account, loadingNode), sessionID); + node.sync = loadingNode.sync; + node.sync.local = node; + + return node; + } + createCoValue(header: CoValueHeader): CoValue { const coValue = new CoValue(header, this); this.coValues[coValue.id] = { state: "loaded", coValue: coValue }; diff --git a/src/sync.test.ts b/src/sync.test.ts index d85143d15..92edce4d1 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -9,7 +9,7 @@ import { WritableStream, TransformStream, } from "isomorphic-streams"; -import { randomAnonymousAccountAndSessionID } from "./testUtils.js"; +import { connectedPeers, newStreamPair, randomAnonymousAccountAndSessionID, shouldNotResolve } from "./testUtils.js"; import { AccountID } from "./account.js"; test("Node replies with initial tx and header to empty subscribe", async () => { @@ -1124,150 +1124,4 @@ function admStateEx(adminID: AccountID) { }; } -function newStreamPair(): [ReadableStream, WritableStream] { - const queue: T[] = []; - let resolveNextItemReady: () => void = () => {}; - let nextItemReady: Promise = new Promise((resolve) => { - resolveNextItemReady = resolve; - }); - let writerClosed = false; - let readerClosed = false; - - const readable = new ReadableStream({ - async pull(controller) { - let retriesLeft = 3; - while (retriesLeft > 0) { - if (writerClosed) { - controller.close(); - return; - } - retriesLeft--; - if (queue.length > 0) { - controller.enqueue(queue.shift()!); - if (queue.length === 0) { - nextItemReady = new Promise((resolve) => { - resolveNextItemReady = resolve; - }); - } - return; - } else { - await nextItemReady; - } - } - throw new Error( - "Should only use one retry to get next item in queue." - ); - }, - - cancel(reason) { - console.log("Manually closing reader"); - readerClosed = true; - }, - }); - - const writable = new WritableStream({ - write(chunk, controller) { - if (readerClosed) { - console.log("Reader closed, not writing chunk", chunk); - throw new Error("Reader closed, not writing chunk"); - } - queue.push(chunk); - if (queue.length === 1) { - // make sure that await write resolves before corresponding read - process.nextTick(() => resolveNextItemReady()); - } - }, - abort(reason) { - console.log("Manually closing writer"); - writerClosed = true; - resolveNextItemReady(); - return Promise.resolve(); - }, - }); - - return [readable, writable]; -} - -function shouldNotResolve( - promise: Promise, - ops: { timeout: number } -): Promise { - return new Promise((resolve, reject) => { - promise - .then((v) => - reject( - new Error( - "Should not have resolved, but resolved to " + - JSON.stringify(v) - ) - ) - ) - .catch(reject); - setTimeout(resolve, ops.timeout); - }); -} - -function connectedPeers( - peer1id: PeerID, - peer2id: PeerID, - { - trace = false, - peer1role = "peer", - peer2role = "peer", - }: { - trace?: boolean; - peer1role?: Peer["role"]; - peer2role?: Peer["role"]; - } = {} -): [Peer, Peer] { - const [inRx1, inTx1] = newStreamPair(); - const [outRx1, outTx1] = newStreamPair(); - - const [inRx2, inTx2] = newStreamPair(); - const [outRx2, outTx2] = newStreamPair(); - - void outRx2 - .pipeThrough( - new TransformStream({ - transform( - chunk: SyncMessage, - controller: { enqueue: (msg: SyncMessage) => void } - ) { - trace && console.log(`${peer2id} -> ${peer1id}`, chunk); - controller.enqueue(chunk); - }, - }) - ) - .pipeTo(inTx1); - - void outRx1 - .pipeThrough( - new TransformStream({ - transform( - chunk: SyncMessage, - controller: { enqueue: (msg: SyncMessage) => void } - ) { - trace && console.log(`${peer1id} -> ${peer2id}`, chunk); - controller.enqueue(chunk); - }, - }) - ) - .pipeTo(inTx2); - - const peer2AsPeer: Peer = { - id: peer2id, - incoming: inRx1, - outgoing: outTx1, - role: peer2role, - }; - - const peer1AsPeer: Peer = { - id: peer1id, - incoming: inRx2, - outgoing: outTx2, - role: peer1role, - }; - - return [peer1AsPeer, peer2AsPeer]; -} diff --git a/src/testUtils.ts b/src/testUtils.ts index 1264798a5..18c3f5e27 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -4,6 +4,8 @@ import { LocalNode } from "./node.js"; import { expectTeamContent } from "./permissions.js"; import { AnonymousControlledAccount } from "./account.js"; import { SessionID } from "./ids.js"; +import { ReadableStream, TransformStream, WritableStream } from "isomorphic-streams"; +import { Peer, PeerID, SyncMessage } from "./sync.js"; export function randomAnonymousAccountAndSessionID(): [AnonymousControlledAccount, SessionID] { const agentSecret = newRandomAgentSecret(); @@ -76,4 +78,152 @@ export function teamWithTwoAdminsHighLevel() { team.addMember(otherAdmin.id, "admin"); return { admin, node, team, otherAdmin }; +} + +export function newStreamPair(): [ReadableStream, WritableStream] { + const queue: T[] = []; + let resolveNextItemReady: () => void = () => {}; + let nextItemReady: Promise = new Promise((resolve) => { + resolveNextItemReady = resolve; + }); + + let writerClosed = false; + let readerClosed = false; + + const readable = new ReadableStream({ + async pull(controller) { + let retriesLeft = 3; + while (retriesLeft > 0) { + if (writerClosed) { + controller.close(); + return; + } + retriesLeft--; + if (queue.length > 0) { + controller.enqueue(queue.shift()!); + if (queue.length === 0) { + nextItemReady = new Promise((resolve) => { + resolveNextItemReady = resolve; + }); + } + return; + } else { + await nextItemReady; + } + } + throw new Error( + "Should only use one retry to get next item in queue." + ); + }, + + cancel(reason) { + console.log("Manually closing reader"); + readerClosed = true; + }, + }); + + const writable = new WritableStream({ + write(chunk, controller) { + if (readerClosed) { + console.log("Reader closed, not writing chunk", chunk); + throw new Error("Reader closed, not writing chunk"); + } + queue.push(chunk); + if (queue.length === 1) { + // make sure that await write resolves before corresponding read + process.nextTick(() => resolveNextItemReady()); + } + }, + abort(reason) { + console.log("Manually closing writer"); + writerClosed = true; + resolveNextItemReady(); + return Promise.resolve(); + }, + }); + + return [readable, writable]; +} + +export function shouldNotResolve( + promise: Promise, + ops: { timeout: number } +): Promise { + return new Promise((resolve, reject) => { + promise + .then((v) => + reject( + new Error( + "Should not have resolved, but resolved to " + + JSON.stringify(v) + ) + ) + ) + .catch(reject); + setTimeout(resolve, ops.timeout); + }); +} + +export function connectedPeers( + peer1id: PeerID, + peer2id: PeerID, + { + trace = false, + peer1role = "peer", + peer2role = "peer", + }: { + trace?: boolean; + peer1role?: Peer["role"]; + peer2role?: Peer["role"]; + } = {} +): [Peer, Peer] { + const [inRx1, inTx1] = newStreamPair(); + const [outRx1, outTx1] = newStreamPair(); + + const [inRx2, inTx2] = newStreamPair(); + const [outRx2, outTx2] = newStreamPair(); + + void outRx2 + .pipeThrough( + new TransformStream({ + transform( + chunk: SyncMessage, + controller: { enqueue: (msg: SyncMessage) => void } + ) { + trace && console.log(`${peer2id} -> ${peer1id}`, JSON.stringify(chunk, null, 2)); + controller.enqueue(chunk); + }, + }) + ) + .pipeTo(inTx1); + + void outRx1 + .pipeThrough( + new TransformStream({ + transform( + chunk: SyncMessage, + controller: { enqueue: (msg: SyncMessage) => void } + ) { + trace && console.log(`${peer1id} -> ${peer2id}`, JSON.stringify(chunk, null, 2)); + controller.enqueue(chunk); + }, + }) + ) + .pipeTo(inTx2); + + const peer2AsPeer: Peer = { + id: peer2id, + incoming: inRx1, + outgoing: outTx1, + role: peer2role, + }; + + const peer1AsPeer: Peer = { + id: peer1id, + incoming: inRx2, + outgoing: outTx2, + role: peer1role, + }; + + return [peer1AsPeer, peer2AsPeer]; } \ No newline at end of file From a5a11569f8f95b988cf1058f5eb77ed58aa71cf1 Mon Sep 17 00:00:00 2001 From: Anselm Date: Tue, 15 Aug 2023 15:07:37 +0100 Subject: [PATCH 46/47] Remove need to communicate session hash --- package.json | 2 +- src/coValue.ts | 13 ++++++------- src/sync.test.ts | 16 ++++++---------- src/sync.ts | 4 +--- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index de210388f..0eeca5659 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "types": "src/index.ts", "type": "module", "license": "MIT", - "version": "0.0.11", + "version": "0.0.12", "devDependencies": { "@types/jest": "^29.5.3", "@typescript-eslint/eslint-plugin": "^6.2.1", diff --git a/src/coValue.ts b/src/coValue.ts index afea7cbeb..408b0c632 100644 --- a/src/coValue.ts +++ b/src/coValue.ts @@ -148,7 +148,7 @@ export class CoValue { tryAddTransactions( sessionID: SessionID, newTransactions: Transaction[], - newHash: Hash, + givenExpectedNewHash: Hash | undefined, newSignature: Signature ): boolean { const signerID = getAgentSignerID( @@ -171,16 +171,16 @@ export class CoValue { newTransactions ); - if (newHash !== expectedNewHash) { - console.warn("Invalid hash", { newHash, expectedNewHash }); + if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) { + console.warn("Invalid hash", { expectedNewHash, givenExpectedNewHash }); return false; } - if (!verify(newSignature, newHash, signerID)) { + if (!verify(newSignature, expectedNewHash, signerID)) { console.warn( "Invalid signature", newSignature, - newHash, + expectedNewHash, signerID ); return false; @@ -192,7 +192,7 @@ export class CoValue { this.sessions[sessionID] = { transactions, - lastHash: newHash, + lastHash: expectedNewHash, streamingHash: newStreamingHash, lastSignature: newSignature, }; @@ -517,7 +517,6 @@ export class CoValue { sessionID as SessionID ] || 0, newTransactions, - lastHash: log.lastHash, lastSignature: log.lastSignature, }, ]; diff --git a/src/sync.test.ts b/src/sync.test.ts index 92edce4d1..880703cd2 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -9,7 +9,12 @@ import { WritableStream, TransformStream, } from "isomorphic-streams"; -import { connectedPeers, newStreamPair, randomAnonymousAccountAndSessionID, shouldNotResolve } from "./testUtils.js"; +import { + connectedPeers, + newStreamPair, + randomAnonymousAccountAndSessionID, + shouldNotResolve, +} from "./testUtils.js"; import { AccountID } from "./account.js"; test("Node replies with initial tx and header to empty subscribe", async () => { @@ -86,7 +91,6 @@ test("Node replies with initial tx and header to empty subscribe", async () => { ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, @@ -165,7 +169,6 @@ test("Node replies with only new tx to subscribe with some known state", async ( ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, @@ -255,7 +258,6 @@ test("After subscribing, node sends own known state and new txs to peer", async ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, @@ -288,7 +290,6 @@ test("After subscribing, node sends own known state and new txs to peer", async ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, @@ -368,7 +369,6 @@ test("Client replies with known new content to tellKnownState from server", asyn ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, @@ -472,7 +472,6 @@ test("No matter the optimistic known state, node respects invalid known state me ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, @@ -576,7 +575,6 @@ test("If we add a server peer, all updates to all coValues are sent to it, even ], }, ], - lastHash: map.coValue.sessions[node.ownSessionID]!.lastHash!, lastSignature: map.coValue.sessions[node.ownSessionID]!.lastSignature!, }, @@ -1123,5 +1121,3 @@ function admStateEx(adminID: AccountID) { id: adminID, }; } - - diff --git a/src/sync.ts b/src/sync.ts index 2241806c5..c99b01bc2 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -52,8 +52,6 @@ export type NewContentMessage = { export type SessionNewContent = { after: number; newTransactions: Transaction[]; - // TODO: is lastHash needed here? - lastHash: Hash; lastSignature: Signature; }; export type DoneMessage = { @@ -426,7 +424,7 @@ export class SyncManager { const success = coValue.tryAddTransactions( sessionID, newTransactions, - newContentForSession.lastHash, + undefined, newContentForSession.lastSignature ); From bb9c4f8ff85ccdc3fc393ae2f69769cf3ffd00d2 Mon Sep 17 00:00:00 2001 From: Anselm Date: Tue, 15 Aug 2023 17:55:46 +0100 Subject: [PATCH 47/47] Various small fixes --- package.json | 2 +- src/account.test.ts | 2 +- src/index.ts | 2 + src/streamUtils.ts | 130 ++++++++++++++++++++++++++++++++++++++++++++ src/sync.test.ts | 6 +- src/sync.ts | 2 +- src/testUtils.ts | 130 -------------------------------------------- 7 files changed, 139 insertions(+), 135 deletions(-) create mode 100644 src/streamUtils.ts diff --git a/package.json b/package.json index 0eeca5659..65796aee1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "types": "src/index.ts", "type": "module", "license": "MIT", - "version": "0.0.12", + "version": "0.0.14", "devDependencies": { "@types/jest": "^29.5.3", "@typescript-eslint/eslint-plugin": "^6.2.1", diff --git a/src/account.test.ts b/src/account.test.ts index 12a1acc18..6298e7d27 100644 --- a/src/account.test.ts +++ b/src/account.test.ts @@ -1,6 +1,6 @@ import { newRandomSessionID } from "./coValue.js"; import { LocalNode } from "./node.js"; -import { connectedPeers } from "./testUtils.js"; +import { connectedPeers } from "./streamUtils.js"; test("Can create a node while creating a new account with profile", async () => { const { node, accountID, accountSecret, sessionID } = diff --git a/src/index.ts b/src/index.ts index b8709ed02..d0b552a29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { CoValue, newRandomSessionID } from "./coValue.js"; import { LocalNode } from "./node.js"; import { CoMap } from "./contentTypes/coMap.js"; import { agentSecretFromBytes, agentSecretToBytes } from "./crypto.js"; +import { connectedPeers } from "./streamUtils.js"; import type { SessionID } from "./ids.js"; import type { CoID, ContentType } from "./contentType.js"; @@ -15,6 +16,7 @@ const internals = { agentSecretFromBytes, agentSecretToBytes, newRandomSessionID, + connectedPeers }; export { LocalNode, CoValue, CoMap, internals }; diff --git a/src/streamUtils.ts b/src/streamUtils.ts new file mode 100644 index 000000000..c3656d60f --- /dev/null +++ b/src/streamUtils.ts @@ -0,0 +1,130 @@ +import { ReadableStream, TransformStream, WritableStream } from "isomorphic-streams"; +import { Peer, PeerID, SyncMessage } from "./sync.js"; + + +export function connectedPeers( + peer1id: PeerID, + peer2id: PeerID, + { + trace = false, peer1role = "peer", peer2role = "peer", + }: { + trace?: boolean; + peer1role?: Peer["role"]; + peer2role?: Peer["role"]; + } = {} +): [Peer, Peer] { + const [inRx1, inTx1] = newStreamPair(); + const [outRx1, outTx1] = newStreamPair(); + + const [inRx2, inTx2] = newStreamPair(); + const [outRx2, outTx2] = newStreamPair(); + + void outRx2 + .pipeThrough( + new TransformStream({ + transform( + chunk: SyncMessage, + controller: { enqueue: (msg: SyncMessage) => void; } + ) { + trace && console.log(`${peer2id} -> ${peer1id}`, JSON.stringify(chunk, null, 2)); + controller.enqueue(chunk); + }, + }) + ) + .pipeTo(inTx1); + + void outRx1 + .pipeThrough( + new TransformStream({ + transform( + chunk: SyncMessage, + controller: { enqueue: (msg: SyncMessage) => void; } + ) { + trace && console.log(`${peer1id} -> ${peer2id}`, JSON.stringify(chunk, null, 2)); + controller.enqueue(chunk); + }, + }) + ) + .pipeTo(inTx2); + + const peer2AsPeer: Peer = { + id: peer2id, + incoming: inRx1, + outgoing: outTx1, + role: peer2role, + }; + + const peer1AsPeer: Peer = { + id: peer1id, + incoming: inRx2, + outgoing: outTx2, + role: peer1role, + }; + + return [peer1AsPeer, peer2AsPeer]; +} + +export function newStreamPair(): [ReadableStream, WritableStream] { + const queue: T[] = []; + let resolveNextItemReady: () => void = () => { }; + let nextItemReady: Promise = new Promise((resolve) => { + resolveNextItemReady = resolve; + }); + + let writerClosed = false; + let readerClosed = false; + + const readable = new ReadableStream({ + async pull(controller) { + let retriesLeft = 3; + while (retriesLeft > 0) { + if (writerClosed) { + controller.close(); + return; + } + retriesLeft--; + if (queue.length > 0) { + controller.enqueue(queue.shift()!); + if (queue.length === 0) { + nextItemReady = new Promise((resolve) => { + resolveNextItemReady = resolve; + }); + } + return; + } else { + await nextItemReady; + } + } + throw new Error( + "Should only use one retry to get next item in queue." + ); + }, + + cancel(reason) { + console.log("Manually closing reader"); + readerClosed = true; + }, + }); + + const writable = new WritableStream({ + write(chunk, controller) { + if (readerClosed) { + console.log("Reader closed, not writing chunk", chunk); + throw new Error("Reader closed, not writing chunk"); + } + queue.push(chunk); + if (queue.length === 1) { + // make sure that await write resolves before corresponding read + setTimeout(() => resolveNextItemReady()); + } + }, + abort(reason) { + console.log("Manually closing writer"); + writerClosed = true; + resolveNextItemReady(); + return Promise.resolve(); + }, + }); + + return [readable, writable]; +} diff --git a/src/sync.test.ts b/src/sync.test.ts index 880703cd2..543b80734 100644 --- a/src/sync.test.ts +++ b/src/sync.test.ts @@ -10,11 +10,13 @@ import { TransformStream, } from "isomorphic-streams"; import { - connectedPeers, - newStreamPair, randomAnonymousAccountAndSessionID, shouldNotResolve, } from "./testUtils.js"; +import { + connectedPeers, + newStreamPair +} from "./streamUtils.js"; import { AccountID } from "./account.js"; test("Node replies with initial tx and header to empty subscribe", async () => { diff --git a/src/sync.ts b/src/sync.ts index c99b01bc2..78d0385d6 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -285,7 +285,7 @@ export class SyncManager { trySendToPeer(peer: PeerState, msg: SyncMessage) { return peer.outgoing.write(msg).catch((e) => { - console.error("Error writing to peer, disconnecting", e); + console.error(new Error("Error writing to peer, disconnecting", {cause: e})); delete this.peers[peer.id]; }); } diff --git a/src/testUtils.ts b/src/testUtils.ts index 18c3f5e27..1785bb082 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -4,8 +4,6 @@ import { LocalNode } from "./node.js"; import { expectTeamContent } from "./permissions.js"; import { AnonymousControlledAccount } from "./account.js"; import { SessionID } from "./ids.js"; -import { ReadableStream, TransformStream, WritableStream } from "isomorphic-streams"; -import { Peer, PeerID, SyncMessage } from "./sync.js"; export function randomAnonymousAccountAndSessionID(): [AnonymousControlledAccount, SessionID] { const agentSecret = newRandomAgentSecret(); @@ -80,71 +78,6 @@ export function teamWithTwoAdminsHighLevel() { return { admin, node, team, otherAdmin }; } -export function newStreamPair(): [ReadableStream, WritableStream] { - const queue: T[] = []; - let resolveNextItemReady: () => void = () => {}; - let nextItemReady: Promise = new Promise((resolve) => { - resolveNextItemReady = resolve; - }); - - let writerClosed = false; - let readerClosed = false; - - const readable = new ReadableStream({ - async pull(controller) { - let retriesLeft = 3; - while (retriesLeft > 0) { - if (writerClosed) { - controller.close(); - return; - } - retriesLeft--; - if (queue.length > 0) { - controller.enqueue(queue.shift()!); - if (queue.length === 0) { - nextItemReady = new Promise((resolve) => { - resolveNextItemReady = resolve; - }); - } - return; - } else { - await nextItemReady; - } - } - throw new Error( - "Should only use one retry to get next item in queue." - ); - }, - - cancel(reason) { - console.log("Manually closing reader"); - readerClosed = true; - }, - }); - - const writable = new WritableStream({ - write(chunk, controller) { - if (readerClosed) { - console.log("Reader closed, not writing chunk", chunk); - throw new Error("Reader closed, not writing chunk"); - } - queue.push(chunk); - if (queue.length === 1) { - // make sure that await write resolves before corresponding read - process.nextTick(() => resolveNextItemReady()); - } - }, - abort(reason) { - console.log("Manually closing writer"); - writerClosed = true; - resolveNextItemReady(); - return Promise.resolve(); - }, - }); - - return [readable, writable]; -} - export function shouldNotResolve( promise: Promise, ops: { timeout: number } @@ -164,66 +97,3 @@ export function shouldNotResolve( }); } -export function connectedPeers( - peer1id: PeerID, - peer2id: PeerID, - { - trace = false, - peer1role = "peer", - peer2role = "peer", - }: { - trace?: boolean; - peer1role?: Peer["role"]; - peer2role?: Peer["role"]; - } = {} -): [Peer, Peer] { - const [inRx1, inTx1] = newStreamPair(); - const [outRx1, outTx1] = newStreamPair(); - - const [inRx2, inTx2] = newStreamPair(); - const [outRx2, outTx2] = newStreamPair(); - - void outRx2 - .pipeThrough( - new TransformStream({ - transform( - chunk: SyncMessage, - controller: { enqueue: (msg: SyncMessage) => void } - ) { - trace && console.log(`${peer2id} -> ${peer1id}`, JSON.stringify(chunk, null, 2)); - controller.enqueue(chunk); - }, - }) - ) - .pipeTo(inTx1); - - void outRx1 - .pipeThrough( - new TransformStream({ - transform( - chunk: SyncMessage, - controller: { enqueue: (msg: SyncMessage) => void } - ) { - trace && console.log(`${peer1id} -> ${peer2id}`, JSON.stringify(chunk, null, 2)); - controller.enqueue(chunk); - }, - }) - ) - .pipeTo(inTx2); - - const peer2AsPeer: Peer = { - id: peer2id, - incoming: inRx1, - outgoing: outTx1, - role: peer2role, - }; - - const peer1AsPeer: Peer = { - id: peer1id, - incoming: inRx2, - outgoing: outTx2, - role: peer1role, - }; - - return [peer1AsPeer, peer2AsPeer]; -} \ No newline at end of file