Compare commits

..

5 Commits

Author SHA1 Message Date
Anselm Eickhoff
988dc37902 Publish
- cojson-simple-sync@0.1.2
 - cojson@0.1.3
 - jazz-browser-auth-local@0.1.3
 - jazz-browser@0.1.3
 - jazz-react-auth-local@0.1.4
 - jazz-react@0.1.4
 - jazz-storage-indexeddb@0.1.3
2023-08-27 17:30:01 +01:00
Anselm Eickhoff
4ef4b87d95 Fix WS reconnect 2023-08-27 17:29:36 +01:00
Anselm Eickhoff
27f811b9e9 Permissioned -> secure 2023-08-20 16:19:49 +01:00
Anselm
52be603996 Distinguish between not yet implemented and documented 2023-08-20 00:28:20 +01:00
Anselm
d1123866c2 Initial docs 2023-08-20 00:27:02 +01:00
10 changed files with 321 additions and 91 deletions

248
README.md
View File

@@ -2,7 +2,7 @@
Homepage: [jazz.tools](https://jazz.tools) — [Discord](https://discord.gg/utDMjHYg42)
Jazz is an open-source toolkit for *permissioned telepathic data.*
Jazz is an open-source toolkit for *secure telepathic data.*
- Ship faster & simplify your frontend and backend
- Get cross-device sync, real-time collaboration & offline support for free
@@ -11,14 +11,14 @@ Jazz is an open-source toolkit for *permissioned telepathic data.*
## What is Permissioned Telepathic Data?
## What is Secure Telepathic Data?
**Telepathic** means:
- **Read and write data as if it was local,** from anywhere in your app.
- **Always have that data synced, instantly.** Across devices of the same user — or to other users (coming soon: to your backend, workers, etc.)
**Permissioned** means:
**Secure** means:
- **Fine-grained, role-based permissions are *baked into* your data.**
- **Permissions are enforced everywhere, locally.** (using cryptography instead of through an API)
@@ -57,7 +57,7 @@ If you want to build something with Jazz, [join the Jazz Discord](https://discor
**`cojson`**
A library implementing abstractions and protocols for "Collaborative JSON". This will soon be standardized and forms the basis of permissioned telepathic data.
A library implementing abstractions and protocols for "Collaborative JSON". This will soon be standardized and forms the basis of secure telepathic data.
**`jazz-react`**
@@ -83,32 +83,256 @@ Provides local, offline-capable persistence. Included and enabled in `jazz-react
## `CoJSON`
CoJSON is the core implementation of permissioned telepathic data. It provides abstractions for Collaborative JSON values ("`CoValues`"), groups for permission management and a protocol for syncing between nodes. Our goal is to standardise CoJSON soon and port it to other languages and platforms.
CoJSON is the core implementation of secure telepathic data. It provides abstractions for Collaborative JSON values ("`CoValues`"), groups for permission management and a protocol for syncing between nodes. Our goal is to standardise CoJSON soon and port it to other languages and platforms.
---
### `LocalNode`
A `LocalNode` represents a local view of a set of loaded `CoValue`s, from the perspective of a particular account (or primitive cryptographic agent).
A `LocalNode` can have peers that it syncs to, for example some form of local persistence, or a sync server, such as `sync.jazz.tools` (Jazz Global Mesh).
You typically get hold of a `LocalNode` using `jazz-react`'s `useJazz()`:
```typescript
const { localNode } = useJazz();
```
#### `LocalNode.load(id)`
```typescript
load<T extends ContentType>(id: CoID<T>): Promise<T>
```
Loads a CoValue's content, syncing from peers as necessary and resolving the returned promise once a first version has been loaded. See `ContentType.subscribe()` and `useTelepathicData` for listening to subsequent updates to the CoValue.
#### `LocalNode.loadProfile(id)`
```typescript
loadProfile(accountID: AccountID): Promise<Profile>
```
Loads a profile associated with an account. `Profile` is at least a `CoMap<{string: name}>`, but might contain other, app-specific properties.
#### `LocalNode.acceptInvite(valueOrGroup, inviteSecret)`
```typescript
acceptInvite<T extends ContentType>(
valueOrGroup: CoID<T>,
inviteSecret: InviteSecret
): Promise<void>
```
Accepts an invite for a group, or infers the group if given the `CoID` of a value owned by that group. Resolves upon successful joining of that group, at which point you should be able to `LocalNode.load` the value.
Invites can be created with `Group.createInvite(role)`.
#### `LocalNode.createGroup()`
```typescript
createGroup(): Group
```
Creates a new group (with the current account as the group's first admin).
---
### `Group`
### `CoValue` & `ContentType`s
A CoJSON group manages permissions of its members. A `Group` object exposes those capabilities and allows you to create new CoValues owned by that group.
#### `CoMap`
(Internally, a `Group` is also just a `CoMap`, mapping member accounts to roles and containing some state management for making cryptographic keys available to current members)
#### `CoList` (coming soon)
#### `Group.id`
#### `CoStram` (coming soon)
Returns the `CoID` of the `Group`.
#### `Static` (coming soon)
#### `Group.roleOf(accountID)`
```typescript
roleOf(accountID: AccountID): "reader" | "writer" | "admin" | undefined
```
Returns the current role of a given account.
#### `Group.myRole()`
```typescript
myRole(accountID: AccountID): "reader" | "writer" | "admin" | undefined
```
Returns the role of the current account in the group.
#### `Group.addMember(accountID, role)`
```typescript
addMember(
accountID: AccountIDOrAgentID,
role: "reader" | "writer" | "admin"
)
```
Directly grants a new member a role in the group. The current account must be an admin to be able to do so. Throws otherwise.
#### `Group.createInvite(role)`
```typescript
createInvite(role: "reader" | "writer" | "admin"): InviteSecret
```
Creates an invite for new members to indirectly join the group, allowing them to grant themselves the specified role with the InviteSecret (a string starting with "inviteSecret_") - use `LocalNode.acceptInvite()` for this purpose.
#### `Group.removeMember(accountID)`
```typescript
removeMember(accountID: AccountID)
```
Strips the specified member of all roles (preventing future writes) and rotates the read encryption key for that group (preventing reads of new content, including in covalues owned by this group)
#### `Group.createMap(meta?)`
```typescript
createMap<
M extends { [key: string]: JsonValue },
Meta extends JsonObject | null = null
>(meta?: Meta): CoMap<M, Meta>
```
Creates a new `CoMap` within this group, with the specified inner content type `M` and optional static metadata.
#### `Group.createList(meta?)` (coming soon)
#### `Group.createStream(meta?)` (coming soon)
#### `Group.createStatic(meta)` (coming soon)
---
### `CoValue` ContentType: `CoMap`
```typescript
class CoMap<
M extends { [key: string]: JsonValue; },
Meta extends JsonObject | null = null,
>
```
#### `CoMap.id`
```typescript
id: CoID<CoMap<M, Meta>>
```
Returns the CoMap's (precisely typed) `CoID`
#### `CoMap.keys()`
```typescript
keys(): (keyof M & string)[]
```
#### `CoMap.get(key)`
```typescript
get<K extends keyof M>(key: K): M[K] | undefined
```
Returns the current value for the given key.
#### `CoMap.getLastEditor(key)`
```typescript
getLastEditor<K extends keyof M>(key: K): AccountID | undefined
```
Returns the accountID of the last account to modify the value for the given key.
#### `CoMap.toJSON()`
```typescript
toJSON(): JsonObject
```
Returns a JSON representation of the state of the CoMap.
#### `CoMap.subscribe(listener)`
```typescript
subscribe(
listener: (coMap: CoMap<M, Meta>) => void
): () => void
```
Lets you subscribe to future updates to this CoMap (whether made locally or by other users). Takes a listener function that will be called with the current state for each update. Returns an unsubscribe function.
Used internally by `useTelepathicData()` for reactive updates on changes to a `CoMap`.
#### `CoMap.edit(editable => {...})`
```typescript
edit(changer: (editable: WriteableCoMap<M, Meta>) => void): CoMap<M, Meta>
```
Lets you apply edits to a `CoMap`, inside the changer callback, which receives a `WriteableCoMap`. A `WritableCoMap` has all the same methods as a `CoMap`, but all edits made to it with `set` or `delete` are reflected in it immediately - so it behaves mutably, whereas a `CoMap` is always immutable (you need to use `subscribe` to receive new versions of it).
```typescript
export class WriteableCoMap<
M extends { [key: string]: JsonValue; },
Meta extends JsonObject | null = null,
> extends CoMap<M, Meta>
```
#### `WritableCoMap.set(key, value)`
```typescript
set<K extends keyof M>(
key: K,
value: M[K],
privacy: "private" | "trusting" = "private"
): void
```
Sets a new value for the given key.
If `privacy` is `"private"` **(default)**, both `key` and `value` are encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
If `privacy` is `"trusting"`, both `key` and `value` are stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
#### `WritableCoMap.delete(key)`
```typescript
delete<K extends keyof M>(
key: K,
privacy: "private" | "trusting" = "private"
): void
```
Deletes the value for the given key (setting it to undefined).
If `privacy` is `"private"` **(default)**, `key` is encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
If `privacy` is `"trusting"`, `key` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
---
### `CoValue` ContentType: `CoList` (not yet implemented)
---
### `CoValue` ContentType: `CoStram` (not yet implemented)
---
### `CoValue` ContentType: `Static` (not yet implemented)
---
## `jazz-react`
---
### `<WithJazz>`
Not yet documented, see [`examples/todo`](./examples/todo/) for now
---
### `useJazz()`
Not yet documented, see [`examples/todo`](./examples/todo/) for now
---
### `useTelepathicData(coID)`
### `useProfile(accountID)`
Not yet documented, see [`examples/todo`](./examples/todo/) for now
---
### `useProfile(accountID)`
Not yet documented, see [`examples/todo`](./examples/todo/) for now

View File

@@ -4,7 +4,7 @@
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.1.1",
"version": "0.1.2",
"devDependencies": {
"@types/jest": "^29.5.3",
"@types/ws": "^8.5.5",
@@ -16,7 +16,7 @@
"typescript": "5.0.2"
},
"dependencies": {
"cojson": "^0.1.2",
"cojson": "^0.1.3",
"ws": "^8.13.0"
},
"scripts": {

View File

@@ -5,7 +5,7 @@
"types": "dist/index.d.ts",
"type": "module",
"license": "MIT",
"version": "0.1.2",
"version": "0.1.3",
"devDependencies": {
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.2.1",

View File

@@ -149,16 +149,11 @@ export class SyncManager {
}
}
async subscribeToIncludingDependencies(
id: RawCoID,
peer: PeerState
) {
async subscribeToIncludingDependencies(id: RawCoID, peer: PeerState) {
const entry = this.local.coValues[id];
if (!entry) {
throw new Error(
"Expected coValue entry on subscribe"
);
throw new Error("Expected coValue entry on subscribe");
}
if (entry.state === "loading") {
@@ -212,10 +207,7 @@ export class SyncManager {
}
}
async sendNewContentIncludingDependencies(
id: RawCoID,
peer: PeerState
) {
async sendNewContentIncludingDependencies(id: RawCoID, peer: PeerState) {
const coValue = this.local.expectCoValueLoaded(id);
for (const id of coValue.getDependedOnCoValues()) {
@@ -229,8 +221,7 @@ export class SyncManager {
if (newContent) {
await this.trySendToPeer(peer, newContent);
peer.optimisticKnownStates[id] = combinedKnownStates(
peer.optimisticKnownStates[id] ||
emptyKnownState(id),
peer.optimisticKnownStates[id] || emptyKnownState(id),
coValue.knownState()
);
}
@@ -265,17 +256,22 @@ export class SyncManager {
}
const readIncoming = async () => {
for await (const msg of peerState.incoming) {
try {
await this.handleSyncMessage(msg, peerState);
} catch (e) {
console.error(
`Error reading from peer ${peer.id}`,
JSON.stringify(msg),
e
);
try {
for await (const msg of peerState.incoming) {
try {
await this.handleSyncMessage(msg, peerState);
} catch (e) {
console.error(
`Error reading from peer ${peer.id}, handling msg`,
JSON.stringify(msg),
e
);
}
}
} catch (e) {
console.error(`Error reading from peer ${peer.id}`, e);
}
console.log("Peer disconnected:", peer.id);
delete this.peers[peer.id];
};
@@ -285,7 +281,11 @@ export class SyncManager {
trySendToPeer(peer: PeerState, msg: SyncMessage) {
return peer.outgoing.write(msg).catch((e) => {
console.error(new Error(`Error writing to peer ${peer.id}, disconnecting`, {cause: e}));
console.error(
new Error(`Error writing to peer ${peer.id}, disconnecting`, {
cause: e,
})
);
delete this.peers[peer.id];
});
}
@@ -313,10 +313,7 @@ export class SyncManager {
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
await this.tellUntoldKnownStateIncludingDependencies(
msg.id,
peer
);
await this.tellUntoldKnownStateIncludingDependencies(msg.id, peer);
await this.sendNewContentIncludingDependencies(msg.id, peer);
}
@@ -325,8 +322,7 @@ export class SyncManager {
let entry = this.local.coValues[msg.id];
peer.optimisticKnownStates[msg.id] = combinedKnownStates(
peer.optimisticKnownStates[msg.id] ||
emptyKnownState(msg.id),
peer.optimisticKnownStates[msg.id] || emptyKnownState(msg.id),
knownStateIn(msg)
);
@@ -352,10 +348,7 @@ export class SyncManager {
return [];
}
await this.tellUntoldKnownStateIncludingDependencies(
msg.id,
peer
);
await this.tellUntoldKnownStateIncludingDependencies(msg.id, peer);
await this.sendNewContentIncludingDependencies(msg.id, peer);
}
@@ -370,8 +363,7 @@ export class SyncManager {
let resolveAfterDone: ((coValue: CoValue) => void) | undefined;
const peerOptimisticKnownState =
peer.optimisticKnownStates[msg.id];
const peerOptimisticKnownState = peer.optimisticKnownStates[msg.id];
if (!peerOptimisticKnownState) {
throw new Error(
@@ -453,10 +445,7 @@ export class SyncManager {
}
}
async handleCorrection(
msg: KnownStateMessage,
peer: PeerState
) {
async handleCorrection(msg: KnownStateMessage, peer: PeerState) {
const coValue = this.local.expectCoValueLoaded(msg.id);
peer.optimisticKnownStates[msg.id] = combinedKnownStates(
@@ -499,11 +488,7 @@ export class SyncManager {
}
}
function knownStateIn(
msg:
| LoadMessage
| KnownStateMessage
) {
function knownStateIn(msg: LoadMessage | KnownStateMessage) {
return {
id: msg.id,
header: msg.header,

View File

@@ -1,11 +1,11 @@
{
"name": "jazz-browser-auth-local",
"version": "0.1.2",
"version": "0.1.3",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"jazz-browser": "^0.1.2",
"jazz-browser": "^0.1.3",
"typescript": "^5.1.6"
},
"scripts": {

View File

@@ -1,12 +1,12 @@
{
"name": "jazz-browser",
"version": "0.1.2",
"version": "0.1.3",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.1.2",
"jazz-storage-indexeddb": "^0.1.2",
"cojson": "^0.1.3",
"jazz-storage-indexeddb": "^0.1.3",
"typescript": "^5.1.6"
},
"scripts": {

View File

@@ -42,7 +42,7 @@ export async function createBrowserNode({
[await IDBStorage.asPeer({ trace: true }), firstWsPeer]
);
void async function websocketReconnectLoop() {
async function websocketReconnectLoop() {
while (shouldTryToReconnect) {
if (
Object.keys(node.sync.peers).some((peerId) =>
@@ -60,7 +60,9 @@ export async function createBrowserNode({
);
}
}
};
}
void websocketReconnectLoop();
return {
node,
@@ -153,6 +155,8 @@ function websocketReadableStream<T>(ws: WebSocket) {
return new ReadableStream<T>({
start(controller) {
let pingTimeout: ReturnType<typeof setTimeout> | undefined;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "ping") {
@@ -163,13 +167,25 @@ function websocketReadableStream<T>(ws: WebSocket) {
Date.now() - msg.time,
"ms"
);
if (pingTimeout) {
clearTimeout(pingTimeout);
}
pingTimeout = setTimeout(() => {
console.debug("Ping timeout");
controller.close();
ws.close();
}, 2500);
return;
}
controller.enqueue(msg);
};
ws.onclose = () => controller.close();
ws.onerror = () =>
controller.error(new Error("The WebSocket errored!"));
ws.addEventListener("close", () => controller.close());
ws.addEventListener("error", () =>
controller.error(new Error("The WebSocket errored!"))
);
},
cancel() {
@@ -195,15 +211,15 @@ function createWebSocketPeer(syncAddress: string): Peer {
function websocketWritableStream<T>(ws: WebSocket) {
return new WritableStream<T>({
start(controller) {
ws.onerror = () => {
ws.addEventListener("error", () => {
controller.error(new Error("The WebSocket errored!"));
ws.onclose = null;
};
ws.onclose = () =>
});
ws.addEventListener("close", () => {
controller.error(
new Error("The server closed the connection unexpectedly!")
);
return new Promise((resolve) => (ws.onopen = resolve));
});
return new Promise((resolve) => (ws.addEventListener("open", resolve)));
},
write(chunk) {
@@ -223,13 +239,19 @@ function websocketWritableStream<T>(ws: WebSocket) {
function closeWS(code: number, reasonString?: string) {
return new Promise<void>((resolve, reject) => {
ws.onclose = (e) => {
if (e.wasClean) {
resolve();
} else {
reject(new Error("The connection was not closed cleanly"));
}
};
ws.addEventListener(
"close",
(e) => {
if (e.wasClean) {
resolve();
} else {
reject(
new Error("The connection was not closed cleanly")
);
}
},
{ once: true }
);
ws.close(code, reasonString);
});
}
@@ -277,8 +299,7 @@ export function parseInviteLink(inviteURL: string):
const valueID = url.hash
.split("&")[0]
?.replace(/^#invitedTo=/, "") as CoID<ContentType>;
const inviteSecret = url.hash
.split("&")[1] as InviteSecret;
const inviteSecret = url.hash.split("&")[1] as InviteSecret;
if (!valueID || !inviteSecret) {
return undefined;
}

View File

@@ -1,12 +1,12 @@
{
"name": "jazz-react-auth-local",
"version": "0.1.3",
"version": "0.1.4",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"jazz-browser-auth-local": "^0.1.2",
"jazz-react": "^0.1.3",
"jazz-browser-auth-local": "^0.1.3",
"jazz-react": "^0.1.4",
"typescript": "^5.1.6"
},
"devDependencies": {

View File

@@ -1,12 +1,12 @@
{
"name": "jazz-react",
"version": "0.1.3",
"version": "0.1.4",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.1.2",
"jazz-browser": "^0.1.2",
"cojson": "^0.1.3",
"jazz-browser": "^0.1.3",
"typescript": "^5.1.6"
},
"devDependencies": {

View File

@@ -1,11 +1,11 @@
{
"name": "jazz-storage-indexeddb",
"version": "0.1.2",
"version": "0.1.3",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.1.2",
"cojson": "^0.1.3",
"typescript": "^5.1.6"
},
"devDependencies": {