Technology is anything that wasn't around when you were born.
This commit is contained in:
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
|
||||
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 (`CoValueID`, `AgentID`, `SessionID`, `SignatoryID`, `SignatorySecret`, `Signature`, `RecipientID`, `RecipientSecret`, `Sealed`, `Hash`, `ShortHash`, `SecretKey`, `EncryptedChunk`, `Role`)
|
||||
|
||||
- array
|
||||
- object
|
||||
|
||||
### `Collaborative` Values
|
||||
- CoMap (`string` → `Immutable`, last-writer-wins per key)
|
||||
- Team (`AgentID` → `Role`)
|
||||
- CoList (`Immutable[]`, addressable positions, insertAfter semantics)
|
||||
- Agent (`{signatoryID, recipientID}[]`)
|
||||
- MultiStream (independent per-session streams of `Immutable`s)
|
||||
- Static (single addressable `Immutable`)
|
||||
|
||||
## Implementation Abstractions
|
||||
- MultiLog
|
||||
- Session Logs
|
||||
- Transactions
|
||||
- Private (encrypted) transactions
|
||||
- Trusting (unencrypted) transactions
|
||||
- Rulesets
|
||||
- CoValue Types
|
||||
- LocalNode
|
||||
- Peers
|
||||
- AgentCredentials
|
||||
- Peer
|
||||
|
||||
## Extensions & higher-level protocols
|
||||
|
||||
### More complex datastructures
|
||||
- MarCo: a clean way to collaboratively mark up rich text with CoJSON
|
||||
- CoJSON Tree: a clean way to represent collaborative tree structures with CoJSON
|
||||
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "cojson",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^0.1.3",
|
||||
"@noble/curves": "^1.1.0",
|
||||
"@noble/hashes": "^1.3.1",
|
||||
"@scure/base": "^1.1.1",
|
||||
"fast-json-stable-stringify": "^2.1.0"
|
||||
}
|
||||
}
|
||||
103
src/cojsonValue.test.ts
Normal file
103
src/cojsonValue.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
getAgent,
|
||||
getAgentID,
|
||||
newRandomAgentCredential,
|
||||
newRandomSessionID,
|
||||
} from "./multilog";
|
||||
import { LocalNode } from "./node";
|
||||
|
||||
test("Empty COJSON Map works", () => {
|
||||
const agentCredential = newRandomAgentCredential();
|
||||
const node = new LocalNode(
|
||||
agentCredential,
|
||||
newRandomSessionID(getAgentID(getAgent(agentCredential)))
|
||||
);
|
||||
|
||||
const multilog = node.createMultiLog({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const content = multilog.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
expect([...content.keys()]).toEqual([]);
|
||||
expect(content.toJSON()).toEqual({});
|
||||
});
|
||||
|
||||
test("Can insert and delete Map entries in edit()", () => {
|
||||
const agentCredential = newRandomAgentCredential();
|
||||
const node = new LocalNode(
|
||||
agentCredential,
|
||||
newRandomSessionID(getAgentID(getAgent(agentCredential)))
|
||||
);
|
||||
|
||||
const multilog = node.createMultiLog({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const content = multilog.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set("hello", "world");
|
||||
expect(editable.get("hello")).toEqual("world");
|
||||
editable.set("foo", "bar");
|
||||
expect(editable.get("foo")).toEqual("bar");
|
||||
expect([...editable.keys()]).toEqual(["hello", "foo"]);
|
||||
editable.delete("foo");
|
||||
expect(editable.get("foo")).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can get map entry values at different points in time", () => {
|
||||
const agentCredential = newRandomAgentCredential();
|
||||
const node = new LocalNode(
|
||||
agentCredential,
|
||||
newRandomSessionID(getAgentID(getAgent(agentCredential)))
|
||||
);
|
||||
|
||||
const multilog = node.createMultiLog({
|
||||
type: "comap",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const content = multilog.getCurrentContent();
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.type).toEqual("comap");
|
||||
|
||||
content.edit((editable) => {
|
||||
const beforeA = Date.now();
|
||||
Bun.sleepSync(1);
|
||||
editable.set("hello", "A");
|
||||
const beforeB = Date.now();
|
||||
Bun.sleepSync(1);
|
||||
editable.set("hello", "B");
|
||||
const beforeC = Date.now();
|
||||
Bun.sleepSync(1);
|
||||
editable.set("hello", "C");
|
||||
expect(editable.get("hello")).toEqual("C");
|
||||
expect(editable.getAtTime("hello", Date.now())).toEqual("C");
|
||||
expect(editable.getAtTime("hello", beforeA)).toEqual(undefined);
|
||||
expect(editable.getAtTime("hello", beforeB)).toEqual("A");
|
||||
expect(editable.getAtTime("hello", beforeC)).toEqual("B");
|
||||
});
|
||||
})
|
||||
183
src/cojsonValue.ts
Normal file
183
src/cojsonValue.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { JsonAtom, JsonObject, JsonValue } from "./jsonValue";
|
||||
import { MultiLog, MultiLogID, TransactionID } from "./multilog";
|
||||
|
||||
export type CoValueID<T extends CoValue> = MultiLogID & {
|
||||
readonly __type: T;
|
||||
};
|
||||
|
||||
export type CoValue =
|
||||
| CoMap<string, JsonValue, JsonValue>
|
||||
| CoList<JsonValue, JsonValue>
|
||||
| MultiStream<JsonValue, JsonValue>
|
||||
| Static<JsonValue>;
|
||||
|
||||
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 class CoMap<
|
||||
K extends string,
|
||||
V extends JsonValue,
|
||||
Meta extends JsonValue
|
||||
> {
|
||||
id: CoValueID<CoMap<K, V, Meta>>;
|
||||
multiLog: MultiLog;
|
||||
type: "comap" = "comap";
|
||||
ops: Map<K, MapOp<K, V>[]>;
|
||||
|
||||
constructor(multiLog: MultiLog) {
|
||||
this.id = multiLog.id as CoValueID<CoMap<K, V, Meta>>;
|
||||
this.multiLog = multiLog;
|
||||
this.ops = new Map();
|
||||
|
||||
this.fillOpsFromMultilog();
|
||||
}
|
||||
|
||||
protected fillOpsFromMultilog() {
|
||||
for (const { txID, changes, madeAt } of this.multiLog.getValidSortedTransactions()) {
|
||||
for (const [changeIdx, change] of (
|
||||
changes as MapOpPayload<K, V>[]
|
||||
).entries()) {
|
||||
let entries = this.ops.get(change.key);
|
||||
if (!entries) {
|
||||
entries = [];
|
||||
this.ops.set(change.key, entries);
|
||||
}
|
||||
entries.push({
|
||||
txID,
|
||||
madeAt,
|
||||
changeIdx,
|
||||
...change,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys(): IterableIterator<K> {
|
||||
return this.ops.keys();
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const ops = this.ops.get(key);
|
||||
if (!ops) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let lastEntry = ops[ops.length - 1];
|
||||
|
||||
if (lastEntry.op === "delete") {
|
||||
return undefined;
|
||||
} else {
|
||||
return lastEntry.value;
|
||||
}
|
||||
}
|
||||
|
||||
getAtTime(key: K, time: number): V | undefined {
|
||||
const ops = this.ops.get(key);
|
||||
if (!ops) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lastOpBeforeOrAtTime = ops.findLast((op) => op.madeAt <= time);
|
||||
|
||||
if (!lastOpBeforeOrAtTime) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastOpBeforeOrAtTime.op === "delete") {
|
||||
return undefined;
|
||||
} else {
|
||||
return lastOpBeforeOrAtTime.value;
|
||||
}
|
||||
}
|
||||
|
||||
toJSON(): JsonObject {
|
||||
const json: JsonObject = {};
|
||||
|
||||
for (const key of this.keys()) {
|
||||
const value = this.get(key);
|
||||
if (value !== undefined) {
|
||||
json[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
edit(changer: (editable: WriteableCoMap<K, V, Meta>) => void): void {
|
||||
const editable = new WriteableCoMap<K, V, Meta>(this.multiLog);
|
||||
changer(editable);
|
||||
}
|
||||
}
|
||||
|
||||
export class WriteableCoMap<
|
||||
K extends string,
|
||||
V extends JsonValue,
|
||||
Meta extends JsonValue
|
||||
> extends CoMap<K, V, Meta> {
|
||||
// TODO: change default to private
|
||||
set(key: K, value: V, privacy: "private" | "trusting" = "trusting"): void {
|
||||
this.multiLog.makeTransaction([
|
||||
{
|
||||
op: "insert",
|
||||
key,
|
||||
value,
|
||||
},
|
||||
], privacy);
|
||||
|
||||
this.fillOpsFromMultilog();
|
||||
}
|
||||
|
||||
// TODO: change default to private
|
||||
delete(key: K, privacy: "private" | "trusting" = "trusting"): void {
|
||||
this.multiLog.makeTransaction([
|
||||
{
|
||||
op: "delete",
|
||||
key,
|
||||
},
|
||||
], privacy);
|
||||
|
||||
this.fillOpsFromMultilog();
|
||||
}
|
||||
}
|
||||
|
||||
export class CoList<T extends JsonValue, Meta extends JsonValue> {
|
||||
id: CoValueID<CoList<T, Meta>>;
|
||||
type: "colist" = "colist";
|
||||
|
||||
constructor(multilog: MultiLog) {
|
||||
this.id = multilog.id as CoValueID<CoList<T, Meta>>;
|
||||
}
|
||||
}
|
||||
|
||||
export class MultiStream<T extends JsonValue, Meta extends JsonValue> {
|
||||
id: CoValueID<MultiStream<T, Meta>>;
|
||||
type: "multistream" = "multistream";
|
||||
|
||||
constructor(multilog: MultiLog) {
|
||||
this.id = multilog.id as CoValueID<MultiStream<T, Meta>>;
|
||||
}
|
||||
}
|
||||
|
||||
export class Static<T extends JsonValue> {
|
||||
id: CoValueID<Static<T>>;
|
||||
type: "static" = "static";
|
||||
|
||||
constructor(multilog: MultiLog) {
|
||||
this.id = multilog.id as CoValueID<Static<T>>;
|
||||
}
|
||||
}
|
||||
103
src/crypto.test.ts
Normal file
103
src/crypto.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
getRecipientID,
|
||||
getSignatoryID,
|
||||
secureHash,
|
||||
newRandomRecipient,
|
||||
newRandomSignatory,
|
||||
sealFor,
|
||||
sign,
|
||||
unsealAs,
|
||||
verify,
|
||||
shortHash,
|
||||
newRandomSecretKey,
|
||||
EncryptionStream,
|
||||
DecryptionStream,
|
||||
} from "./crypto";
|
||||
|
||||
test("Signatures round-trip and use stable stringify", () => {
|
||||
const data = { b: "world", a: "hello" };
|
||||
const signatory = newRandomSignatory();
|
||||
const signature = sign(signatory, data);
|
||||
|
||||
expect(signature).toMatch(/^signature_z/);
|
||||
expect(
|
||||
verify(signature, { a: "hello", b: "world" }, getSignatoryID(signatory))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Invalid signatures don't verify", () => {
|
||||
const data = { b: "world", a: "hello" };
|
||||
const signatory = newRandomSignatory();
|
||||
const signatory2 = newRandomSignatory();
|
||||
const wrongSignature = sign(signatory2, data);
|
||||
|
||||
expect(verify(wrongSignature, data, getSignatoryID(signatory))).toBe(false);
|
||||
});
|
||||
|
||||
test("Sealing round-trips", () => {
|
||||
const data = { b: "world", a: "hello" };
|
||||
const recipient = newRandomRecipient();
|
||||
const sealed = sealFor(getRecipientID(recipient), data);
|
||||
|
||||
expect(sealed).toMatch(/^sealed_U/);
|
||||
expect(unsealAs(recipient, sealed)).toEqual(data);
|
||||
});
|
||||
|
||||
test("Invalid receiver can't unseal", () => {
|
||||
const data = { b: "world", a: "hello" };
|
||||
const recipient = newRandomRecipient();
|
||||
const recipient2 = newRandomRecipient();
|
||||
const sealed = sealFor(getRecipientID(recipient), data);
|
||||
|
||||
expect(unsealAs(recipient2, sealed)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Hashing is deterministic", () => {
|
||||
expect(secureHash({ b: "world", a: "hello" })).toEqual(
|
||||
secureHash({ a: "hello", b: "world" })
|
||||
);
|
||||
|
||||
expect(shortHash({ b: "world", a: "hello" })).toEqual(
|
||||
shortHash({ a: "hello", b: "world" })
|
||||
);
|
||||
});
|
||||
|
||||
test("Encryption streams round-trip", () => {
|
||||
const secretKey = newRandomSecretKey();
|
||||
const nonce = new Uint8Array(24);
|
||||
|
||||
const encryptionStream = new EncryptionStream(secretKey, nonce);
|
||||
const decryptionStream = new DecryptionStream(secretKey, nonce);
|
||||
|
||||
const encryptedChunks = [
|
||||
encryptionStream.encrypt({ a: "hello" }),
|
||||
encryptionStream.encrypt({ b: "world" }),
|
||||
];
|
||||
|
||||
const decryptedChunks = encryptedChunks.map((chunk) =>
|
||||
decryptionStream.decrypt(chunk)
|
||||
);
|
||||
|
||||
expect(decryptedChunks).toEqual([{ a: "hello" }, { b: "world" }]);
|
||||
});
|
||||
|
||||
test("Encryption streams don't decrypt with a wrong key", () => {
|
||||
const secretKey = newRandomSecretKey();
|
||||
const secretKey2 = newRandomSecretKey();
|
||||
const nonce = new Uint8Array(24);
|
||||
|
||||
const encryptionStream = new EncryptionStream(secretKey, nonce);
|
||||
const decryptionStream = new DecryptionStream(secretKey2, nonce);
|
||||
|
||||
const encryptedChunks = [
|
||||
encryptionStream.encrypt({ a: "hello" }),
|
||||
encryptionStream.encrypt({ b: "world" }),
|
||||
];
|
||||
|
||||
const decryptedChunks = encryptedChunks.map((chunk) =>
|
||||
decryptionStream.decrypt(chunk)
|
||||
);
|
||||
|
||||
expect(decryptedChunks).toEqual([undefined, undefined]);
|
||||
});
|
||||
245
src/crypto.ts
Normal file
245
src/crypto.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { ed25519, x25519 } from "@noble/curves/ed25519";
|
||||
import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa";
|
||||
import { JsonValue } from "./jsonValue";
|
||||
import { base58, base64url } from "@scure/base";
|
||||
import stableStringify from "fast-json-stable-stringify";
|
||||
import { blake2b } from "@noble/hashes/blake2b";
|
||||
import { concatBytes } from "@noble/ciphers/utils";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
|
||||
|
||||
export type SignatorySecret = `signatorySecret_z${string}`;
|
||||
export type SignatoryID = `signatory_z${string}`;
|
||||
export type Signature = `signature_z${string}`;
|
||||
|
||||
export type RecipientSecret = `recipientSecret_z${string}`;
|
||||
export type RecipientID = `recipient_z${string}`;
|
||||
export type Sealed = `sealed_U${string}`;
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export function newRandomSignatory(): SignatorySecret {
|
||||
return `signatorySecret_z${base58.encode(
|
||||
ed25519.utils.randomPrivateKey()
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function getSignatoryID(secret: SignatorySecret): SignatoryID {
|
||||
return `signatory_z${base58.encode(
|
||||
ed25519.getPublicKey(
|
||||
base58.decode(secret.substring("signatorySecret_z".length))
|
||||
)
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function sign(secret: SignatorySecret, message: JsonValue): Signature {
|
||||
const signature = ed25519.sign(
|
||||
textEncoder.encode(stableStringify(message)),
|
||||
base58.decode(secret.substring("signatorySecret_z".length))
|
||||
);
|
||||
return `signature_z${base58.encode(signature)}`;
|
||||
}
|
||||
|
||||
export function verify(
|
||||
signature: Signature,
|
||||
message: JsonValue,
|
||||
id: SignatoryID
|
||||
): boolean {
|
||||
return ed25519.verify(
|
||||
base58.decode(signature.substring("signature_z".length)),
|
||||
textEncoder.encode(stableStringify(message)),
|
||||
base58.decode(id.substring("signatory_z".length))
|
||||
);
|
||||
}
|
||||
|
||||
export function newRandomRecipient(): RecipientSecret {
|
||||
return `recipientSecret_z${base58.encode(x25519.utils.randomPrivateKey())}`;
|
||||
}
|
||||
|
||||
export function getRecipientID(secret: RecipientSecret): RecipientID {
|
||||
return `recipient_z${base58.encode(
|
||||
x25519.getPublicKey(
|
||||
base58.decode(secret.substring("recipientSecret_z".length))
|
||||
)
|
||||
)}`;
|
||||
}
|
||||
|
||||
// same construction as libsodium sealed_Uox
|
||||
export function sealFor(recipient: RecipientID, message: JsonValue): Sealed {
|
||||
const ephemeralSenderPriv = x25519.utils.randomPrivateKey();
|
||||
const ephemeralSenderPub = x25519.getPublicKey(ephemeralSenderPriv);
|
||||
const recipientPub = base58.decode(
|
||||
recipient.substring("recipient_z".length)
|
||||
);
|
||||
const sharedSecret = x25519.getSharedSecret(
|
||||
ephemeralSenderPriv,
|
||||
recipientPub
|
||||
);
|
||||
const nonce = blake2b(concatBytes(ephemeralSenderPub, recipientPub)).slice(
|
||||
0,
|
||||
24
|
||||
);
|
||||
|
||||
const plaintext = textEncoder.encode(stableStringify(message));
|
||||
|
||||
const sealedBox = concatBytes(
|
||||
ephemeralSenderPub,
|
||||
xsalsa20_poly1305(sharedSecret, nonce).encrypt(plaintext)
|
||||
);
|
||||
|
||||
return `sealed_U${base64url.encode(sealedBox)}`;
|
||||
}
|
||||
|
||||
export function unsealAs(
|
||||
recipientSecret: RecipientSecret,
|
||||
sealed: Sealed
|
||||
): JsonValue | undefined {
|
||||
const sealedBytes = base64url.decode(sealed.substring("sealed_U".length));
|
||||
|
||||
const ephemeralSenderPub = sealedBytes.slice(0, 32);
|
||||
const recipentPriv = base58.decode(
|
||||
recipientSecret.substring("recipientSecret_z".length)
|
||||
);
|
||||
const recipientPub = x25519.getPublicKey(recipentPriv);
|
||||
const sharedSecret = x25519.getSharedSecret(
|
||||
recipentPriv,
|
||||
ephemeralSenderPub
|
||||
);
|
||||
const nonce = blake2b(concatBytes(ephemeralSenderPub, recipientPub)).slice(
|
||||
0,
|
||||
24
|
||||
);
|
||||
|
||||
const ciphertext = sealedBytes.slice(32);
|
||||
try {
|
||||
const plaintext = xsalsa20_poly1305(sharedSecret, nonce).decrypt(
|
||||
ciphertext
|
||||
);
|
||||
return JSON.parse(textDecoder.decode(plaintext));
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type Hash = `hash_z${string}`;
|
||||
|
||||
export function secureHash(value: JsonValue): Hash {
|
||||
return `hash_z${base58.encode(
|
||||
blake3(textEncoder.encode(stableStringify(value)))
|
||||
)}`;
|
||||
}
|
||||
|
||||
export class StreamingHash {
|
||||
state: ReturnType<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 EncryptedStreamChunk<T extends JsonValue> =
|
||||
`encryptedChunk_U${string}`;
|
||||
|
||||
export type SecretKey = `secretKey_z${string}`;
|
||||
|
||||
export function newRandomSecretKey(): SecretKey {
|
||||
return `secretKey_z${base58.encode(randomBytes(32))}`;
|
||||
}
|
||||
|
||||
export class EncryptionStream {
|
||||
secretKey: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
counter: number;
|
||||
|
||||
constructor(secretKey: SecretKey, nonce: Uint8Array) {
|
||||
this.secretKey = base58.decode(
|
||||
secretKey.substring("secretKey_z".length)
|
||||
);
|
||||
this.nonce = nonce;
|
||||
this.counter = 0;
|
||||
}
|
||||
|
||||
static resume(secretKey: SecretKey, nonce: Uint8Array, counter: number) {
|
||||
const stream = new EncryptionStream(secretKey, nonce);
|
||||
stream.counter = counter;
|
||||
return stream;
|
||||
}
|
||||
|
||||
encrypt<T extends JsonValue>(value: T): EncryptedStreamChunk<T> {
|
||||
const plaintext = textEncoder.encode(stableStringify(value));
|
||||
const ciphertext = xsalsa20(
|
||||
this.secretKey,
|
||||
this.nonce,
|
||||
plaintext,
|
||||
new Uint8Array(plaintext.length),
|
||||
this.counter
|
||||
);
|
||||
this.counter++;
|
||||
|
||||
return `encryptedChunk_U${base64url.encode(ciphertext)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class DecryptionStream {
|
||||
secretKey: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
counter: number;
|
||||
|
||||
constructor(secretKey: SecretKey, nonce: Uint8Array) {
|
||||
this.secretKey = base58.decode(
|
||||
secretKey.substring("secretKey_z".length)
|
||||
);
|
||||
this.nonce = nonce;
|
||||
this.counter = 0;
|
||||
}
|
||||
|
||||
static resume(secretKey: SecretKey, nonce: Uint8Array, counter: number) {
|
||||
const stream = new DecryptionStream(secretKey, nonce);
|
||||
stream.counter = counter;
|
||||
return stream;
|
||||
}
|
||||
|
||||
decrypt<T extends JsonValue>(
|
||||
encryptedChunk: EncryptedStreamChunk<T>
|
||||
): T | undefined {
|
||||
const ciphertext = base64url.decode(
|
||||
encryptedChunk.substring("encryptedChunk_U".length)
|
||||
);
|
||||
const plaintext = xsalsa20(
|
||||
this.secretKey,
|
||||
this.nonce,
|
||||
ciphertext,
|
||||
new Uint8Array(ciphertext.length),
|
||||
this.counter
|
||||
);
|
||||
this.counter++;
|
||||
|
||||
try {
|
||||
return JSON.parse(textDecoder.decode(plaintext));
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/index.ts
Normal file
14
src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CoValue } from "./cojsonValue";
|
||||
import { JsonValue } from "./jsonValue";
|
||||
import { MultiLog } from "./multilog";
|
||||
import { LocalNode } from "./node";
|
||||
|
||||
type Value = JsonValue | CoValue;
|
||||
|
||||
export {
|
||||
JsonValue,
|
||||
CoValue as CoJsonValue,
|
||||
Value,
|
||||
LocalNode as Node,
|
||||
MultiLog
|
||||
}
|
||||
6
src/jsonValue.ts
Normal file
6
src/jsonValue.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { CoValueID, CoValue } from "./cojsonValue";
|
||||
|
||||
export type JsonAtom = string | number | boolean | null;
|
||||
export type JsonValue = JsonAtom | JsonArray | JsonObject | CoValueID<CoValue>;
|
||||
export type JsonArray = JsonValue[];
|
||||
export type JsonObject = { [key: string]: JsonValue; };
|
||||
138
src/multilog.test.ts
Normal file
138
src/multilog.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
MultiLog,
|
||||
Transaction,
|
||||
getAgent,
|
||||
getAgentID,
|
||||
newRandomAgentCredential,
|
||||
newRandomSessionID,
|
||||
} from "./multilog";
|
||||
import { LocalNode } from "./node";
|
||||
import { sign } from "./crypto";
|
||||
|
||||
test("Can create multilog with new agent credentials and add transaction to it", () => {
|
||||
const agentCredential = newRandomAgentCredential();
|
||||
const node = new LocalNode(
|
||||
agentCredential,
|
||||
newRandomSessionID(getAgentID(getAgent(agentCredential)))
|
||||
);
|
||||
|
||||
const multilog = node.createMultiLog({
|
||||
type: "multistream",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = multilog.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
[transaction]
|
||||
);
|
||||
|
||||
expect(
|
||||
multilog.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(agentCredential.signatorySecret, expectedNewHash)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("transactions with wrong signature are rejected", () => {
|
||||
const agent = newRandomAgentCredential();
|
||||
const wrongAgent = newRandomAgentCredential();
|
||||
const agentCredential = newRandomAgentCredential();
|
||||
const node = new LocalNode(
|
||||
agentCredential,
|
||||
newRandomSessionID(getAgentID(getAgent(agentCredential)))
|
||||
);
|
||||
|
||||
const multilog = node.createMultiLog({
|
||||
type: "multistream",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = multilog.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
[transaction]
|
||||
);
|
||||
|
||||
expect(
|
||||
multilog.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(wrongAgent.signatorySecret, expectedNewHash)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
const agent = newRandomAgentCredential();
|
||||
const agentCredential = newRandomAgentCredential();
|
||||
const node = new LocalNode(
|
||||
agentCredential,
|
||||
newRandomSessionID(getAgentID(getAgent(agentCredential)))
|
||||
);
|
||||
|
||||
const multilog = node.createMultiLog({
|
||||
type: "multistream",
|
||||
ruleset: { type: "unsafeAllowAll" },
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const transaction: Transaction = {
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "world",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { expectedNewHash } = multilog.expectedNewHashAfter(
|
||||
node.ownSessionID,
|
||||
[
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: Date.now(),
|
||||
changes: [
|
||||
{
|
||||
hello: "wrong",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(
|
||||
multilog.tryAddTransactions(
|
||||
node.ownSessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
sign(agent.signatorySecret, expectedNewHash)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
348
src/multilog.ts
Normal file
348
src/multilog.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
import {
|
||||
CoList,
|
||||
CoMap,
|
||||
CoValue,
|
||||
Static,
|
||||
MultiStream,
|
||||
} from "./cojsonValue";
|
||||
import {
|
||||
EncryptedStreamChunk,
|
||||
Hash,
|
||||
RecipientID,
|
||||
RecipientSecret,
|
||||
SignatoryID,
|
||||
SignatorySecret,
|
||||
Signature,
|
||||
StreamingHash,
|
||||
getRecipientID,
|
||||
getSignatoryID,
|
||||
newRandomRecipient,
|
||||
newRandomSignatory,
|
||||
shortHash,
|
||||
sign,
|
||||
verify,
|
||||
} from "./crypto";
|
||||
import { JsonValue } from "./jsonValue";
|
||||
import { base58 } from "@scure/base";
|
||||
import {
|
||||
PermissionsDef as RulesetDef,
|
||||
determineValidTransactions,
|
||||
} from "./permissions";
|
||||
|
||||
export type MultiLogID = `coval_${string}`;
|
||||
|
||||
export type MultiLogHeader = {
|
||||
type: CoValue['type'];
|
||||
ruleset: RulesetDef;
|
||||
meta: JsonValue;
|
||||
};
|
||||
|
||||
function multilogIDforHeader(header: MultiLogHeader): MultiLogID {
|
||||
const hash = shortHash(header);
|
||||
return `coval_${hash.slice("shortHash_".length)}`;
|
||||
}
|
||||
|
||||
export type SessionID = `session_${string}_${AgentID}`;
|
||||
|
||||
export function agentIDfromSessionID(sessionID: SessionID): AgentID {
|
||||
return `agent_${sessionID.substring(sessionID.lastIndexOf("_") + 1)}`;
|
||||
}
|
||||
|
||||
export function newRandomSessionID(agentID: AgentID): SessionID {
|
||||
return `session_${base58.encode(randomBytes(8))}_${agentID}`;
|
||||
}
|
||||
|
||||
type SessionLog = {
|
||||
transactions: Transaction[];
|
||||
lastHash?: Hash;
|
||||
streamingHash: StreamingHash;
|
||||
lastSignature: string;
|
||||
};
|
||||
|
||||
export type PrivateTransaction = {
|
||||
privacy: "private";
|
||||
madeAt: number;
|
||||
encryptedChanges: EncryptedStreamChunk<JsonValue[]>;
|
||||
};
|
||||
|
||||
export type TrustingTransaction = {
|
||||
privacy: "trusting";
|
||||
madeAt: number;
|
||||
changes: JsonValue[];
|
||||
};
|
||||
|
||||
export type Transaction = PrivateTransaction | TrustingTransaction;
|
||||
|
||||
export type DecryptedTransaction = {
|
||||
txID: TransactionID;
|
||||
changes: JsonValue[];
|
||||
madeAt: number;
|
||||
};
|
||||
|
||||
export type TransactionID = { sessionID: SessionID; txIndex: number };
|
||||
|
||||
export class MultiLog {
|
||||
id: MultiLogID;
|
||||
header: MultiLogHeader;
|
||||
sessions: { [key: SessionID]: SessionLog };
|
||||
agentCredential: AgentCredential;
|
||||
ownSessionID: SessionID;
|
||||
knownAgents: { [key: AgentID]: Agent };
|
||||
requiredMultiLogs: { [key: MultiLogID]: MultiLog };
|
||||
content?: CoValue;
|
||||
|
||||
constructor(
|
||||
header: MultiLogHeader,
|
||||
agentCredential: AgentCredential,
|
||||
ownSessionID: SessionID,
|
||||
knownAgents: { [key: AgentID]: Agent },
|
||||
requiredMultiLogs: { [key: MultiLogID]: MultiLog }
|
||||
) {
|
||||
this.id = multilogIDforHeader(header);
|
||||
this.header = header;
|
||||
this.sessions = {};
|
||||
this.agentCredential = agentCredential;
|
||||
this.ownSessionID = ownSessionID;
|
||||
this.knownAgents = knownAgents;
|
||||
this.requiredMultiLogs = requiredMultiLogs;
|
||||
}
|
||||
|
||||
testWithDifferentCredentials(
|
||||
agentCredential: AgentCredential,
|
||||
ownSessionID: SessionID
|
||||
): MultiLog {
|
||||
const knownAgents = {
|
||||
...this.knownAgents,
|
||||
[agentIDfromSessionID(ownSessionID)]: getAgent(agentCredential),
|
||||
};
|
||||
const cloned = new MultiLog(
|
||||
this.header,
|
||||
agentCredential,
|
||||
ownSessionID,
|
||||
knownAgents,
|
||||
this.requiredMultiLogs
|
||||
);
|
||||
|
||||
cloned.sessions = JSON.parse(JSON.stringify(this.sessions));
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
knownState(): MultilogKnownState {
|
||||
return {
|
||||
header: true,
|
||||
sessions: Object.fromEntries(
|
||||
Object.entries(this.sessions).map(([k, v]) => [
|
||||
k,
|
||||
v.transactions.length,
|
||||
])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
get meta(): JsonValue {
|
||||
return this.header?.meta ?? null;
|
||||
}
|
||||
|
||||
tryAddTransactions(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[],
|
||||
newHash: Hash,
|
||||
newSignature: Signature
|
||||
): boolean {
|
||||
const signatoryID =
|
||||
this.knownAgents[agentIDfromSessionID(sessionID)]?.signatoryID;
|
||||
|
||||
if (!signatoryID) {
|
||||
console.warn("Unknown agent", agentIDfromSessionID(sessionID));
|
||||
return false;
|
||||
}
|
||||
|
||||
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
|
||||
sessionID,
|
||||
newTransactions
|
||||
);
|
||||
|
||||
if (newHash !== expectedNewHash) {
|
||||
console.warn("Invalid hash", { newHash, expectedNewHash });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!verify(newSignature, newHash, signatoryID)) {
|
||||
console.warn(
|
||||
"Invalid signature",
|
||||
newSignature,
|
||||
newHash,
|
||||
signatoryID
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const transactions = this.sessions[sessionID]?.transactions ?? [];
|
||||
|
||||
transactions.push(...newTransactions);
|
||||
|
||||
this.sessions[sessionID] = {
|
||||
transactions,
|
||||
lastHash: newHash,
|
||||
streamingHash: newStreamingHash,
|
||||
lastSignature: newSignature,
|
||||
};
|
||||
|
||||
this.content = undefined;
|
||||
|
||||
const _ = this.getCurrentContent();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
expectedNewHashAfter(
|
||||
sessionID: SessionID,
|
||||
newTransactions: Transaction[]
|
||||
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
|
||||
const streamingHash =
|
||||
this.sessions[sessionID]?.streamingHash.clone() ??
|
||||
new StreamingHash();
|
||||
for (const transaction of newTransactions) {
|
||||
streamingHash.update(transaction);
|
||||
}
|
||||
|
||||
const newStreamingHash = streamingHash.clone();
|
||||
|
||||
return {
|
||||
expectedNewHash: streamingHash.digest(),
|
||||
newStreamingHash,
|
||||
};
|
||||
}
|
||||
|
||||
makeTransaction(
|
||||
changes: JsonValue[],
|
||||
privacy: "private" | "trusting"
|
||||
): boolean {
|
||||
const madeAt = Date.now();
|
||||
|
||||
const transaction: Transaction =
|
||||
privacy === "private"
|
||||
? (() => {
|
||||
throw new Error("Not implemented");
|
||||
})()
|
||||
: {
|
||||
privacy: "trusting",
|
||||
madeAt,
|
||||
changes,
|
||||
};
|
||||
|
||||
const sessionID = this.ownSessionID;
|
||||
|
||||
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
|
||||
transaction,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
this.agentCredential.signatorySecret,
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
return this.tryAddTransactions(
|
||||
sessionID,
|
||||
[transaction],
|
||||
expectedNewHash,
|
||||
signature
|
||||
);
|
||||
}
|
||||
|
||||
getCurrentContent(): CoValue {
|
||||
if (this.content) {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
if (this.header.type === "comap") {
|
||||
this.content = new CoMap(this);
|
||||
} else if (this.header.type === "colist") {
|
||||
this.content = new CoList(this);
|
||||
} else if (this.header.type === "multistream") {
|
||||
this.content = new MultiStream(this);
|
||||
} else if (this.header.type === "static") {
|
||||
this.content = new Static(this);
|
||||
} else {
|
||||
throw new Error(`Unknown multilog type ${this.header.type}`);
|
||||
}
|
||||
|
||||
return this.content;
|
||||
}
|
||||
|
||||
getValidSortedTransactions(): DecryptedTransaction[] {
|
||||
const validTransactions = determineValidTransactions(this);
|
||||
|
||||
const allTransactions: DecryptedTransaction[] = validTransactions.map(
|
||||
({ txID, tx }) => {
|
||||
if (tx.privacy === "private") {
|
||||
throw new Error("Private transactions not supported yet");
|
||||
} else {
|
||||
return {
|
||||
txID,
|
||||
changes: tx.changes,
|
||||
madeAt: tx.madeAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
// TODO: sort by timestamp, then by txID
|
||||
allTransactions.sort((a, b) => a.madeAt - b.madeAt);
|
||||
|
||||
return allTransactions;
|
||||
}
|
||||
}
|
||||
|
||||
type MultilogKnownState = {
|
||||
header: boolean;
|
||||
sessions: { [key: SessionID]: number };
|
||||
};
|
||||
|
||||
export type AgentID = `agent_${string}`;
|
||||
|
||||
export type Agent = {
|
||||
signatoryID: SignatoryID;
|
||||
recipientID: RecipientID;
|
||||
};
|
||||
|
||||
export function getAgent(agentCredential: AgentCredential) {
|
||||
return {
|
||||
signatoryID: getSignatoryID(agentCredential.signatorySecret),
|
||||
recipientID: getRecipientID(agentCredential.recipientSecret),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAgentMultilogHeader(agent: Agent): MultiLogHeader {
|
||||
return {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "agent",
|
||||
initialSignatoryID: agent.signatoryID,
|
||||
initialRecipientID: agent.recipientID,
|
||||
},
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAgentID(agent: Agent): AgentID {
|
||||
return `agent_${multilogIDforHeader(getAgentMultilogHeader(agent)).slice(
|
||||
"coval_".length
|
||||
)}`;
|
||||
}
|
||||
|
||||
export type AgentCredential = {
|
||||
signatorySecret: SignatorySecret;
|
||||
recipientSecret: RecipientSecret;
|
||||
};
|
||||
|
||||
export function newRandomAgentCredential(): AgentCredential {
|
||||
const signatorySecret = newRandomSignatory();
|
||||
const recipientSecret = newRandomRecipient();
|
||||
return { signatorySecret, recipientSecret };
|
||||
}
|
||||
|
||||
// type Role = "admin" | "writer" | "reader";
|
||||
|
||||
// type PermissionsDef = CJMap<AgentID, Role, {[agent: AgentID]: Role}>;
|
||||
75
src/node.ts
Normal file
75
src/node.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
MultiLogID,
|
||||
MultiLog,
|
||||
AgentCredential,
|
||||
AgentID,
|
||||
SessionID,
|
||||
Agent,
|
||||
getAgent,
|
||||
getAgentID,
|
||||
getAgentMultilogHeader,
|
||||
MultiLogHeader,
|
||||
} from "./multilog";
|
||||
|
||||
export class LocalNode {
|
||||
multilogs: { [key: MultiLogID]: Promise<MultiLog> | MultiLog } = {};
|
||||
// peers: {[key: Hostname]: Peer} = {};
|
||||
agentCredential: AgentCredential;
|
||||
agentID: AgentID;
|
||||
ownSessionID: SessionID;
|
||||
knownAgents: { [key: AgentID]: Agent } = {};
|
||||
|
||||
constructor(agentCredential: AgentCredential, ownSessionID: SessionID) {
|
||||
this.agentCredential = agentCredential;
|
||||
const agent = getAgent(agentCredential);
|
||||
const agentID = getAgentID(agent);
|
||||
this.agentID = agentID;
|
||||
this.knownAgents[agentID] = agent;
|
||||
this.ownSessionID = ownSessionID;
|
||||
|
||||
const agentMultilog = new MultiLog(
|
||||
getAgentMultilogHeader(agent),
|
||||
agentCredential,
|
||||
ownSessionID,
|
||||
this.knownAgents,
|
||||
{}
|
||||
);
|
||||
this.multilogs[agentMultilog.id] = Promise.resolve(agentMultilog);
|
||||
}
|
||||
|
||||
createMultiLog(header: MultiLogHeader): MultiLog {
|
||||
const requiredMultiLogs = header.ruleset.type === "ownedByTeam" ? {
|
||||
[header.ruleset.team]: this.expectMultiLogLoaded(header.ruleset.team)
|
||||
} : {};
|
||||
|
||||
const multilog = new MultiLog(
|
||||
header,
|
||||
this.agentCredential,
|
||||
this.ownSessionID,
|
||||
this.knownAgents,
|
||||
requiredMultiLogs
|
||||
);
|
||||
this.multilogs[multilog.id] = multilog;
|
||||
return multilog;
|
||||
}
|
||||
|
||||
expectMultiLogLoaded(id: MultiLogID): MultiLog {
|
||||
const multilog = this.multilogs[id];
|
||||
if (!multilog) {
|
||||
throw new Error(`Unknown multilog ${id}`);
|
||||
}
|
||||
if (multilog instanceof Promise) {
|
||||
throw new Error(`Multilog ${id} not yet loaded`);
|
||||
}
|
||||
return multilog;
|
||||
}
|
||||
}
|
||||
|
||||
// type Hostname = string;
|
||||
|
||||
// interface Peer {
|
||||
// hostname: Hostname;
|
||||
// incoming: ReadableStream<SyncMessage>;
|
||||
// outgoing: WritableStream<SyncMessage>;
|
||||
// optimisticKnownStates: {[multilogID: MultiLogID]: MultilogKnownState};
|
||||
// }
|
||||
311
src/permissions.test.ts
Normal file
311
src/permissions.test.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
AgentID,
|
||||
getAgent,
|
||||
getAgentID,
|
||||
newRandomAgentCredential,
|
||||
newRandomSessionID,
|
||||
} from "./multilog";
|
||||
import { LocalNode } from "./node";
|
||||
import { CoJsonValue } from ".";
|
||||
import { CoMap } from "./cojsonValue";
|
||||
import { Role } from "./permissions";
|
||||
|
||||
function teamWithTwoAdmins() {
|
||||
const { multilog, admin, adminID } = newTeam();
|
||||
|
||||
const otherAdmin = newRandomAgentCredential();
|
||||
const otherAdminID = getAgentID(getAgent(otherAdmin));
|
||||
|
||||
let content = expectTeam(multilog.getCurrentContent());
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set(otherAdminID, "admin", "trusting");
|
||||
expect(editable.get(otherAdminID)).toEqual("admin");
|
||||
});
|
||||
|
||||
content = expectTeam(multilog.getCurrentContent());
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.get(otherAdminID)).toEqual("admin");
|
||||
return { multilog, admin, adminID, otherAdmin, otherAdminID };
|
||||
}
|
||||
|
||||
function newTeam() {
|
||||
const admin = newRandomAgentCredential();
|
||||
const adminID = getAgentID(getAgent(admin));
|
||||
|
||||
const node = new LocalNode(admin, newRandomSessionID(adminID));
|
||||
|
||||
const multilog = node.createMultiLog({
|
||||
type: "comap",
|
||||
ruleset: { type: "team", initialAdmin: adminID },
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const content = expectTeam(multilog.getCurrentContent());
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set(adminID, "admin", "trusting");
|
||||
expect(editable.get(adminID)).toEqual("admin");
|
||||
});
|
||||
|
||||
return { node, multilog, admin, adminID };
|
||||
}
|
||||
|
||||
function expectTeam(content: CoJsonValue): CoMap<AgentID, Role, {}> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<AgentID, Role, {}>;
|
||||
}
|
||||
|
||||
function expectMap(content: CoJsonValue): CoMap<string, string, {}> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<string, string, {}>;
|
||||
}
|
||||
|
||||
test("Initial admin can add another admin to a team", () => {
|
||||
teamWithTwoAdmins();
|
||||
});
|
||||
|
||||
test("Added admin can add a third admin to a team", () => {
|
||||
const { multilog, otherAdmin, otherAdminID } = teamWithTwoAdmins();
|
||||
|
||||
const otherAdminMultilog = multilog.testWithDifferentCredentials(
|
||||
otherAdmin,
|
||||
newRandomSessionID(otherAdminID)
|
||||
);
|
||||
|
||||
let otherContent = expectTeam(otherAdminMultilog.getCurrentContent());
|
||||
|
||||
expect(otherContent.get(otherAdminID)).toEqual("admin");
|
||||
|
||||
const thirdAdmin = newRandomAgentCredential();
|
||||
const thirdAdminID = getAgentID(getAgent(thirdAdmin));
|
||||
|
||||
otherContent.edit((editable) => {
|
||||
editable.set(thirdAdminID, "admin", "trusting");
|
||||
expect(editable.get(thirdAdminID)).toEqual("admin");
|
||||
});
|
||||
|
||||
otherContent = expectTeam(otherAdminMultilog.getCurrentContent());
|
||||
|
||||
expect(otherContent.get(thirdAdminID)).toEqual("admin");
|
||||
});
|
||||
|
||||
test("Admins can't demote other admins in a team", () => {
|
||||
const { multilog, adminID, otherAdmin, otherAdminID } = teamWithTwoAdmins();
|
||||
|
||||
let content = expectTeam(multilog.getCurrentContent());
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set(otherAdminID, "writer", "trusting");
|
||||
expect(editable.get(otherAdminID)).toEqual("admin");
|
||||
});
|
||||
|
||||
content = expectTeam(multilog.getCurrentContent());
|
||||
expect(content.get(otherAdminID)).toEqual("admin");
|
||||
|
||||
const otherAdminMultilog = multilog.testWithDifferentCredentials(
|
||||
otherAdmin,
|
||||
newRandomSessionID(otherAdminID)
|
||||
);
|
||||
|
||||
let otherContent = expectTeam(otherAdminMultilog.getCurrentContent());
|
||||
|
||||
otherContent.edit((editable) => {
|
||||
editable.set(adminID, "writer", "trusting");
|
||||
expect(editable.get(adminID)).toEqual("admin");
|
||||
});
|
||||
|
||||
otherContent = expectTeam(otherAdminMultilog.getCurrentContent());
|
||||
|
||||
expect(otherContent.get(adminID)).toEqual("admin");
|
||||
});
|
||||
|
||||
test("Admins an add writers to a team, who can't add admins, writers, or readers", () => {
|
||||
const { multilog } = newTeam();
|
||||
const writer = newRandomAgentCredential();
|
||||
const writerID = getAgentID(getAgent(writer));
|
||||
|
||||
let content = expectTeam(multilog.getCurrentContent());
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set(writerID, "writer", "trusting");
|
||||
expect(editable.get(writerID)).toEqual("writer");
|
||||
});
|
||||
|
||||
content = expectTeam(multilog.getCurrentContent());
|
||||
expect(content.get(writerID)).toEqual("writer");
|
||||
|
||||
const writerMultilog = multilog.testWithDifferentCredentials(
|
||||
writer,
|
||||
newRandomSessionID(writerID)
|
||||
);
|
||||
|
||||
let writerContent = expectTeam(writerMultilog.getCurrentContent());
|
||||
|
||||
expect(writerContent.get(writerID)).toEqual("writer");
|
||||
|
||||
const otherAgent = newRandomAgentCredential();
|
||||
const otherAgentID = getAgentID(getAgent(otherAgent));
|
||||
|
||||
writerContent.edit((editable) => {
|
||||
editable.set(otherAgentID, "admin", "trusting");
|
||||
expect(editable.get(otherAgentID)).toBeUndefined();
|
||||
|
||||
editable.set(otherAgentID, "writer", "trusting");
|
||||
expect(editable.get(otherAgentID)).toBeUndefined();
|
||||
|
||||
editable.set(otherAgentID, "reader", "trusting");
|
||||
expect(editable.get(otherAgentID)).toBeUndefined();
|
||||
});
|
||||
|
||||
writerContent = expectTeam(writerMultilog.getCurrentContent());
|
||||
|
||||
expect(writerContent.get(otherAgentID)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Admins can add readers to a team, who can't add admins, writers, or readers", () => {
|
||||
const { multilog } = newTeam();
|
||||
const reader = newRandomAgentCredential();
|
||||
const readerID = getAgentID(getAgent(reader));
|
||||
|
||||
let content = expectTeam(multilog.getCurrentContent());
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set(readerID, "reader", "trusting");
|
||||
expect(editable.get(readerID)).toEqual("reader");
|
||||
});
|
||||
|
||||
content = expectTeam(multilog.getCurrentContent());
|
||||
expect(content.get(readerID)).toEqual("reader");
|
||||
|
||||
const readerMultilog = multilog.testWithDifferentCredentials(
|
||||
reader,
|
||||
newRandomSessionID(readerID)
|
||||
);
|
||||
|
||||
let readerContent = expectTeam(readerMultilog.getCurrentContent());
|
||||
|
||||
expect(readerContent.get(readerID)).toEqual("reader");
|
||||
|
||||
const otherAgent = newRandomAgentCredential();
|
||||
const otherAgentID = getAgentID(getAgent(otherAgent));
|
||||
|
||||
readerContent.edit((editable) => {
|
||||
editable.set(otherAgentID, "admin", "trusting");
|
||||
expect(editable.get(otherAgentID)).toBeUndefined();
|
||||
|
||||
editable.set(otherAgentID, "writer", "trusting");
|
||||
expect(editable.get(otherAgentID)).toBeUndefined();
|
||||
|
||||
editable.set(otherAgentID, "reader", "trusting");
|
||||
expect(editable.get(otherAgentID)).toBeUndefined();
|
||||
});
|
||||
|
||||
readerContent = expectTeam(readerMultilog.getCurrentContent());
|
||||
|
||||
expect(readerContent.get(otherAgentID)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Admins can write to an object that is owned by their team", () => {
|
||||
const { node, multilog, adminID } = newTeam();
|
||||
|
||||
const childObject = node.createMultiLog({
|
||||
type: "comap",
|
||||
ruleset: { type: "ownedByTeam", team: multilog.id },
|
||||
meta: null,
|
||||
});
|
||||
|
||||
let childContent = expectMap(childObject.getCurrentContent());
|
||||
|
||||
childContent.edit((editable) => {
|
||||
editable.set("foo", "bar", "trusting");
|
||||
expect(editable.get("foo")).toEqual("bar");
|
||||
});
|
||||
|
||||
childContent = expectMap(childObject.getCurrentContent());
|
||||
|
||||
expect(childContent.get("foo")).toEqual("bar");
|
||||
})
|
||||
|
||||
test("Writers can write to an object that is owned by their team", () => {
|
||||
const { node, multilog, adminID } = newTeam();
|
||||
|
||||
const content = expectTeam(multilog.getCurrentContent());
|
||||
|
||||
const writer = newRandomAgentCredential();
|
||||
const writerID = getAgentID(getAgent(writer));
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set(writerID, "writer", "trusting");
|
||||
expect(editable.get(writerID)).toEqual("writer");
|
||||
});
|
||||
|
||||
const childObject = node.createMultiLog({
|
||||
type: "comap",
|
||||
ruleset: { type: "ownedByTeam", team: multilog.id },
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const childObjectAsWriter = childObject.testWithDifferentCredentials(
|
||||
writer,
|
||||
newRandomSessionID(writerID)
|
||||
);
|
||||
|
||||
let childContent = expectMap(childObjectAsWriter.getCurrentContent());
|
||||
|
||||
childContent.edit((editable) => {
|
||||
editable.set("foo", "bar", "trusting");
|
||||
expect(editable.get("foo")).toEqual("bar");
|
||||
});
|
||||
|
||||
childContent = expectMap(childObjectAsWriter.getCurrentContent());
|
||||
|
||||
expect(childContent.get("foo")).toEqual("bar");
|
||||
});
|
||||
|
||||
test("Readers can not write to an object that is owned by their team", () => {
|
||||
const { node, multilog, adminID } = newTeam();
|
||||
|
||||
const content = expectTeam(multilog.getCurrentContent());
|
||||
|
||||
const reader = newRandomAgentCredential();
|
||||
const readerID = getAgentID(getAgent(reader));
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set(readerID, "reader", "trusting");
|
||||
expect(editable.get(readerID)).toEqual("reader");
|
||||
});
|
||||
|
||||
const childObject = node.createMultiLog({
|
||||
type: "comap",
|
||||
ruleset: { type: "ownedByTeam", team: multilog.id },
|
||||
meta: null,
|
||||
});
|
||||
|
||||
const childObjectAsReader = childObject.testWithDifferentCredentials(
|
||||
reader,
|
||||
newRandomSessionID(readerID)
|
||||
);
|
||||
|
||||
let childContent = expectMap(childObjectAsReader.getCurrentContent());
|
||||
|
||||
childContent.edit((editable) => {
|
||||
editable.set("foo", "bar", "trusting");
|
||||
expect(editable.get("foo")).toBeUndefined();
|
||||
});
|
||||
|
||||
childContent = expectMap(childObjectAsReader.getCurrentContent());
|
||||
|
||||
expect(childContent.get("foo")).toBeUndefined();
|
||||
});
|
||||
171
src/permissions.ts
Normal file
171
src/permissions.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { MapOpPayload } from "./cojsonValue";
|
||||
import { RecipientID, SignatoryID } from "./crypto";
|
||||
import {
|
||||
AgentID,
|
||||
MultiLog,
|
||||
MultiLogID,
|
||||
SessionID,
|
||||
Transaction,
|
||||
TransactionID,
|
||||
TrustingTransaction,
|
||||
agentIDfromSessionID,
|
||||
} from "./multilog";
|
||||
|
||||
export type PermissionsDef =
|
||||
| { type: "team"; initialAdmin: AgentID; parentTeams?: MultiLogID[] }
|
||||
| { type: "ownedByTeam"; team: MultiLogID }
|
||||
| {
|
||||
type: "agent";
|
||||
initialSignatoryID: SignatoryID;
|
||||
initialRecipientID: RecipientID;
|
||||
}
|
||||
| { type: "unsafeAllowAll" };
|
||||
|
||||
export type Role = "reader" | "writer" | "admin" | "revoked";
|
||||
|
||||
export function determineValidTransactions(
|
||||
multilog: MultiLog
|
||||
): { txID: TransactionID; tx: Transaction }[] {
|
||||
if (multilog.header.ruleset.type === "team") {
|
||||
const allTrustingTransactionsSorted = Object.entries(
|
||||
multilog.sessions
|
||||
).flatMap(([sessionID, sessionLog]) => {
|
||||
return sessionLog.transactions
|
||||
.map((tx, txIndex) => ({ sessionID, txIndex, tx }))
|
||||
.filter(({ tx }) => {
|
||||
if (tx.privacy === "trusting") {
|
||||
return true;
|
||||
} else {
|
||||
console.warn("Unexpected private transaction in Team");
|
||||
return false;
|
||||
}
|
||||
}) as {
|
||||
sessionID: SessionID;
|
||||
txIndex: number;
|
||||
tx: TrustingTransaction;
|
||||
}[];
|
||||
});
|
||||
|
||||
allTrustingTransactionsSorted.sort((a, b) => {
|
||||
return a.tx.madeAt - b.tx.madeAt;
|
||||
});
|
||||
|
||||
const initialAdmin = multilog.header.ruleset.initialAdmin;
|
||||
|
||||
if (!initialAdmin) {
|
||||
throw new Error("Team must have initialAdmin");
|
||||
}
|
||||
|
||||
const memberState: { [agent: AgentID]: Role } = {};
|
||||
|
||||
const validTransactions: { txID: TransactionID; tx: Transaction }[] =
|
||||
[];
|
||||
|
||||
for (const {
|
||||
sessionID,
|
||||
txIndex,
|
||||
tx,
|
||||
} of allTrustingTransactionsSorted) {
|
||||
// console.log("before", { memberState, validTransactions });
|
||||
const transactor = agentIDfromSessionID(sessionID);
|
||||
|
||||
const change = tx.changes[0] as MapOpPayload<AgentID, Role>;
|
||||
if (tx.changes.length !== 1) {
|
||||
console.warn("Team transaction must have exactly one change");
|
||||
continue;
|
||||
}
|
||||
|
||||
const affectedMember = change.key;
|
||||
|
||||
if (change.op !== "insert") {
|
||||
console.warn("Team transaction must set a role");
|
||||
continue;
|
||||
}
|
||||
|
||||
const assignedRole = change.value;
|
||||
|
||||
if (
|
||||
change.value !== "admin" &&
|
||||
change.value !== "writer" &&
|
||||
change.value !== "reader"
|
||||
) {
|
||||
console.warn("Team transaction must set a valid role");
|
||||
continue;
|
||||
}
|
||||
|
||||
const isFirstSelfAppointment =
|
||||
!memberState[transactor] &&
|
||||
transactor === initialAdmin &&
|
||||
change.op === "insert" &&
|
||||
change.key === transactor &&
|
||||
change.value === "admin";
|
||||
|
||||
if (!isFirstSelfAppointment) {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
console.warn(
|
||||
"Team transaction must be made by current admin"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
memberState[affectedMember] === "admin" &&
|
||||
affectedMember !== transactor &&
|
||||
assignedRole !== "admin"
|
||||
) {
|
||||
console.warn("Admins can only demote themselves.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
memberState[affectedMember] = change.value;
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
|
||||
// console.log("after", { memberState, validTransactions });
|
||||
}
|
||||
|
||||
return validTransactions;
|
||||
} else if (multilog.header.ruleset.type === "ownedByTeam") {
|
||||
const teamContent =
|
||||
multilog.requiredMultiLogs[
|
||||
multilog.header.ruleset.team
|
||||
].getCurrentContent();
|
||||
|
||||
if (teamContent.type !== "comap") {
|
||||
throw new Error("Team must be a map");
|
||||
}
|
||||
|
||||
return Object.entries(multilog.sessions).flatMap(
|
||||
([sessionID, sessionLog]) => {
|
||||
const transactor = agentIDfromSessionID(sessionID as SessionID);
|
||||
return sessionLog.transactions
|
||||
.filter((tx) => {
|
||||
const transactorRoleAtTxTime = teamContent.getAtTime(
|
||||
transactor,
|
||||
tx.madeAt
|
||||
);
|
||||
|
||||
return (
|
||||
transactorRoleAtTxTime === "admin" ||
|
||||
transactorRoleAtTxTime === "writer"
|
||||
);
|
||||
})
|
||||
.map((tx, txIndex) => ({
|
||||
txID: { sessionID: sessionID as SessionID, txIndex },
|
||||
tx,
|
||||
}));
|
||||
}
|
||||
);
|
||||
} else if (multilog.header.ruleset.type === "unsafeAllowAll") {
|
||||
return Object.entries(multilog.sessions).flatMap(
|
||||
([sessionID, sessionLog]) => {
|
||||
return sessionLog.transactions.map((tx, txIndex) => ({
|
||||
txID: { sessionID: sessionID as SessionID, txIndex },
|
||||
tx,
|
||||
}));
|
||||
}
|
||||
);
|
||||
} else {
|
||||
throw new Error("Unknown ruleset type " + multilog.header.ruleset.type);
|
||||
}
|
||||
}
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user