Merge remote-tracking branch 'cojson/main'
This commit is contained in:
21
.eslintrc.cjs
Normal file
21
.eslintrc.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:require-extensions/recommended",
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint", "require-extensions"],
|
||||
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",
|
||||
},
|
||||
};
|
||||
171
.gitignore
vendored
Normal file
171
.gitignore
vendored
Normal file
@@ -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
|
||||
2
.npmignore
Normal file
2
.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
coverage
|
||||
node_modules
|
||||
19
LICENSE.txt
Normal file
19
LICENSE.txt
Normal file
@@ -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.
|
||||
53
README.md
Normal file
53
README.md
Normal file
@@ -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 (`CoID`, `AgentID`, `SessionID`, `SignerID`, `SignerSecret`, `Signature`, `SealerID`, `SealerSecret`, `Sealed`, `Hash`, `ShortHash`, `KeySecret`, `KeyID`, `Encrypted`, `Role`)
|
||||
|
||||
- array
|
||||
- object
|
||||
|
||||
### `Collaborative` Values
|
||||
- CoMap (`string` → `Immutable`, last-writer-wins per key)
|
||||
- Team (`AgentID` → `Role`)
|
||||
- CoList (`Immutable[]`, addressable positions, insertAfter semantics)
|
||||
- Agent (`{signerID, sealerID}[]`)
|
||||
- CoStream (independent per-session streams of `Immutable`s)
|
||||
- Static (single addressable `Immutable`)
|
||||
|
||||
## Implementation Abstractions
|
||||
- CoValue
|
||||
- Session Logs
|
||||
- Transactions
|
||||
- Private (encrypted) transactions
|
||||
- Trusting (unencrypted) transactions
|
||||
- Rulesets
|
||||
- CoValue Content Types
|
||||
- LocalNode
|
||||
- Peers
|
||||
- AgentCredentials
|
||||
- Peer
|
||||
|
||||
## Extensions & higher-level protocols
|
||||
|
||||
### More complex datastructures
|
||||
- CoText: a clean way to collaboratively mark up rich text with CoJSON
|
||||
- CoJSON Tree: a clean way to represent collaborative tree structures with CoJSON
|
||||
55
package.json
Normal file
55
package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "cojson",
|
||||
"module": "dist/index.js",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.0.14",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
"@typescript-eslint/parser": "^6.2.1",
|
||||
"eslint": "^8.46.0",
|
||||
"eslint-plugin-require-extensions": "^0.1.3",
|
||||
"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",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "node",
|
||||
"transform": {
|
||||
"\\.[jt]sx?$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"useESM": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"(.+)\\.js": "$1"
|
||||
},
|
||||
"extensionsToTreatAsEsm": [
|
||||
".ts"
|
||||
],
|
||||
"modulePathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"/dist/"
|
||||
]
|
||||
}
|
||||
}
|
||||
67
src/account.test.ts
Normal file
67
src/account.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { newRandomSessionID } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
|
||||
test("Can create a node while creating a new account with profile", async () => {
|
||||
const { node, accountID, accountSecret, sessionID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
expect(node).not.toBeNull();
|
||||
expect(accountID).not.toBeNull();
|
||||
expect(accountSecret).not.toBeNull();
|
||||
expect(sessionID).not.toBeNull();
|
||||
|
||||
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, accountID } =
|
||||
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(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");
|
||||
});
|
||||
152
src/account.ts
Normal file
152
src/account.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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 {
|
||||
const agent = getAgentID(agentSecret);
|
||||
return {
|
||||
type: "comap",
|
||||
ruleset: { type: "team", initialAdmin: agent },
|
||||
meta: {
|
||||
type: "account",
|
||||
},
|
||||
createdAt: null,
|
||||
uniqueness: null,
|
||||
};
|
||||
}
|
||||
|
||||
export class Account extends Team {
|
||||
get id(): AccountID {
|
||||
return this.teamMap.id as AccountID;
|
||||
}
|
||||
|
||||
getCurrentAgentID(): AgentID {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
return agents[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GeneralizedControlledAccount {
|
||||
id: AccountIDOrAgentID;
|
||||
agentSecret: AgentSecret;
|
||||
|
||||
currentAgentID: () => AgentID;
|
||||
currentSignerID: () => SignerID;
|
||||
currentSignerSecret: () => SignerSecret;
|
||||
currentSealerID: () => SealerID;
|
||||
currentSealerSecret: () => SealerSecret;
|
||||
}
|
||||
|
||||
export class ControlledAccount
|
||||
extends Account
|
||||
implements GeneralizedControlledAccount
|
||||
{
|
||||
agentSecret: AgentSecret;
|
||||
|
||||
constructor(
|
||||
agentSecret: AgentSecret,
|
||||
teamMap: CoMap<AccountContent, AccountMeta>,
|
||||
node: LocalNode
|
||||
) {
|
||||
super(teamMap, node);
|
||||
|
||||
this.agentSecret = agentSecret;
|
||||
}
|
||||
|
||||
currentAgentID(): AgentID {
|
||||
return getAgentID(this.agentSecret);
|
||||
}
|
||||
|
||||
currentSignerID(): SignerID {
|
||||
return getAgentSignerID(this.currentAgentID());
|
||||
}
|
||||
|
||||
currentSignerSecret(): SignerSecret {
|
||||
return getAgentSignerSecret(this.agentSecret);
|
||||
}
|
||||
|
||||
currentSealerID(): SealerID {
|
||||
return getAgentSealerID(this.currentAgentID());
|
||||
}
|
||||
|
||||
currentSealerSecret(): SealerSecret {
|
||||
return getAgentSealerSecret(this.agentSecret);
|
||||
}
|
||||
}
|
||||
|
||||
export class AnonymousControlledAccount
|
||||
implements GeneralizedControlledAccount
|
||||
{
|
||||
agentSecret: AgentSecret;
|
||||
|
||||
constructor(agentSecret: AgentSecret) {
|
||||
this.agentSecret = agentSecret;
|
||||
}
|
||||
|
||||
get id(): AgentID {
|
||||
return getAgentID(this.agentSecret);
|
||||
}
|
||||
|
||||
currentAgentID(): AgentID {
|
||||
return getAgentID(this.agentSecret);
|
||||
}
|
||||
|
||||
currentSignerID(): SignerID {
|
||||
return getAgentSignerID(this.currentAgentID());
|
||||
}
|
||||
|
||||
currentSignerSecret(): SignerSecret {
|
||||
return getAgentSignerSecret(this.agentSecret);
|
||||
}
|
||||
|
||||
currentSealerID(): SealerID {
|
||||
return getAgentSealerID(this.currentAgentID());
|
||||
}
|
||||
|
||||
currentSealerSecret(): SealerSecret {
|
||||
return getAgentSealerSecret(this.agentSecret);
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountContent = TeamContent & { profile: CoID<Profile> };
|
||||
export type AccountMeta = { type: "account" };
|
||||
export type AccountID = CoID<CoMap<AccountContent, AccountMeta>>;
|
||||
|
||||
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<ProfileContent, ProfileMeta>;
|
||||
123
src/coValue.test.ts
Normal file
123
src/coValue.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Transaction } from "./coValue.js";
|
||||
import { LocalNode } from "./node.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", () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
[transaction]
|
||||
);
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(account.currentSignerSecret(), expectedNewHash)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("transactions with wrong signature are rejected", () => {
|
||||
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(),
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
[transaction]
|
||||
);
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(getAgentSignerSecret(wrongAgent), expectedNewHash)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "costream",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = coValue.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
[
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "wrong",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(
|
||||
coValue.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(account.currentSignerSecret(), expectedNewHash)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
547
src/coValue.ts
Normal file
547
src/coValue.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
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 {
|
||||
Encrypted,
|
||||
Hash,
|
||||
KeySecret,
|
||||
Signature,
|
||||
StreamingHash,
|
||||
unseal,
|
||||
shortHash,
|
||||
sign,
|
||||
verify,
|
||||
encryptForTransaction,
|
||||
decryptForTransaction,
|
||||
KeyID,
|
||||
decryptKeySecret,
|
||||
getAgentSignerID,
|
||||
getAgentSealerID,
|
||||
} from "./crypto.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import {
|
||||
PermissionsDef as RulesetDef,
|
||||
Team,
|
||||
determineValidTransactions,
|
||||
expectTeamContent,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
||||
import { RawCoID, 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: JsonObject | null;
|
||||
createdAt: `2${string}` | null;
|
||||
uniqueness: `z${string}` | null;
|
||||
};
|
||||
|
||||
export function idforHeader(header: CoValueHeader): RawCoID {
|
||||
const hash = shortHash(header);
|
||||
return `co_z${hash.slice("shortHash_z".length)}`;
|
||||
}
|
||||
|
||||
export function accountOrAgentIDfromSessionID(
|
||||
sessionID: SessionID
|
||||
): AccountIDOrAgentID {
|
||||
return sessionID.split("_session")[0] as AccountIDOrAgentID;
|
||||
}
|
||||
|
||||
export function newRandomSessionID(accountID: AccountIDOrAgentID): SessionID {
|
||||
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
|
||||
}
|
||||
|
||||
type SessionLog = {
|
||||
transactions: Transaction[];
|
||||
lastHash?: Hash;
|
||||
streamingHash: StreamingHash;
|
||||
lastSignature: Signature;
|
||||
};
|
||||
|
||||
export type PrivateTransaction = {
|
||||
privacy: "private";
|
||||
madeAt: number;
|
||||
keyUsed: KeyID;
|
||||
encryptedChanges: Encrypted<
|
||||
JsonValue[],
|
||||
{ in: RawCoID; tx: TransactionID }
|
||||
>;
|
||||
};
|
||||
|
||||
export type TrustingTransaction = {
|
||||
privacy: "trusting";
|
||||
madeAt: number;
|
||||
changes: JsonValue[];
|
||||
};
|
||||
|
||||
export type Transaction = PrivateTransaction | TrustingTransaction;
|
||||
|
||||
export type DecryptedTransaction = {
|
||||
txID: TransactionID;
|
||||
changes: JsonValue[];
|
||||
madeAt: number;
|
||||
};
|
||||
|
||||
export class CoValue {
|
||||
id: RawCoID;
|
||||
node: LocalNode;
|
||||
header: CoValueHeader;
|
||||
sessions: { [key: SessionID]: SessionLog };
|
||||
content?: ContentType;
|
||||
listeners: Set<(content?: ContentType) => void> = new Set();
|
||||
|
||||
constructor(header: CoValueHeader, node: LocalNode) {
|
||||
this.id = idforHeader(header);
|
||||
this.header = header;
|
||||
this.sessions = {};
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
ownSessionID: SessionID
|
||||
): CoValue {
|
||||
const newNode = this.node.testWithDifferentAccount(
|
||||
account,
|
||||
ownSessionID
|
||||
);
|
||||
|
||||
return newNode.expectCoValueLoaded(this.id);
|
||||
}
|
||||
|
||||
knownState(): CoValueKnownState {
|
||||
return {
|
||||
id: this.id,
|
||||
header: true,
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(this.sessions).map(([k, v]) => [
|
||||
k,
|
||||
v.transactions.length,
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
get meta(): JsonValue {
|
||||
return this.header?.meta ?? null;
|
||||
}
|
||||
|
||||
nextTransactionID(): TransactionID {
|
||||
const sessionID = this.node.ownSessionID;
|
||||
return {
|
||||
sessionID,
|
||||
txIndex: this.sessions[sessionID]?.transactions.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
tryAddTransactions(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
givenExpectedNewHash: Hash | undefined,
|
||||
newSignature: Signature
|
||||
): boolean {
|
||||
const signerID = getAgentSignerID(
|
||||
this.node.resolveAccountAgent(
|
||||
accountOrAgentIDfromSessionID(sessionID),
|
||||
"Expected to know signer of transaction"
|
||||
)
|
||||
);
|
||||
|
||||
if (!signerID) {
|
||||
console.warn(
|
||||
"Unknown agent",
|
||||
accountOrAgentIDfromSessionID(sessionID)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
|
||||
sessionID,
|
||||
newTransactions
|
||||
);
|
||||
|
||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
||||
console.warn("Invalid hash", { expectedNewHash, givenExpectedNewHash });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!verify(newSignature, expectedNewHash, signerID)) {
|
||||
console.warn(
|
||||
"Invalid signature",
|
||||
newSignature,
|
||||
expectedNewHash,
|
||||
signerID
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
||||
|
||||
transactions.push(...newTransactions);
|
||||
|
||||
this.sessions[sessionID] = {
|
||||
transactions,
|
||||
lastHash: expectedNewHash,
|
||||
streamingHash: newStreamingHash,
|
||||
lastSignature: newSignature,
|
||||
};
|
||||
|
||||
this.content = undefined;
|
||||
|
||||
const content = this.getCurrentContent();
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
listener(content);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
subscribe(listener: (content?: ContentType) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getCurrentContent());
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
expectedNewHashAfter(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[]
|
||||
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
|
||||
const streamingHash =
|
||||
this.sessions[sessionID]?.streamingHash.clone() ??
|
||||
new StreamingHash();
|
||||
for (const transaction of newTransactions) {
|
||||
streamingHash.update(transaction);
|
||||
}
|
||||
|
||||
const newStreamingHash = streamingHash.clone();
|
||||
|
||||
return {
|
||||
expectedNewHash: streamingHash.digest(),
|
||||
newStreamingHash,
|
||||
};
|
||||
}
|
||||
|
||||
makeTransaction(
|
||||
changes: JsonValue[],
|
||||
privacy: "private" | "trusting"
|
||||
): boolean {
|
||||
const madeAt = Date.now();
|
||||
|
||||
let transaction: Transaction;
|
||||
|
||||
if (privacy === "private") {
|
||||
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
|
||||
|
||||
if (!keySecret) {
|
||||
throw new Error(
|
||||
"Can't make transaction without read key secret"
|
||||
);
|
||||
}
|
||||
|
||||
transaction = {
|
||||
privacy: "private",
|
||||
madeAt,
|
||||
keyUsed: keyID,
|
||||
encryptedChanges: encryptForTransaction(changes, keySecret, {
|
||||
in: this.id,
|
||||
tx: this.nextTransactionID(),
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt,
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionID = this.node.ownSessionID;
|
||||
|
||||
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
|
||||
transaction,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
this.node.account.currentSignerSecret(),
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
const success = this.tryAddTransactions(
|
||||
sessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
signature
|
||||
);
|
||||
|
||||
if (success) {
|
||||
void this.node.sync.syncCoValue(this);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
getCurrentContent(): ContentType {
|
||||
if (this.content) {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
if (this.header.type === "comap") {
|
||||
this.content = new CoMap(this);
|
||||
} else if (this.header.type === "colist") {
|
||||
this.content = new CoList(this);
|
||||
} else if (this.header.type === "costream") {
|
||||
this.content = new CoStream(this);
|
||||
} else if (this.header.type === "static") {
|
||||
this.content = new Static(this);
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||
}
|
||||
|
||||
return this.content;
|
||||
}
|
||||
|
||||
getValidSortedTransactions(): DecryptedTransaction[] {
|
||||
const validTransactions = determineValidTransactions(this);
|
||||
|
||||
const allTransactions: DecryptedTransaction[] = validTransactions
|
||||
.map(({ txID, tx }) => {
|
||||
if (tx.privacy === "trusting") {
|
||||
return {
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: tx.changes,
|
||||
};
|
||||
} else {
|
||||
const readKey = this.getReadKey(tx.keyUsed);
|
||||
|
||||
if (!readKey) {
|
||||
return undefined;
|
||||
} else {
|
||||
const decrytedChanges = decryptForTransaction(
|
||||
tx.encryptedChanges,
|
||||
readKey,
|
||||
{
|
||||
in: this.id,
|
||||
tx: txID,
|
||||
}
|
||||
);
|
||||
|
||||
if (!decrytedChanges) {
|
||||
console.error(
|
||||
"Failed to decrypt transaction despite having key"
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
txID,
|
||||
madeAt: tx.madeAt,
|
||||
changes: decrytedChanges,
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x);
|
||||
allTransactions.sort(
|
||||
(a, b) =>
|
||||
a.madeAt - b.madeAt ||
|
||||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
|
||||
a.txID.txIndex - b.txID.txIndex
|
||||
);
|
||||
|
||||
return allTransactions;
|
||||
}
|
||||
|
||||
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
|
||||
if (this.header.ruleset.type === "team") {
|
||||
const content = expectTeamContent(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.get("readKey");
|
||||
|
||||
if (!currentKeyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
const secret = this.getReadKey(currentKeyId);
|
||||
|
||||
return {
|
||||
secret: secret,
|
||||
id: currentKeyId,
|
||||
};
|
||||
} else if (this.header.ruleset.type === "ownedByTeam") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.team)
|
||||
.getCurrentReadKey();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only teams or values owned by teams have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (this.header.ruleset.type === "team") {
|
||||
const content = expectTeamContent(this.getCurrentContent());
|
||||
|
||||
// Try to find key revelation for us
|
||||
|
||||
const readKeyEntry = content.getLastEntry(`${keyID}_for_${this.node.account.id}`);
|
||||
|
||||
if (readKeyEntry) {
|
||||
const revealer = accountOrAgentIDfromSessionID(
|
||||
readKeyEntry.txID.sessionID
|
||||
);
|
||||
const revealerAgent = this.node.resolveAccountAgent(
|
||||
revealer,
|
||||
"Expected to know revealer"
|
||||
);
|
||||
|
||||
const secret = unseal(
|
||||
readKeyEntry.value,
|
||||
this.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: readKeyEntry.txID,
|
||||
}
|
||||
);
|
||||
|
||||
if (secret) return secret as KeySecret;
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
|
||||
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 (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = content.get(field)!;
|
||||
|
||||
const secret = decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret;
|
||||
} else {
|
||||
console.error(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} else if (this.header.ruleset.type === "ownedByTeam") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.team)
|
||||
.getReadKey(keyID);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only teams or values owned by teams have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
newContentSince(
|
||||
knownState: CoValueKnownState | undefined
|
||||
): NewContentMessage | undefined {
|
||||
const newContent: NewContentMessage = {
|
||||
action: "content",
|
||||
id: this.id,
|
||||
header: knownState?.header ? undefined : this.header,
|
||||
new: 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,
|
||||
lastSignature: log.lastSignature,
|
||||
},
|
||||
];
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x)
|
||||
),
|
||||
};
|
||||
|
||||
if (
|
||||
!newContent.header &&
|
||||
Object.keys(newContent.new).length === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return newContent;
|
||||
}
|
||||
|
||||
getDependedOnCoValues(): RawCoID[] {
|
||||
return this.header.ruleset.type === "team"
|
||||
? expectTeamContent(this.getCurrentContent())
|
||||
.keys()
|
||||
.filter((k): k is AccountID => k.startsWith("co_"))
|
||||
: this.header.ruleset.type === "ownedByTeam"
|
||||
? [this.header.ruleset.team]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
175
src/contentType.test.ts
Normal file
175
src/contentType.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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 node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
expect([...content.keys()]).toEqual([]);
|
||||
expect(content.toJSON()).toEqual({});
|
||||
});
|
||||
|
||||
test("Can insert and delete Map entries in edit()", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set("hello", "world", "trusting");
|
||||
expect(editable.get("hello")).toEqual("world");
|
||||
editable.set("foo", "bar", "trusting");
|
||||
expect(editable.get("foo")).toEqual("bar");
|
||||
expect([...editable.keys()]).toEqual(["hello", "foo"]);
|
||||
editable.delete("foo", "trusting");
|
||||
expect(editable.get("foo")).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can get map entry values at different points in time", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
const beforeA = Date.now();
|
||||
while (Date.now() < beforeA + 10) {}
|
||||
editable.set("hello", "A", "trusting");
|
||||
const beforeB = Date.now();
|
||||
while (Date.now() < beforeB + 10) {}
|
||||
editable.set("hello", "B", "trusting");
|
||||
const beforeC = Date.now();
|
||||
while (Date.now() < beforeC + 10) {}
|
||||
editable.set("hello", "C", "trusting");
|
||||
expect(editable.get("hello")).toEqual("C");
|
||||
expect(editable.getAtTime("hello", Date.now())).toEqual("C");
|
||||
expect(editable.getAtTime("hello", beforeA)).toEqual(undefined);
|
||||
expect(editable.getAtTime("hello", beforeB)).toEqual("A");
|
||||
expect(editable.getAtTime("hello", beforeC)).toEqual("B");
|
||||
});
|
||||
});
|
||||
|
||||
test("Can get all historic values of key", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set("hello", "A", "trusting");
|
||||
const txA = editable.getLastTxID("hello");
|
||||
editable.set("hello", "B", "trusting");
|
||||
const txB = editable.getLastTxID("hello");
|
||||
editable.delete("hello", "trusting");
|
||||
const txDel = editable.getLastTxID("hello");
|
||||
editable.set("hello", "C", "trusting");
|
||||
const txC = editable.getLastTxID("hello");
|
||||
expect(editable.getHistory("hello")).toEqual([
|
||||
{
|
||||
txID: txA,
|
||||
value: "A",
|
||||
at: txA && coValue.getTx(txA)?.madeAt,
|
||||
},
|
||||
{
|
||||
txID: txB,
|
||||
value: "B",
|
||||
at: txB && coValue.getTx(txB)?.madeAt,
|
||||
},
|
||||
{
|
||||
txID: txDel,
|
||||
value: undefined,
|
||||
at: txDel && coValue.getTx(txDel)?.madeAt,
|
||||
},
|
||||
{
|
||||
txID: txC,
|
||||
value: "C",
|
||||
at: txC && coValue.getTx(txC)?.madeAt,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can get last tx ID for a key", () => {
|
||||
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
|
||||
|
||||
const coValue = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const content = coValue.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
expect(editable.getLastTxID("hello")).toEqual(undefined);
|
||||
editable.set("hello", "A", "trusting");
|
||||
const sessionID = editable.getLastTxID("hello")?.sessionID;
|
||||
expect(sessionID && accountOrAgentIDfromSessionID(sessionID)).toEqual(
|
||||
node.account.id
|
||||
);
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(0);
|
||||
editable.set("hello", "B", "trusting");
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(1);
|
||||
editable.set("hello", "C", "trusting");
|
||||
expect(editable.getLastTxID("hello")?.txIndex).toEqual(2);
|
||||
});
|
||||
});
|
||||
26
src/contentType.ts
Normal file
26
src/contentType.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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<T extends ContentType> = RawCoID & {
|
||||
readonly __type: T;
|
||||
};
|
||||
|
||||
export type ContentType =
|
||||
| CoMap<{ [key: string]: JsonValue }, JsonObject | null>
|
||||
| CoList<JsonValue, JsonObject | null>
|
||||
| CoStream<JsonValue, JsonObject | null>
|
||||
| Static<JsonObject>;
|
||||
|
||||
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 }, JsonObject | null>;
|
||||
}
|
||||
24
src/contentTypes/coList.ts
Normal file
24
src/contentTypes/coList.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { JsonObject, JsonValue } from '../jsonValue.js';
|
||||
import { CoID } from '../contentType.js';
|
||||
import { CoValue } from '../coValue.js';
|
||||
|
||||
export class CoList<T extends JsonValue, Meta extends JsonObject | null = null> {
|
||||
id: CoID<CoList<T, Meta>>;
|
||||
type = "colist" as const;
|
||||
coValue: CoValue;
|
||||
|
||||
constructor(coValue: CoValue) {
|
||||
this.id = coValue.id as CoID<CoList<T, Meta>>;
|
||||
this.coValue = coValue;
|
||||
}
|
||||
|
||||
toJSON(): JsonObject {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
subscribe(listener: (coMap: CoList<T, Meta>) => void): () => void {
|
||||
return this.coValue.subscribe((content) => {
|
||||
listener(content as CoList<T, Meta>);
|
||||
});
|
||||
}
|
||||
}
|
||||
221
src/contentTypes/coMap.ts
Normal file
221
src/contentTypes/coMap.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { JsonObject, JsonValue } from '../jsonValue.js';
|
||||
import { TransactionID } from '../ids.js';
|
||||
import { CoID } from '../contentType.js';
|
||||
import { CoValue, accountOrAgentIDfromSessionID } from '../coValue.js';
|
||||
import { AccountID, isAccountID } from '../account.js';
|
||||
|
||||
type MapOp<K extends string, V extends JsonValue> = {
|
||||
txID: TransactionID;
|
||||
madeAt: number;
|
||||
changeIdx: number;
|
||||
} & MapOpPayload<K, V>;
|
||||
// TODO: add after TransactionID[] for conflicts/ordering
|
||||
|
||||
export type MapOpPayload<K extends string, V extends JsonValue> = {
|
||||
op: "insert";
|
||||
key: K;
|
||||
value: V;
|
||||
} |
|
||||
{
|
||||
op: "delete";
|
||||
key: K;
|
||||
};
|
||||
|
||||
export type MapK<M extends { [key: string]: JsonValue; }> = keyof M & string;
|
||||
export type MapV<M extends { [key: string]: JsonValue; }> = M[MapK<M>];
|
||||
export type MapM<M extends { [key: string]: JsonValue; }> = {
|
||||
[KK in MapK<M>]: M[KK];
|
||||
}
|
||||
|
||||
export class CoMap<
|
||||
M extends { [key: string]: JsonValue; },
|
||||
Meta extends JsonObject | null = null,
|
||||
> {
|
||||
id: CoID<CoMap<MapM<M>, Meta>>;
|
||||
coValue: CoValue;
|
||||
type = "comap" as const;
|
||||
ops: {
|
||||
[KK in MapK<M>]?: MapOp<KK, M[KK]>[];
|
||||
};
|
||||
|
||||
constructor(coValue: CoValue) {
|
||||
this.id = coValue.id as CoID<CoMap<MapM<M>, Meta>>;
|
||||
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<MapK<M>, MapV<M>>;
|
||||
let entries = this.ops[change.key];
|
||||
if (!entries) {
|
||||
entries = [];
|
||||
this.ops[change.key] = entries;
|
||||
}
|
||||
entries.push({
|
||||
txID,
|
||||
madeAt,
|
||||
changeIdx,
|
||||
...(change as MapOpPayload<MapK<M>, MapV<M>>),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys(): MapK<M>[] {
|
||||
return Object.keys(this.ops) as MapK<M>[];
|
||||
}
|
||||
|
||||
get<K extends MapK<M>>(key: K): M[K] | 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<K extends MapK<M>>(key: K, time: number): M[K] | 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;
|
||||
}
|
||||
}
|
||||
|
||||
getLastEditor<K extends MapK<M>>(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<K extends MapK<M>>(key: K): TransactionID | undefined {
|
||||
const ops = this.ops[key];
|
||||
if (!ops) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastEntry = ops[ops.length - 1]!;
|
||||
|
||||
return lastEntry.txID;
|
||||
}
|
||||
|
||||
getLastEntry<K extends MapK<M>>(key: K): { at: number; txID: TransactionID; value: M[K]; } | 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<K extends MapK<M>>(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[K] | 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<M, Meta>) => void): CoMap<M, Meta> {
|
||||
const editable = new WriteableCoMap<M, Meta>(this.coValue);
|
||||
changer(editable);
|
||||
return new CoMap(this.coValue);
|
||||
}
|
||||
|
||||
subscribe(listener: (coMap: CoMap<M, Meta>) => void): () => void {
|
||||
return this.coValue.subscribe((content) => {
|
||||
listener(content as CoMap<M, Meta>);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableCoMap<
|
||||
M extends { [key: string]: JsonValue; },
|
||||
Meta extends JsonObject | null = null,
|
||||
|
||||
> extends CoMap<M, Meta> {
|
||||
set<K extends MapK<M>>(key: K, value: M[K], privacy: "private" | "trusting" = "private"): void {
|
||||
this.coValue.makeTransaction([
|
||||
{
|
||||
op: "insert",
|
||||
key,
|
||||
value,
|
||||
},
|
||||
], privacy);
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
}
|
||||
|
||||
delete(key: MapK<M>, privacy: "private" | "trusting" = "private"): void {
|
||||
this.coValue.makeTransaction([
|
||||
{
|
||||
op: "delete",
|
||||
key,
|
||||
},
|
||||
], privacy);
|
||||
|
||||
this.fillOpsFromCoValue();
|
||||
}
|
||||
}
|
||||
24
src/contentTypes/coStream.ts
Normal file
24
src/contentTypes/coStream.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { JsonObject, JsonValue } from '../jsonValue.js';
|
||||
import { CoID } from '../contentType.js';
|
||||
import { CoValue } from '../coValue.js';
|
||||
|
||||
export class CoStream<T extends JsonValue, Meta extends JsonObject | null = null> {
|
||||
id: CoID<CoStream<T, Meta>>;
|
||||
type = "costream" as const;
|
||||
coValue: CoValue;
|
||||
|
||||
constructor(coValue: CoValue) {
|
||||
this.id = coValue.id as CoID<CoStream<T, Meta>>;
|
||||
this.coValue = coValue;
|
||||
}
|
||||
|
||||
toJSON(): JsonObject {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
subscribe(listener: (coMap: CoStream<T, Meta>) => void): () => void {
|
||||
return this.coValue.subscribe((content) => {
|
||||
listener(content as CoStream<T, Meta>);
|
||||
});
|
||||
}
|
||||
}
|
||||
22
src/contentTypes/static.ts
Normal file
22
src/contentTypes/static.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { JsonObject } from '../jsonValue.js';
|
||||
import { CoID } from '../contentType.js';
|
||||
import { CoValue } from '../coValue.js';
|
||||
|
||||
export class Static<T extends JsonObject> {
|
||||
id: CoID<Static<T>>;
|
||||
type = "static" as const;
|
||||
coValue: CoValue;
|
||||
|
||||
constructor(coValue: CoValue) {
|
||||
this.id = coValue.id as CoID<Static<T>>;
|
||||
this.coValue = coValue;
|
||||
}
|
||||
|
||||
toJSON(): JsonObject {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
subscribe(_listener: (coMap: Static<T>) => void): () => void {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
183
src/crypto.test.ts
Normal file
183
src/crypto.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
getSealerID,
|
||||
getSignerID,
|
||||
secureHash,
|
||||
newRandomSealer,
|
||||
newRandomSigner,
|
||||
seal,
|
||||
sign,
|
||||
unseal,
|
||||
verify,
|
||||
shortHash,
|
||||
newRandomKeySecret,
|
||||
encryptForTransaction,
|
||||
decryptForTransaction,
|
||||
encryptKeySecret,
|
||||
decryptKeySecret,
|
||||
} from './crypto.js';
|
||||
import { base58, base64url } from "@scure/base";
|
||||
import { x25519 } from "@noble/curves/ed25519";
|
||||
import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
|
||||
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" };
|
||||
const signer = newRandomSigner();
|
||||
const signature = sign(signer, data);
|
||||
|
||||
expect(signature).toMatch(/^signature_z/);
|
||||
expect(
|
||||
verify(signature, { a: "hello", b: "world" }, getSignerID(signer))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Invalid signatures don't verify", () => {
|
||||
const data = { b: "world", a: "hello" };
|
||||
const signer = newRandomSigner();
|
||||
const signer2 = newRandomSigner();
|
||||
const wrongSignature = sign(signer2, data);
|
||||
|
||||
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 = newRandomSealer();
|
||||
const sealer = newRandomSealer();
|
||||
const wrongSealer = newRandomSealer();
|
||||
|
||||
const nOnceMaterial = {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
} as const;
|
||||
|
||||
const sealed = seal(
|
||||
data,
|
||||
sender,
|
||||
getSealerID(sealer),
|
||||
nOnceMaterial
|
||||
);
|
||||
|
||||
expect(
|
||||
unseal(sealed, sealer, getSealerID(sender), nOnceMaterial)
|
||||
).toEqual(data);
|
||||
expect(
|
||||
() => unseal(sealed, wrongSealer, getSealerID(sender), nOnceMaterial)
|
||||
).toThrow(/Wrong tag/);
|
||||
|
||||
// trying with wrong sealer secret, by hand
|
||||
const nOnce = blake3(
|
||||
new TextEncoder().encode(stableStringify(nOnceMaterial))
|
||||
).slice(0, 24);
|
||||
const sealer3priv = base58.decode(
|
||||
wrongSealer.substring("sealerSecret_z".length)
|
||||
);
|
||||
const senderPub = base58.decode(
|
||||
getSealerID(sender).substring("sealer_z".length)
|
||||
);
|
||||
const sealedBytes = base64url.decode(
|
||||
sealed.substring("sealed_U".length)
|
||||
);
|
||||
const sharedSecret = x25519.getSharedSecret(sealer3priv, senderPub);
|
||||
|
||||
expect(() => {
|
||||
const _ = xsalsa20_poly1305(sharedSecret, nOnce).decrypt(sealedBytes);
|
||||
}).toThrow("Wrong tag");
|
||||
});
|
||||
|
||||
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 for transactions round-trips", () => {
|
||||
const { secret } = newRandomKeySecret();
|
||||
|
||||
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
});
|
||||
|
||||
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
});
|
||||
|
||||
const decrypted1 = decryptForTransaction(encrypted1, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
});
|
||||
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_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 encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
});
|
||||
|
||||
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
});
|
||||
|
||||
const decrypted1 = decryptForTransaction(encrypted1, secret2, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
});
|
||||
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret2, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
});
|
||||
|
||||
expect([decrypted1, decrypted2]).toEqual([undefined, undefined]);
|
||||
});
|
||||
|
||||
test("Encryption of keySecrets round-trips", () => {
|
||||
const toEncrypt = newRandomKeySecret();
|
||||
const encrypting = newRandomKeySecret();
|
||||
|
||||
const keys = {
|
||||
toEncrypt,
|
||||
encrypting,
|
||||
};
|
||||
|
||||
const encrypted = encryptKeySecret(keys);
|
||||
|
||||
const decrypted = decryptKeySecret(encrypted, encrypting.secret);
|
||||
|
||||
expect(decrypted).toEqual(toEncrypt.secret);
|
||||
});
|
||||
|
||||
test("Encryption of keySecrets doesn't decrypt with a wrong key", () => {
|
||||
const toEncrypt = newRandomKeySecret();
|
||||
const encrypting = newRandomKeySecret();
|
||||
const encryptingWrong = newRandomKeySecret();
|
||||
|
||||
const keys = {
|
||||
toEncrypt,
|
||||
encrypting,
|
||||
};
|
||||
|
||||
const encrypted = encryptKeySecret(keys);
|
||||
|
||||
const decrypted = decryptKeySecret(encrypted, encryptingWrong.secret);
|
||||
|
||||
expect(decrypted).toBeUndefined();
|
||||
});
|
||||
353
src/crypto.ts
Normal file
353
src/crypto.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { ed25519, x25519 } from "@noble/curves/ed25519";
|
||||
import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa";
|
||||
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 { AgentID, RawCoID, TransactionID } from './ids.js';
|
||||
|
||||
export type SignerSecret = `signerSecret_z${string}`;
|
||||
export type SignerID = `signer_z${string}`;
|
||||
export type Signature = `signature_z${string}`;
|
||||
|
||||
export type SealerSecret = `sealerSecret_z${string}`;
|
||||
export type SealerID = `sealer_z${string}`;
|
||||
export type Sealed<T> = `sealed_U${string}` & { __type: T };
|
||||
|
||||
export type AgentSecret = `${SealerSecret}/${SignerSecret}`;
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export function newRandomSigner(): SignerSecret {
|
||||
return `signerSecret_z${base58.encode(
|
||||
ed25519.utils.randomPrivateKey()
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function signerSecretToBytes(secret: SignerSecret): Uint8Array {
|
||||
return base58.decode(secret.substring("signerSecret_z".length));
|
||||
}
|
||||
|
||||
export function signerSecretFromBytes(bytes: Uint8Array): SignerSecret {
|
||||
return `signerSecret_z${base58.encode(bytes)}`;
|
||||
}
|
||||
|
||||
export function getSignerID(secret: SignerSecret): SignerID {
|
||||
return `signer_z${base58.encode(
|
||||
ed25519.getPublicKey(
|
||||
base58.decode(secret.substring("signerSecret_z".length))
|
||||
)
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function sign(secret: SignerSecret, message: JsonValue): Signature {
|
||||
const signature = ed25519.sign(
|
||||
textEncoder.encode(stableStringify(message)),
|
||||
base58.decode(secret.substring("signerSecret_z".length))
|
||||
);
|
||||
return `signature_z${base58.encode(signature)}`;
|
||||
}
|
||||
|
||||
export function verify(
|
||||
signature: Signature,
|
||||
message: JsonValue,
|
||||
id: SignerID
|
||||
): boolean {
|
||||
return ed25519.verify(
|
||||
base58.decode(signature.substring("signature_z".length)),
|
||||
textEncoder.encode(stableStringify(message)),
|
||||
base58.decode(id.substring("signer_z".length))
|
||||
);
|
||||
}
|
||||
|
||||
export function newRandomSealer(): SealerSecret {
|
||||
return `sealerSecret_z${base58.encode(x25519.utils.randomPrivateKey())}`;
|
||||
}
|
||||
|
||||
export function sealerSecretToBytes(secret: SealerSecret): Uint8Array {
|
||||
return base58.decode(secret.substring("sealerSecret_z".length));
|
||||
}
|
||||
|
||||
export function sealerSecretFromBytes(bytes: Uint8Array): SealerSecret {
|
||||
return `sealerSecret_z${base58.encode(bytes)}`;
|
||||
}
|
||||
|
||||
export function getSealerID(secret: SealerSecret): SealerID {
|
||||
return `sealer_z${base58.encode(
|
||||
x25519.getPublicKey(
|
||||
base58.decode(secret.substring("sealerSecret_z".length))
|
||||
)
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function newRandomAgentSecret(): AgentSecret {
|
||||
return `${newRandomSealer()}/${newRandomSigner()}`;
|
||||
}
|
||||
|
||||
export function agentSecretToBytes(secret: AgentSecret): Uint8Array {
|
||||
const [sealerSecret, signerSecret] = secret.split("/");
|
||||
return new Uint8Array([
|
||||
...sealerSecretToBytes(sealerSecret as SealerSecret),
|
||||
...signerSecretToBytes(signerSecret as SignerSecret),
|
||||
]);
|
||||
}
|
||||
|
||||
export function agentSecretFromBytes(bytes: Uint8Array): AgentSecret {
|
||||
const sealerSecret = sealerSecretFromBytes(
|
||||
bytes.slice(0, 32)
|
||||
);
|
||||
const signerSecret = signerSecretFromBytes(
|
||||
bytes.slice(32)
|
||||
);
|
||||
return `${sealerSecret}/${signerSecret}`;
|
||||
}
|
||||
|
||||
export function getAgentID(secret: AgentSecret): AgentID {
|
||||
const [sealerSecret, signerSecret] = secret.split("/");
|
||||
return `${getSealerID(
|
||||
sealerSecret as SealerSecret
|
||||
)}/${getSignerID(signerSecret as SignerSecret)}`;
|
||||
}
|
||||
|
||||
export function getAgentSignerID(agentId: AgentID): SignerID {
|
||||
return agentId.split("/")[1] as SignerID;
|
||||
}
|
||||
|
||||
export function getAgentSignerSecret(agentSecret: AgentSecret): SignerSecret {
|
||||
return agentSecret.split("/")[1] as SignerSecret;
|
||||
}
|
||||
|
||||
export function getAgentSealerID(agentId: AgentID): SealerID {
|
||||
return agentId.split("/")[0] as SealerID;
|
||||
}
|
||||
|
||||
export function getAgentSealerSecret(agentSecret: AgentSecret): SealerSecret {
|
||||
return agentSecret.split("/")[0] as SealerSecret;
|
||||
}
|
||||
|
||||
export function seal<T extends JsonValue>(
|
||||
message: T,
|
||||
from: SealerSecret,
|
||||
to: SealerID,
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID }
|
||||
): Sealed<T> {
|
||||
const nOnce = blake3(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial))
|
||||
).slice(0, 24);
|
||||
|
||||
const sealerPub = base58.decode(to.substring("sealer_z".length));
|
||||
|
||||
const senderPriv = base58.decode(
|
||||
from.substring("sealerSecret_z".length)
|
||||
);
|
||||
|
||||
const plaintext = textEncoder.encode(stableStringify(message));
|
||||
|
||||
const sharedSecret = x25519.getSharedSecret(
|
||||
senderPriv,
|
||||
sealerPub
|
||||
);
|
||||
|
||||
const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt(
|
||||
plaintext
|
||||
);
|
||||
|
||||
return `sealed_U${base64url.encode(
|
||||
sealedBytes
|
||||
)}` as Sealed<T>
|
||||
}
|
||||
|
||||
export function unseal<T extends JsonValue>(
|
||||
sealed: Sealed<T>,
|
||||
sealer: SealerSecret,
|
||||
from: SealerID,
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID }
|
||||
): T | undefined {
|
||||
const nOnce = blake3(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial))
|
||||
).slice(0, 24);
|
||||
|
||||
const sealerPriv = base58.decode(
|
||||
sealer.substring("sealerSecret_z".length)
|
||||
);
|
||||
|
||||
const senderPub = base58.decode(from.substring("sealer_z".length));
|
||||
|
||||
const sealedBytes = base64url.decode(sealed.substring("sealed_U".length));
|
||||
|
||||
const sharedSecret = x25519.getSharedSecret(sealerPriv, senderPub);
|
||||
|
||||
const plaintext = xsalsa20_poly1305(sharedSecret, nOnce).decrypt(
|
||||
sealedBytes
|
||||
);
|
||||
|
||||
try {
|
||||
return JSON.parse(textDecoder.decode(plaintext));
|
||||
} catch (e) {
|
||||
console.error("Failed to decrypt/parse sealed message", 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<typeof blake3.create>;
|
||||
|
||||
constructor(fromClone?: ReturnType<typeof blake3.create>) {
|
||||
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 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}`;
|
||||
|
||||
export function newRandomKeySecret(): { secret: KeySecret; id: KeyID } {
|
||||
return {
|
||||
secret: `keySecret_z${base58.encode(randomBytes(32))}`,
|
||||
id: `key_z${base58.encode(randomBytes(12))}`,
|
||||
};
|
||||
}
|
||||
|
||||
function encrypt<T extends JsonValue, N extends JsonValue>(
|
||||
value: T,
|
||||
keySecret: KeySecret,
|
||||
nOnceMaterial: N
|
||||
): Encrypted<T, N> {
|
||||
const keySecretBytes = base58.decode(
|
||||
keySecret.substring("keySecret_z".length)
|
||||
);
|
||||
const nOnce = blake3(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial))
|
||||
).slice(0, 24);
|
||||
|
||||
const plaintext = textEncoder.encode(stableStringify(value));
|
||||
const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext);
|
||||
return `encrypted_U${base64url.encode(ciphertext)}` as Encrypted<T, N>;
|
||||
}
|
||||
|
||||
export function encryptForTransaction<T extends JsonValue>(
|
||||
value: T,
|
||||
keySecret: KeySecret,
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID }
|
||||
): Encrypted<T, { in: RawCoID; tx: TransactionID }> {
|
||||
return encrypt(value, keySecret, nOnceMaterial);
|
||||
}
|
||||
|
||||
export function encryptKeySecret(keys: {
|
||||
toEncrypt: { id: KeyID; secret: KeySecret };
|
||||
encrypting: { id: KeyID; secret: KeySecret };
|
||||
}): {
|
||||
encryptedID: KeyID;
|
||||
encryptingID: KeyID;
|
||||
encrypted: Encrypted<KeySecret, { encryptedID: KeyID; encryptingID: KeyID }>;
|
||||
} {
|
||||
const nOnceMaterial = {
|
||||
encryptedID: keys.toEncrypt.id,
|
||||
encryptingID: keys.encrypting.id,
|
||||
};
|
||||
|
||||
return {
|
||||
encryptedID: keys.toEncrypt.id,
|
||||
encryptingID: keys.encrypting.id,
|
||||
encrypted: encrypt(
|
||||
keys.toEncrypt.secret,
|
||||
keys.encrypting.secret,
|
||||
nOnceMaterial
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function decrypt<T extends JsonValue, N extends JsonValue>(
|
||||
encrypted: Encrypted<T, N>,
|
||||
keySecret: KeySecret,
|
||||
nOnceMaterial: N
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptForTransaction<T extends JsonValue>(
|
||||
encrypted: Encrypted<T, { in: RawCoID; tx: TransactionID }>,
|
||||
keySecret: KeySecret,
|
||||
nOnceMaterial: { in: RawCoID; tx: TransactionID }
|
||||
): T | undefined {
|
||||
return decrypt(encrypted, keySecret, nOnceMaterial);
|
||||
}
|
||||
|
||||
export function decryptKeySecret(
|
||||
encryptedInfo: {
|
||||
encryptedID: KeyID;
|
||||
encryptingID: KeyID;
|
||||
encrypted: Encrypted<KeySecret, { encryptedID: KeyID; encryptingID: KeyID }>;
|
||||
},
|
||||
sealingSecret: KeySecret
|
||||
): KeySecret | undefined {
|
||||
const nOnceMaterial = {
|
||||
encryptedID: encryptedInfo.encryptedID,
|
||||
encryptingID: encryptedInfo.encryptingID,
|
||||
};
|
||||
|
||||
return decrypt(encryptedInfo.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(),
|
||||
}
|
||||
}
|
||||
13
src/ids.ts
Normal file
13
src/ids.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { AccountIDOrAgentID } from './account.js';
|
||||
|
||||
export type RawCoID = `co_z${string}`;
|
||||
|
||||
export type TransactionID = { sessionID: SessionID; txIndex: number };
|
||||
|
||||
export type AgentID = `sealer_z${string}/signer_z${string}`;
|
||||
|
||||
export function isAgentID(id: string): id is AgentID {
|
||||
return typeof id === "string" && id.startsWith("sealer_") && id.includes("/signer_");
|
||||
}
|
||||
|
||||
export type SessionID = `${AccountIDOrAgentID}_session_z${string}`;
|
||||
32
src/index.ts
Normal file
32
src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type { SyncMessage } from "./sync.js";
|
||||
import type { AgentSecret } from "./crypto.js";
|
||||
|
||||
type Value = JsonValue | ContentType;
|
||||
|
||||
const internals = {
|
||||
agentSecretFromBytes,
|
||||
agentSecretToBytes,
|
||||
newRandomSessionID,
|
||||
connectedPeers
|
||||
};
|
||||
|
||||
export { LocalNode, CoValue, CoMap, internals };
|
||||
|
||||
export type {
|
||||
Value,
|
||||
JsonValue,
|
||||
ContentType,
|
||||
CoID,
|
||||
AgentSecret,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
};
|
||||
6
src/jsonValue.ts
Normal file
6
src/jsonValue.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { CoID, ContentType } from './contentType.js';
|
||||
|
||||
export type JsonAtom = string | number | boolean | null;
|
||||
export type JsonValue = JsonAtom | JsonArray | JsonObject | CoID<ContentType>;
|
||||
export type JsonArray = JsonValue[];
|
||||
export type JsonObject = { [key: string]: JsonValue; };
|
||||
324
src/node.ts
Normal file
324
src/node.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
AgentSecret,
|
||||
createdNowUnique,
|
||||
getAgentID,
|
||||
getAgentSealerID,
|
||||
getAgentSealerSecret,
|
||||
newRandomAgentSecret,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
} from "./crypto.js";
|
||||
import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js";
|
||||
import { Team, TeamContent, expectTeamContent } from "./permissions.js";
|
||||
import { Peer, SyncManager } from "./sync.js";
|
||||
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
import {
|
||||
Account,
|
||||
AccountMeta,
|
||||
AccountIDOrAgentID,
|
||||
accountHeaderForInitialAgentSecret,
|
||||
GeneralizedControlledAccount,
|
||||
ControlledAccount,
|
||||
AnonymousControlledAccount,
|
||||
AccountID,
|
||||
Profile,
|
||||
AccountContent,
|
||||
ProfileContent,
|
||||
ProfileMeta,
|
||||
} from "./account.js";
|
||||
import { CoMap } from "./index.js";
|
||||
|
||||
export class LocalNode {
|
||||
coValues: { [key: RawCoID]: CoValueState } = {};
|
||||
account: GeneralizedControlledAccount;
|
||||
ownSessionID: SessionID;
|
||||
sync = new SyncManager(this);
|
||||
|
||||
constructor(
|
||||
account: GeneralizedControlledAccount,
|
||||
ownSessionID: SessionID
|
||||
) {
|
||||
this.account = account;
|
||||
this.ownSessionID = ownSessionID;
|
||||
}
|
||||
|
||||
static withNewlyCreatedAccount(name: string): {
|
||||
node: LocalNode;
|
||||
accountID: 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,
|
||||
accountID: account.id,
|
||||
accountSecret: account.agentSecret,
|
||||
sessionID: nodeWithAccount.ownSessionID,
|
||||
};
|
||||
}
|
||||
|
||||
static async withLoadedAccount(accountID: AccountID, accountSecret: AgentSecret, sessionID: SessionID, peersToLoadFrom: Peer[]): Promise<LocalNode> {
|
||||
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 };
|
||||
|
||||
void this.sync.syncCoValue(coValue);
|
||||
|
||||
return coValue;
|
||||
}
|
||||
|
||||
loadCoValue(id: RawCoID): Promise<CoValue> {
|
||||
let entry = this.coValues[id];
|
||||
if (!entry) {
|
||||
entry = newLoadingState();
|
||||
|
||||
this.coValues[id] = entry;
|
||||
|
||||
this.sync.loadFromPeers(id);
|
||||
}
|
||||
if (entry.state === "loaded") {
|
||||
return Promise.resolve(entry.coValue);
|
||||
}
|
||||
return entry.done;
|
||||
}
|
||||
|
||||
async load<T extends ContentType>(id: CoID<T>): Promise<T> {
|
||||
return (await this.loadCoValue(id)).getCurrentContent() as T;
|
||||
}
|
||||
|
||||
async loadProfile(id: AccountID): Promise<Profile> {
|
||||
const account = await this.load<CoMap<AccountContent>>(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) {
|
||||
throw new Error(
|
||||
`${expectation ? expectation + ": " : ""}Unknown CoValue ${id}`
|
||||
);
|
||||
}
|
||||
if (entry.state === "loading") {
|
||||
throw new Error(
|
||||
`${
|
||||
expectation ? expectation + ": " : ""
|
||||
}CoValue ${id} not yet loaded`
|
||||
);
|
||||
}
|
||||
return entry.coValue;
|
||||
}
|
||||
|
||||
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(
|
||||
accountHeaderForInitialAgentSecret(agentSecret)
|
||||
).testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
newRandomSessionID(getAgentID(agentSecret))
|
||||
);
|
||||
|
||||
const accountAsTeam = new Team(expectTeamContent(account.getCurrentContent()), account.node);
|
||||
|
||||
accountAsTeam.teamMap.edit((editable) => {
|
||||
editable.set(getAgentID(agentSecret), "admin", "trusting");
|
||||
|
||||
const readKey = newRandomKeySecret();
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${getAgentID(agentSecret)}`,
|
||||
seal(
|
||||
readKey.secret,
|
||||
getAgentSealerSecret(agentSecret),
|
||||
getAgentSealerID(getAgentID(agentSecret)),
|
||||
{
|
||||
in: account.id,
|
||||
tx: account.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
|
||||
editable.set("readKey", readKey.id, "trusting");
|
||||
});
|
||||
|
||||
const controlledAccount = new ControlledAccount(
|
||||
agentSecret,
|
||||
account.getCurrentContent() as CoMap<AccountContent, AccountMeta>,
|
||||
account.node
|
||||
);
|
||||
|
||||
const profile = accountAsTeam.createMap<ProfileContent, ProfileMeta>({ 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 {
|
||||
if (isAgentID(id)) {
|
||||
return id;
|
||||
}
|
||||
|
||||
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 account`
|
||||
);
|
||||
}
|
||||
|
||||
return new Account(
|
||||
coValue.getCurrentContent() as CoMap<TeamContent, AccountMeta>,
|
||||
this
|
||||
).getCurrentAgentID();
|
||||
}
|
||||
|
||||
createTeam(): Team {
|
||||
const teamCoValue = this.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "team", initialAdmin: this.account.id },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
let teamContent = expectTeamContent(teamCoValue.getCurrentContent());
|
||||
|
||||
teamContent = teamContent.edit((editable) => {
|
||||
editable.set(this.account.id, "admin", "trusting");
|
||||
|
||||
const readKey = newRandomKeySecret();
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${this.account.id}`,
|
||||
seal(
|
||||
readKey.secret,
|
||||
this.account.currentSealerSecret(),
|
||||
this.account.currentSealerID(),
|
||||
{
|
||||
in: teamCoValue.id,
|
||||
tx: teamCoValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
|
||||
editable.set("readKey", readKey.id, "trusting");
|
||||
});
|
||||
|
||||
return new Team(teamContent, this);
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
ownSessionID: SessionID
|
||||
): LocalNode {
|
||||
const newNode = new LocalNode(account, ownSessionID);
|
||||
|
||||
newNode.coValues = Object.fromEntries(
|
||||
Object.entries(this.coValues)
|
||||
.map(([id, entry]) => {
|
||||
if (entry.state === "loading") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const newCoValue = new CoValue(
|
||||
entry.coValue.header,
|
||||
newNode
|
||||
);
|
||||
|
||||
newCoValue.sessions = entry.coValue.sessions;
|
||||
|
||||
return [id, { state: "loaded", coValue: newCoValue }];
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x)
|
||||
);
|
||||
|
||||
return newNode;
|
||||
}
|
||||
}
|
||||
|
||||
type CoValueState =
|
||||
| {
|
||||
state: "loading";
|
||||
done: Promise<CoValue>;
|
||||
resolve: (coValue: CoValue) => void;
|
||||
}
|
||||
| { state: "loaded"; coValue: CoValue };
|
||||
|
||||
export function newLoadingState(): CoValueState {
|
||||
let resolve: (coValue: CoValue) => void;
|
||||
|
||||
const promise = new Promise<CoValue>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
|
||||
return {
|
||||
state: "loading",
|
||||
done: promise,
|
||||
resolve: resolve!,
|
||||
};
|
||||
}
|
||||
1267
src/permissions.test.ts
Normal file
1267
src/permissions.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
392
src/permissions.ts
Normal file
392
src/permissions.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
Encrypted,
|
||||
KeyID,
|
||||
KeySecret,
|
||||
createdNowUnique,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
encryptKeySecret,
|
||||
getAgentSealerID,
|
||||
Sealed,
|
||||
} from "./crypto.js";
|
||||
import {
|
||||
CoValue,
|
||||
Transaction,
|
||||
TrustingTransaction,
|
||||
accountOrAgentIDfromSessionID,
|
||||
} from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { RawCoID, SessionID, TransactionID, isAgentID } from "./ids.js";
|
||||
import { AccountIDOrAgentID, GeneralizedControlledAccount, Profile } from "./account.js";
|
||||
|
||||
export type PermissionsDef =
|
||||
| { type: "team"; initialAdmin: AccountIDOrAgentID }
|
||||
| { type: "ownedByTeam"; team: RawCoID }
|
||||
| { type: "unsafeAllowAll" };
|
||||
|
||||
export type Role = "reader" | "writer" | "admin" | "revoked";
|
||||
|
||||
export function determineValidTransactions(
|
||||
coValue: CoValue
|
||||
): { txID: TransactionID; tx: Transaction }[] {
|
||||
if (coValue.header.ruleset.type === "team") {
|
||||
const allTrustingTransactionsSorted = Object.entries(
|
||||
coValue.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 = coValue.header.ruleset.initialAdmin;
|
||||
|
||||
if (!initialAdmin) {
|
||||
throw new Error("Team must have initialAdmin");
|
||||
}
|
||||
|
||||
const memberState: { [agent: AccountIDOrAgentID]: Role } = {};
|
||||
|
||||
const validTransactions: { txID: TransactionID; tx: Transaction }[] =
|
||||
[];
|
||||
|
||||
for (const {
|
||||
sessionID,
|
||||
txIndex,
|
||||
tx,
|
||||
} of allTrustingTransactionsSorted) {
|
||||
// console.log("before", { memberState, validTransactions });
|
||||
const transactor = accountOrAgentIDfromSessionID(sessionID);
|
||||
|
||||
const change = tx.changes[0] as
|
||||
| MapOpPayload<AccountIDOrAgentID, Role>
|
||||
| MapOpPayload<"readKey", JsonValue>
|
||||
| MapOpPayload<"profile", CoID<Profile>>;
|
||||
if (tx.changes.length !== 1) {
|
||||
console.warn("Team transaction must have exactly one change");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.op !== "insert") {
|
||||
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;
|
||||
}
|
||||
|
||||
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)) {
|
||||
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 });
|
||||
continue;
|
||||
}
|
||||
|
||||
const affectedMember = change.key;
|
||||
const assignedRole = change.value;
|
||||
|
||||
if (
|
||||
change.value !== "admin" &&
|
||||
change.value !== "writer" &&
|
||||
change.value !== "reader" &&
|
||||
change.value !== "revoked"
|
||||
) {
|
||||
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 (coValue.header.ruleset.type === "ownedByTeam") {
|
||||
const teamContent = coValue.node
|
||||
.expectCoValueLoaded(
|
||||
coValue.header.ruleset.team,
|
||||
"Determining valid transaction in owned object but its team wasn't loaded"
|
||||
)
|
||||
.getCurrentContent();
|
||||
|
||||
if (teamContent.type !== "comap") {
|
||||
throw new Error("Team must be a map");
|
||||
}
|
||||
|
||||
return Object.entries(coValue.sessions).flatMap(
|
||||
([sessionID, sessionLog]) => {
|
||||
const transactor = accountOrAgentIDfromSessionID(
|
||||
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 (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 },
|
||||
tx,
|
||||
}));
|
||||
}
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Unknown ruleset type " + (coValue.header.ruleset as any).type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type TeamContent = {
|
||||
profile: CoID<Profile> | null;
|
||||
[key: AccountIDOrAgentID]: Role;
|
||||
readKey: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
};
|
||||
|
||||
export function expectTeamContent(
|
||||
content: ContentType
|
||||
): CoMap<TeamContent, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<TeamContent, JsonObject | null>;
|
||||
}
|
||||
|
||||
export class Team {
|
||||
teamMap: CoMap<TeamContent, JsonObject | null>;
|
||||
node: LocalNode;
|
||||
|
||||
constructor(teamMap: CoMap<TeamContent, JsonObject | null>, node: LocalNode) {
|
||||
this.teamMap = teamMap;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
get id(): CoID<CoMap<TeamContent, JsonObject | null>> {
|
||||
return this.teamMap.id;
|
||||
}
|
||||
|
||||
addMember(accountID: AccountIDOrAgentID, role: Role) {
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
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(
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal(
|
||||
currentReadKey.secret,
|
||||
this.teamMap.coValue.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(agent),
|
||||
{
|
||||
in: this.teamMap.coValue.id,
|
||||
tx: this.teamMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
rotateReadKey() {
|
||||
const currentlyPermittedReaders = this.teamMap.keys().filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.teamMap.get(key);
|
||||
return (
|
||||
role === "admin" || role === "writer" || role === "reader"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as AccountIDOrAgentID[];
|
||||
|
||||
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();
|
||||
|
||||
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.currentSealerSecret(),
|
||||
getAgentSealerID(reader),
|
||||
{
|
||||
in: this.teamMap.coValue.id,
|
||||
tx: this.teamMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${newReadKey.id}`,
|
||||
encryptKeySecret({
|
||||
encrypting: newReadKey,
|
||||
toEncrypt: currentReadKey,
|
||||
}).encrypted,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
map.set("readKey", newReadKey.id, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
removeMember(accountID: AccountIDOrAgentID) {
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
map.set(accountID, "revoked", "trusting");
|
||||
});
|
||||
|
||||
this.rotateReadKey();
|
||||
}
|
||||
|
||||
createMap<M extends { [key: string]: JsonValue }, Meta extends JsonObject | null>(
|
||||
meta?: Meta
|
||||
): CoMap<M, Meta> {
|
||||
return this.node
|
||||
.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "ownedByTeam",
|
||||
team: this.teamMap.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as CoMap<M, Meta>;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
sessionId: SessionID
|
||||
): Team {
|
||||
return new Team(
|
||||
expectTeamContent(
|
||||
this.teamMap.coValue
|
||||
.testWithDifferentAccount(account, sessionId)
|
||||
.getCurrentContent()
|
||||
),
|
||||
this.node
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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_sealer") || field.includes("_for_co"));
|
||||
}
|
||||
130
src/streamUtils.ts
Normal file
130
src/streamUtils.ts
Normal file
@@ -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<SyncMessage>();
|
||||
const [outRx1, outTx1] = newStreamPair<SyncMessage>();
|
||||
|
||||
const [inRx2, inTx2] = newStreamPair<SyncMessage>();
|
||||
const [outRx2, outTx2] = newStreamPair<SyncMessage>();
|
||||
|
||||
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<T>(): [ReadableStream<T>, WritableStream<T>] {
|
||||
const queue: T[] = [];
|
||||
let resolveNextItemReady: () => void = () => { };
|
||||
let nextItemReady: Promise<void> = new Promise((resolve) => {
|
||||
resolveNextItemReady = resolve;
|
||||
});
|
||||
|
||||
let writerClosed = false;
|
||||
let readerClosed = false;
|
||||
|
||||
const readable = new ReadableStream<T>({
|
||||
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<T>({
|
||||
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];
|
||||
}
|
||||
1125
src/sync.test.ts
Normal file
1125
src/sync.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
512
src/sync.ts
Normal file
512
src/sync.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
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 { RawCoID, SessionID } from "./ids.js";
|
||||
|
||||
export type CoValueKnownState = {
|
||||
id: RawCoID;
|
||||
header: boolean;
|
||||
sessions: { [sessionID: SessionID]: number };
|
||||
};
|
||||
|
||||
export function emptyKnownState(id: RawCoID): CoValueKnownState {
|
||||
return {
|
||||
id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
};
|
||||
}
|
||||
|
||||
export type SyncMessage =
|
||||
| LoadMessage
|
||||
| KnownStateMessage
|
||||
| NewContentMessage
|
||||
| DoneMessage;
|
||||
|
||||
export type LoadMessage = {
|
||||
action: "load";
|
||||
} & CoValueKnownState;
|
||||
|
||||
export type KnownStateMessage = {
|
||||
action: "known";
|
||||
asDependencyOf?: RawCoID;
|
||||
isCorrection?: boolean;
|
||||
} & CoValueKnownState;
|
||||
|
||||
export type NewContentMessage = {
|
||||
action: "content";
|
||||
id: RawCoID;
|
||||
header?: CoValueHeader;
|
||||
new: {
|
||||
[sessionID: SessionID]: SessionNewContent;
|
||||
};
|
||||
};
|
||||
|
||||
export type SessionNewContent = {
|
||||
after: number;
|
||||
newTransactions: Transaction[];
|
||||
lastSignature: Signature;
|
||||
};
|
||||
export type DoneMessage = {
|
||||
action: "done";
|
||||
id: RawCoID;
|
||||
};
|
||||
|
||||
export type PeerID = string;
|
||||
|
||||
export interface Peer {
|
||||
id: PeerID;
|
||||
incoming: ReadableStream<SyncMessage>;
|
||||
outgoing: WritableStream<SyncMessage>;
|
||||
role: "peer" | "server" | "client";
|
||||
}
|
||||
|
||||
export interface PeerState {
|
||||
id: PeerID;
|
||||
optimisticKnownStates: { [id: RawCoID]: CoValueKnownState };
|
||||
toldKnownState: Set<RawCoID>;
|
||||
incoming: ReadableStream<SyncMessage>;
|
||||
outgoing: WritableStreamDefaultWriter<SyncMessage>;
|
||||
role: "peer" | "server" | "client";
|
||||
}
|
||||
|
||||
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[]);
|
||||
|
||||
for (const sessionID of allSessions) {
|
||||
const stateAValue = stateA.sessions[sessionID];
|
||||
const stateBValue = stateB.sessions[sessionID];
|
||||
|
||||
sessionStates[sessionID] = Math.max(stateAValue || 0, stateBValue || 0);
|
||||
}
|
||||
|
||||
return {
|
||||
id: stateA.id,
|
||||
header: stateA.header || stateB.header,
|
||||
sessions: sessionStates,
|
||||
};
|
||||
}
|
||||
|
||||
export class SyncManager {
|
||||
peers: { [key: PeerID]: PeerState } = {};
|
||||
local: LocalNode;
|
||||
|
||||
constructor(local: LocalNode) {
|
||||
this.local = local;
|
||||
}
|
||||
|
||||
loadFromPeers(id: RawCoID) {
|
||||
for (const peer of Object.values(this.peers)) {
|
||||
peer.outgoing
|
||||
.write({
|
||||
action: "load",
|
||||
id: 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 "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 "done":
|
||||
return await this.handleUnsubscribe(msg);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown message type ${
|
||||
(msg as { action: "string" }).action
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeToIncludingDependencies(
|
||||
id: RawCoID,
|
||||
peer: PeerState
|
||||
) {
|
||||
const entry = this.local.coValues[id];
|
||||
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
"Expected coValue entry on subscribe"
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.state === "loading") {
|
||||
await this.trySendToPeer(peer, {
|
||||
action: "load",
|
||||
id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const coValue = entry.coValue;
|
||||
|
||||
for (const id of coValue.getDependedOnCoValues()) {
|
||||
await this.subscribeToIncludingDependencies(id, peer);
|
||||
}
|
||||
|
||||
if (!peer.toldKnownState.has(id)) {
|
||||
peer.toldKnownState.add(id);
|
||||
await this.trySendToPeer(peer, {
|
||||
action: "load",
|
||||
...coValue.knownState(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async tellUntoldKnownStateIncludingDependencies(
|
||||
id: RawCoID,
|
||||
peer: PeerState,
|
||||
asDependencyOf?: RawCoID
|
||||
) {
|
||||
const coValue = this.local.expectCoValueLoaded(id);
|
||||
|
||||
for (const dependentCoID of coValue.getDependedOnCoValues()) {
|
||||
await this.tellUntoldKnownStateIncludingDependencies(
|
||||
dependentCoID,
|
||||
peer,
|
||||
asDependencyOf || id
|
||||
);
|
||||
}
|
||||
|
||||
if (!peer.toldKnownState.has(id)) {
|
||||
await this.trySendToPeer(peer, {
|
||||
action: "known",
|
||||
asDependencyOf,
|
||||
...coValue.knownState(),
|
||||
});
|
||||
|
||||
peer.toldKnownState.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
async sendNewContentIncludingDependencies(
|
||||
id: RawCoID,
|
||||
peer: PeerState
|
||||
) {
|
||||
const coValue = this.local.expectCoValueLoaded(id);
|
||||
|
||||
for (const id of coValue.getDependedOnCoValues()) {
|
||||
await this.sendNewContentIncludingDependencies(id, peer);
|
||||
}
|
||||
|
||||
const newContent = coValue.newContentSince(
|
||||
peer.optimisticKnownStates[id]
|
||||
);
|
||||
|
||||
if (newContent) {
|
||||
await this.trySendToPeer(peer, newContent);
|
||||
peer.optimisticKnownStates[id] = combinedKnownStates(
|
||||
peer.optimisticKnownStates[id] ||
|
||||
emptyKnownState(id),
|
||||
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 id of Object.keys(
|
||||
this.local.coValues
|
||||
) as RawCoID[]) {
|
||||
await this.subscribeToIncludingDependencies(id, peerState);
|
||||
|
||||
peerState.optimisticKnownStates[id] = {
|
||||
id: 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
|
||||
);
|
||||
}
|
||||
}
|
||||
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(new Error("Error writing to peer, disconnecting", {cause: e}));
|
||||
delete this.peers[peer.id];
|
||||
});
|
||||
}
|
||||
|
||||
async handleLoad(msg: LoadMessage, peer: PeerState) {
|
||||
const entry = this.local.coValues[msg.id];
|
||||
|
||||
if (!entry || entry.state === "loading") {
|
||||
if (!entry) {
|
||||
this.local.coValues[msg.id] = newLoadingState();
|
||||
}
|
||||
|
||||
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
|
||||
peer.toldKnownState.add(msg.id);
|
||||
|
||||
await this.trySendToPeer(peer, {
|
||||
action: "known",
|
||||
id: msg.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
|
||||
|
||||
await this.tellUntoldKnownStateIncludingDependencies(
|
||||
msg.id,
|
||||
peer
|
||||
);
|
||||
|
||||
await this.sendNewContentIncludingDependencies(msg.id, peer);
|
||||
}
|
||||
|
||||
async handleKnownState(msg: KnownStateMessage, peer: PeerState) {
|
||||
let entry = this.local.coValues[msg.id];
|
||||
|
||||
peer.optimisticKnownStates[msg.id] = combinedKnownStates(
|
||||
peer.optimisticKnownStates[msg.id] ||
|
||||
emptyKnownState(msg.id),
|
||||
knownStateIn(msg)
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
if (msg.asDependencyOf) {
|
||||
if (this.local.coValues[msg.asDependencyOf]) {
|
||||
entry = newLoadingState();
|
||||
|
||||
this.local.coValues[msg.id] = 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.id,
|
||||
peer
|
||||
);
|
||||
await this.sendNewContentIncludingDependencies(msg.id, peer);
|
||||
}
|
||||
|
||||
async handleNewContent(msg: NewContentMessage, peer: PeerState) {
|
||||
let entry = this.local.coValues[msg.id];
|
||||
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
"Expected coValue entry to be created, missing subscribe?"
|
||||
);
|
||||
}
|
||||
|
||||
let resolveAfterDone: ((coValue: CoValue) => void) | undefined;
|
||||
|
||||
const peerOptimisticKnownState =
|
||||
peer.optimisticKnownStates[msg.id];
|
||||
|
||||
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.id] = entry;
|
||||
}
|
||||
|
||||
const coValue = entry.coValue;
|
||||
|
||||
let invalidStateAssumed = false;
|
||||
|
||||
for (const [sessionID, newContentForSession] of Object.entries(
|
||||
msg.new
|
||||
) 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,
|
||||
undefined,
|
||||
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 this.trySendToPeer(peer, {
|
||||
action: "known",
|
||||
isCorrection: true,
|
||||
...coValue.knownState(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleCorrection(
|
||||
msg: KnownStateMessage,
|
||||
peer: PeerState
|
||||
) {
|
||||
const coValue = this.local.expectCoValueLoaded(msg.id);
|
||||
|
||||
peer.optimisticKnownStates[msg.id] = combinedKnownStates(
|
||||
msg,
|
||||
coValue.knownState()
|
||||
);
|
||||
|
||||
const newContent = coValue.newContentSince(msg);
|
||||
|
||||
if (newContent) {
|
||||
await this.trySendToPeer(peer, newContent);
|
||||
}
|
||||
}
|
||||
|
||||
handleUnsubscribe(_msg: DoneMessage) {
|
||||
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) {
|
||||
await this.tellUntoldKnownStateIncludingDependencies(
|
||||
coValue.id,
|
||||
peer
|
||||
);
|
||||
await this.sendNewContentIncludingDependencies(
|
||||
coValue.id,
|
||||
peer
|
||||
);
|
||||
} else if (peer.role === "server") {
|
||||
await this.subscribeToIncludingDependencies(coValue.id, peer);
|
||||
await this.sendNewContentIncludingDependencies(
|
||||
coValue.id,
|
||||
peer
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function knownStateIn(
|
||||
msg:
|
||||
| LoadMessage
|
||||
| KnownStateMessage
|
||||
) {
|
||||
return {
|
||||
id: msg.id,
|
||||
header: msg.header,
|
||||
sessions: msg.sessions,
|
||||
};
|
||||
}
|
||||
99
src/testUtils.ts
Normal file
99
src/testUtils.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "./crypto.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();
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function shouldNotResolve<T>(
|
||||
promise: Promise<T>,
|
||||
ops: { timeout: number }
|
||||
): Promise<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "esnext",
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
}
|
||||
Reference in New Issue
Block a user