Technology is anything that wasn't around when you were born.

This commit is contained in:
Anselm
2023-07-19 11:55:18 +01:00
commit d0993189d2
17 changed files with 1980 additions and 0 deletions

171
.gitignore vendored Normal file
View 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
View 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
View 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

BIN
bun.lockb Executable file

Binary file not shown.

19
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
]
}
}