Compare commits

...

45 Commits

Author SHA1 Message Date
Anselm
a1a96e1118 Publish
- jazz-example-todo@0.0.15
 - cojson@0.1.2
 - cojson-simple-sync@0.1.1
 - jazz-browser@0.1.2
 - jazz-browser-auth-local@0.1.2
 - jazz-react@0.1.2
 - jazz-react-auth-local@0.1.2
 - jazz-storage-indexeddb@0.1.2
2023-08-19 20:18:28 +01:00
Anselm
40b4ebaf00 Skeleton for tasks 2023-08-19 20:18:09 +01:00
Anselm
37559b2dec Cache readKeys 2023-08-19 20:18:05 +01:00
Anselm
81fd3e8aff Update READMEs 2023-08-19 20:10:09 +01:00
Anselm
f1747e1aaf Publish
- jazz-example-todo@0.0.14
 - cojson-simple-sync@0.1.0
2023-08-19 18:47:55 +01:00
Anselm
934365c24d Add cojson-simple-sync 2023-08-19 18:47:26 +01:00
Anselm
ff20c3a260 Start of readme 2023-08-19 18:46:11 +01:00
Anselm
1d0ce83019 Tweak name badges 2023-08-19 18:04:53 +01:00
Anselm
2951d8452f Publish
- jazz-example-todo@0.0.13
 - cojson@0.1.1
 - jazz-browser@0.1.1
 - jazz-browser-auth-local@0.1.1
 - jazz-react@0.1.1
 - jazz-react-auth-local@0.1.1
 - jazz-storage-indexeddb@0.1.1
2023-08-19 17:51:32 +01:00
Anselm
6532e79790 Small fix to acceptInvite 2023-08-19 17:51:15 +01:00
Anselm Eickhoff
09603d17a3 Merge pull request #42 from gardencmp/anselm-gar-111
Rename "Team" to "Group"
2023-08-19 16:47:57 +01:00
Anselm
10b372da6e Publish
- jazz-example-todo@0.0.12
 - cojson@0.1.0
 - jazz-browser@0.1.0
 - jazz-browser-auth-local@0.1.0
 - jazz-react@0.1.0
 - jazz-react-auth-local@0.1.0
 - jazz-storage-indexeddb@0.1.0
2023-08-19 16:32:32 +01:00
Anselm
b556a36db3 Rename "Team" to "Group" 2023-08-19 16:31:40 +01:00
Anselm Eickhoff
094c505cf0 Merge pull request #41 from gardencmp/anselm-gar-112
Polish todo example
2023-08-19 16:31:21 +01:00
Anselm
4d71ab8aac Lint 2023-08-19 16:25:15 +01:00
Anselm
f1297c613b Polishing 2023-08-19 16:22:43 +01:00
Anselm
1ee5c2b3c8 Cleaner invite links 2023-08-19 15:58:55 +01:00
Anselm
f14337f862 Publish
- jazz-example-todo@0.0.11
 - cojson@0.0.24
 - jazz-browser@0.0.7
 - jazz-browser-auth-local@0.0.7
 - jazz-react@0.0.17
 - jazz-react-auth-local@0.0.14
 - jazz-storage-indexeddb@0.0.11
2023-08-19 15:48:33 +01:00
Anselm
b8f4571474 Fix first time invite flow 2023-08-19 15:48:19 +01:00
Anselm
6557532743 Publish
- jazz-example-todo@0.0.10
 - cojson@0.0.23
 - jazz-browser@0.0.6
 - jazz-browser-auth-local@0.0.6
 - jazz-react@0.0.16
 - jazz-react-auth-local@0.0.13
 - jazz-storage-indexeddb@0.0.10
2023-08-19 14:54:51 +01:00
Anselm
ea6835664b Lots of fixes related to first-time invite race conditions 2023-08-19 14:54:22 +01:00
Anselm Eickhoff
a00332f4af Merge pull request #39 from gardencmp/anselm-gar-78
Implement Invitations
2023-08-19 12:46:10 +01:00
Anselm
6a6fb2eb3c Publish
- jazz-example-todo@0.0.9
 - cojson@0.0.22
 - jazz-browser@0.0.5
 - jazz-browser-auth-local@0.0.5
 - jazz-react@0.0.15
 - jazz-react-auth-local@0.0.12
 - jazz-storage-indexeddb@0.0.9
2023-08-19 12:24:08 +01:00
Anselm
0437223d50 Invitation flow improvements 2023-08-19 12:23:31 +01:00
Anselm
30c7e1bf6d Replace "insert" op with "set" 2023-08-19 12:23:17 +01:00
Anselm
aaa9d876d5 Implement high-level interface for invites 2023-08-18 18:13:20 +01:00
Anselm
264009a1a9 Implement underlying permissions for invites 2023-08-18 16:32:39 +01:00
Anselm Eickhoff
f2cb5d1b59 Merge pull request #38 from gardencmp/anselm-gar-108
Create proper accounts in local auth
2023-08-17 22:49:53 +01:00
Anselm
8610db2d8e more style fixes 2023-08-17 22:46:08 +01:00
Anselm
c672a03338 Large text in input 2023-08-17 22:44:43 +01:00
Anselm
cb60088d2a Publish
- jazz-example-todo@0.0.8
 - cojson@0.0.21
 - jazz-browser@0.0.4
 - jazz-browser-auth-local@0.0.4
 - jazz-react@0.0.14
 - jazz-react-auth-local@0.0.11
 - jazz-storage-indexeddb@0.0.8
2023-08-17 22:24:01 +01:00
Anselm
a59d5d3b70 Fix max call stack size tsc errors 2023-08-17 22:23:47 +01:00
Anselm
dcdf829a05 Publish
- jazz-example-todo@0.0.7
 - cojson@0.0.20
 - jazz-browser@0.0.3
 - jazz-browser-auth-local@0.0.3
 - jazz-react@0.0.13
 - jazz-react-auth-local@0.0.10
 - jazz-storage-indexeddb@0.0.7
2023-08-17 22:04:08 +01:00
Anselm
a907321d02 Fix main and types fields 2023-08-17 22:03:43 +01:00
Anselm
a2b9be9dd0 Lint 2023-08-17 21:52:58 +01:00
Anselm
432d114438 Lint 2023-08-17 21:51:44 +01:00
Anselm
17a2e2337a Publish
- jazz-example-todo@0.0.6
 - cojson@0.0.19
 - jazz-browser@0.0.2
 - jazz-browser-auth-local@0.0.2
 - jazz-react@0.0.12
 - jazz-react-auth-local@0.0.9
 - jazz-storage-indexeddb@0.0.6
2023-08-17 21:47:42 +01:00
Anselm
442e6d58cb Various small improvements 2023-08-17 21:47:01 +01:00
Anselm
2b26771bc0 Use proper accounts in local auth and separate out JS libs 2023-08-17 21:28:53 +01:00
Anselm Eickhoff
0851d21674 Deploy todo-example (#36) 2023-08-17 15:40:52 +01:00
Anselm Eickhoff
2c79dbb335 Merge pull request #33 from gardencmp:anselm/gar-70-simple-indexeddb-storage-peer
Simple IndexedDB storage peer
2023-08-16 16:55:06 +01:00
Anselm
5966170c38 Publish
- jazz-example-todo@0.0.3
 - cojson@0.0.17
 - jazz-react@0.0.9
 - jazz-react-auth-local@0.0.6
 - jazz-storage-indexeddb@0.0.4
2023-08-16 16:20:29 +01:00
Anselm
798a88d1fc Use IndexedDB in jazz-react & todo example 2023-08-16 16:20:00 +01:00
Anselm
60a9961ff2 Ensure IndexedDB loads depended on values 2023-08-16 16:14:07 +01:00
Anselm
7964e5d382 First successful store & load to IndexedDB 2023-08-16 15:48:19 +01:00
82 changed files with 7565 additions and 3323 deletions

81
.github/workflows/build-and-deploy.yaml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: Build and Deploy
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: true
- uses: actions/setup-node@v3
with:
node-version: 18
cache: 'yarn'
cache-dependency-path: yarn.lock
- name: Nuke Workspace
run: |
rm package.json yarn.lock;
- name: Yarn Build
run: |
yarn install --frozen-lockfile;
yarn build;
working-directory: ./examples/todo
- uses: satackey/action-docker-layer-caching@v0.0.11
continue-on-error: true
with:
key: docker-layer-caching-${{ github.workflow }}-{hash}
restore-keys: |
docker-layer-caching-${{ github.workflow }}-
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: gardencmp
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Build & Push
run: |
export DOCKER_TAG=ghcr.io/gardencmp/jazz-example-todo:${{github.head_ref || github.ref_name}}-${{github.sha}}-$(date +%s) ;
docker build . --file Dockerfile --tag $DOCKER_TAG;
docker push $DOCKER_TAG;
echo "DOCKER_TAG=$DOCKER_TAG" >> $GITHUB_ENV
working-directory: ./examples/todo
- uses: gacts/install-nomad@v1
- name: Tailscale
uses: tailscale/github-action@v1
with:
authkey: ${{ secrets.TAILSCALE_AUTHKEY }}
- name: Deploy on Nomad
run: |
if [ "${{github.ref_name}}" == "main" ]; then
export BRANCH_SUFFIX="";
export BRANCH_SUBDOMAIN="";
else
export BRANCH_SUFFIX=-${{github.head_ref || github.ref_name}};
export BRANCH_SUBDOMAIN=${{github.head_ref || github.ref_name}}.;
fi
export DOCKER_USER=gardencmp;
export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
export DOCKER_TAG=${{ env.DOCKER_TAG }};
for region in ${{ vars.DEPLOY_REGIONS }}
do
export REGION=$region;
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN} ${REGION}' < job-template.nomad > job-instance.nomad;
cat job-instance.nomad;
NOMAD_ADDR='${{ secrets.NOMAD_ADDR }}' nomad job run job-instance.nomad;
done
working-directory: ./examples/todo

104
README.md
View File

@@ -1,9 +1,103 @@
# Jazz - instant sync
Jazz is an open-source toolkit for telepathic data.
Homepage: [jazz.tools](https://jazz.tools) &mdash; [Discord](https://discord.gg/utDMjHYg42)
Ship faster and simplify frontend, backend & devops by building with Telepathic Data.
Get real-time multiplayer and cross-device sync for free.
Jazz is an open-source toolkit for *permissioned telepathic data.*
## What is Telepathic Data?
...
- Ship faster & simplify your frontend and backend
- Get cross-device sync, real-time collaboration & offline support for free
[Jazz Global Mesh](https://jazz.tools/mesh) is serverless sync & storage for Jazz apps. (currently free!)
## What is Permissioned 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 &mdash; or to other users (coming soon: to your backend, workers, etc.)
**Permissioned** means:
- **Fine-grained, role-based permissions are *baked into* your data.**
- **Permissions are enforced everywhere, locally.** (using cryptography instead of through an API)
- Roles can be changed dynamically, supporting changing teams, invite links and more.
## How to build an app with Jazz?
### Building a new app, completely with Jazz
It's still a bit early, but these are the rough steps:
1. Define your data model with [CoJSON Values](#cojson).
2. Implement permission logic using [CoJSON Groups](#group).
3. Hook up a user interface with [jazz-react](#jazz-react).
The best example is currently the [Todo List app](#example-app-todo-list).
### Gradually adding Jazz to an existing app
Coming soon: Jazz will support gradual adoption by integrating with your existing UI, auth and database.
## Example App: Todo List
The best example of Jazz is currently the Todo List app.
- Live version: https://example-todo.jazz.tools
- Source code: [`./examples/todo`](./examples/todo). See the README there for a walk-through and running instructions.
# API Reference
Note: Since it's early days, this is the only source of documentation so far.
If you want to build something with Jazz, [join the Jazz Discord](https://discord.gg/utDMjHYg42) for encouragement and help!
## Overview: Main Packages
**`cojson`:** A library implementing abstractions and protocols for "Collaborative JSON". This will soon be standardized and forms the basis of permissioned telepathic data.
**`jazz-react`:** Provides you with everything you need to build react apps around CoJSON, including reactive hooks for telepathic data, local IndexedDB persistence, support for different auth providers and helpers for simple invite links for CoJSON groups.
### Supporting packages
<small>
**`cojson-simple-sync`:**
A generic CoJSON sync server you can run locally if you don't want to use Jazz Global Mesh (the default sync backend, at `wss://sync.jazz.tools`)
**`jazz-browser`:** framework-agnostic primitives that allow you to use CoJSON in the browser. Used to implement `jazz-react`, will be used to implement bindings for other frameworks in the future.
**`jazz-react-auth-local`** (and `jazz-browser-auth-local`): A simple auth provider that stores cryptographic keys on user devices using WebAuthentication/Passkeys. Lets you build Jazz apps completely without a backend, with end-to-end encryption by default.
**`jazz-storage-indexeddb`**: Provides local, offline-capable persistence. Included and enabled in `jazz-react` by default.
</small>
## `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.
### `LocalNode`
A `LocalNode` represents a local view of a set of loaded CoValues
### `Group`
### `CoValue` & `ContentType`s
#### `CoMap`
#### `CoList` (coming soon)
#### `CoStram` (coming soon)
#### `Static` (coming soon)
## `jazz-react`
### `<WithJazz>`
### `useJazz()`
### `useTelepathicData(coID)`
### `useProfile(accountID)`

4
examples/todo/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM caddy:2.7.3-alpine
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
COPY ./dist /usr/share/caddy/

View File

@@ -1,76 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--radius: 0.5rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/jazz-logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>Jazz Todo List Example</title>
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,54 @@
job "example-todo$BRANCH_SUFFIX" {
region = "$REGION"
datacenters = ["$REGION"]
group "static" {
// count = 3
network {
port "http" {
to = 80
}
}
constraint {
attribute = "${node.class}"
operator = "="
value = "edge"
}
// spread {
// attribute = "${node.datacenter}"
// weight = 100
// }
task "server" {
driver = "docker"
config {
image = "$DOCKER_TAG"
ports = ["http"]
auth = {
username = "$DOCKER_USER"
password = "$DOCKER_PASSWORD"
}
}
service {
tags = ["public"]
meta {
public_name = "${BRANCH_SUBDOMAIN}example-todo"
}
port = "http"
provider = "consul"
}
resources {
cpu = 50 # MHz
memory = 50 # MB
}
}
}
}
# deploy bump 4

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-todo",
"private": true,
"version": "0.0.2",
"version": "0.0.15",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,15 +12,17 @@
"dependencies": {
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "^0.0.8",
"jazz-react-auth-local": "^0.0.5",
"jazz-react": "^0.1.2",
"jazz-react-auth-local": "^0.1.2",
"lucide-react": "^0.265.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.6"
"tailwindcss-animate": "^1.0.6",
"uniqolor": "^1.1.0"
},
"devDependencies": {
"@types/react": "^18.2.15",

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -6,50 +6,92 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { CoMap, CoID } from "cojson";
import { useJazz, useTelepathicState } from "jazz-react";
import { useCallback, useEffect, useState } from "react";
import { CoMap, CoID, AccountID } from "cojson";
import {
consumeInviteLinkFromWindowLocation,
useJazz,
useProfile,
useTelepathicState,
} from "jazz-react";
import { SubmittableInput } from "./components/SubmittableInput";
import { createInviteLink } from "jazz-react";
import { useToast } from "./components/ui/use-toast";
import { Skeleton } from "./components/ui/skeleton";
import uniqolor from "uniqolor";
type TaskContent = { done: boolean; text: string };
type Task = CoMap<TaskContent>;
type TodoListContent = { title: string; [taskId: CoID<Task>]: true };
type TodoListContent = {
title: string;
// other keys form a set of task IDs
[taskId: CoID<Task>]: true;
};
type TodoList = CoMap<TodoListContent>;
function App() {
const [listId, setListId] = useState<CoID<TodoList>>(window.location.hash.slice(1) as CoID<TodoList>);
const [listId, setListId] = useState<CoID<TodoList>>();
const { localNode } = useJazz();
const { localNode, logOut } = useJazz();
const createList = () => {
const listTeam = localNode.createTeam();
const list = listTeam.createMap<TodoListContent, null>();
const createList = useCallback(
(title: string) => {
const listGroup = localNode.createGroup();
const list = listGroup.createMap<TodoListContent>();
list.edit((list) => {
list.set("title", "My Todo List");
});
list.edit((list) => {
list.set("title", title);
});
window.location.hash = list.id;
};
window.location.hash = list.id;
},
[localNode]
);
useEffect(() => {
const listener = () => {
const listener = async () => {
const acceptedInvitation =
await consumeInviteLinkFromWindowLocation(localNode);
if (acceptedInvitation) {
setListId(acceptedInvitation.valueID as CoID<TodoList>);
window.location.hash = acceptedInvitation.valueID;
return;
}
setListId(window.location.hash.slice(1) as CoID<TodoList>);
}
};
window.addEventListener("hashchange", listener);
listener();
return () => {
window.removeEventListener("hashchange", listener);
}
}, [])
};
}, [localNode]);
return (
<div className="flex flex-col h-full items-center justify-center gap-10">
{listId && <TodoList listId={listId} />}
<Button onClick={createList}>Create New List</Button>
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
{listId ? (
<TodoList listId={listId} />
) : (
<SubmittableInput
onSubmit={createList}
label="Create New List"
placeholder="New list title"
/>
)}
<Button
onClick={() => {
window.location.hash = "";
logOut();
}}
variant="outline"
>
Log Out
</Button>
</div>
);
}
@@ -57,29 +99,35 @@ function App() {
export function TodoList({ listId }: { listId: CoID<TodoList> }) {
const list = useTelepathicState(listId);
const createTodo = (text: string) => {
const createTask = (text: string) => {
if (!list) return;
let task = list.coValue.getTeam().createMap<TaskContent, {}>();
const task = list.coValue.getGroup().createMap<TaskContent>();
task = task.edit((task) => {
task.edit((task) => {
task.set("text", text);
task.set("done", false);
});
console.log("Created task", task.id, task.toJSON());
const listAfter = list.edit((list) => {
list.edit((list) => {
list.set(task.id, true);
});
console.log("Updated list", listAfter.toJSON());
};
return (
<div className="max-w-full w-4xl">
<h1>
{list?.get("title")} ({list?.id})
</h1>
<div className="flex justify-between items-center gap-4 mb-4">
<h1>
{list?.get("title") ? (
<>
{list.get("title")}{" "}
<span className="text-sm">({list.id})</span>
</>
) : (
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
)}
</h1>
{list && <InviteButton list={list} />}
</div>
<Table>
<TableHeader>
<TableRow>
@@ -95,35 +143,19 @@ export function TodoList({ listId }: { listId: CoID<TodoList> }) {
key.startsWith("co_")
)
.map((taskId) => (
<TodoRow key={taskId} taskId={taskId} />
<TaskRow key={taskId} taskId={taskId} />
))}
<TableRow key="new">
<TableCell>
<Checkbox className="mt-1" disabled />
</TableCell>
<TableCell>
<form
className="flex flex-row items-center gap-5"
onSubmit={(e) => {
e.preventDefault();
const textEl =
e.currentTarget.elements.namedItem(
"text"
) as HTMLInputElement;
createTodo(textEl.value);
textEl.value = "";
}}
>
<Input
className="-ml-3 -my-2"
name="text"
placeholder="Add todo"
autoComplete="off"
/>
<Button asChild type="submit">
<Input type="submit" value="Add" />
</Button>
</form>
<SubmittableInput
onSubmit={(taskText) => createTask(taskText)}
label="Add"
placeholder="New task"
disabled={!list}
/>
</TableCell>
</TableRow>
</TableBody>
@@ -132,7 +164,7 @@ export function TodoList({ listId }: { listId: CoID<TodoList> }) {
);
}
function TodoRow({ taskId }: { taskId: CoID<Task> }) {
function TaskRow({ taskId }: { taskId: CoID<Task> }) {
const task = useTelepathicState(taskId);
return (
@@ -148,11 +180,71 @@ function TodoRow({ taskId }: { taskId: CoID<Task> }) {
}}
/>
</TableCell>
<TableCell className={task?.get("done") ? "line-through" : ""}>
{task?.get("text")}
<TableCell>
<div className="flex flex-row justify-between items-center gap-2">
<span className={task?.get("done") ? "line-through" : ""}>
{task?.get("text") || <Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />}
</span>
<NameBadge accountID={task?.getLastEditor("text")} />
</div>
</TableCell>
</TableRow>
);
}
function NameBadge({ accountID }: { accountID?: AccountID }) {
const profile = useProfile({ accountID });
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
return (
profile?.get("name") && <span
className="rounded-full py-0.5 px-2 text-xs"
style={{
color: theme == "light" ? darkColor : brightColor,
background: theme == "light" ? brightColor : darkColor,
}}
>
{profile?.get("name")}
</span>
);
}
function InviteButton({ list }: { list: TodoList }) {
const [existingInviteLink, setExistingInviteLink] = useState<string>();
const { toast } = useToast();
return (
list.coValue.getGroup().myRole() === "admin" && (
<Button
size="sm"
className="py-0"
disabled={!list}
variant="outline"
onClick={() => {
let inviteLink = existingInviteLink;
if (list && !inviteLink) {
inviteLink = createInviteLink(list, "writer");
setExistingInviteLink(inviteLink);
}
if (inviteLink) {
navigator.clipboard.writeText(inviteLink).then(() =>
toast({
description: "Copied invite link to clipboard!",
})
);
}
}}
>
Invite
</Button>
)
);
}
export default App;

View File

@@ -1,31 +0,0 @@
import React from "react";
import { Input } from "./components/ui/input.tsx";
import { Button } from "./components/ui/button.tsx";
import { AuthComponent } from "jazz-react";
import { useLocalAuth } from "jazz-react-auth-local";
export const LocalAuth: AuthComponent = ({ onCredential }) => {
const { displayName, setDisplayName, signIn, signUp } = useLocalAuth(onCredential);
return (<div className="w-full h-full flex items-center justify-center">
<div className="w-72 flex flex-col gap-4">
<form
className="w-72 flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
signUp();
}}
>
<Input
placeholder="Display name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoComplete="webauthn" />
<Button asChild>
<Input type="submit" value="Sign Up as new account" />
</Button>
</form>
<Button onClick={signIn}>Log In with existing account</Button>
</div>
</div>);
};

View File

@@ -0,0 +1,39 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
export function SubmittableInput({
onSubmit,
label,
placeholder,
disabled,
}: {
onSubmit: (text: string) => void;
label: string;
placeholder: string;
disabled?: boolean;
}) {
return (
<form
className="flex flex-row items-center gap-3"
onSubmit={(e) => {
e.preventDefault();
const textEl = e.currentTarget.elements.namedItem(
"text"
) as HTMLInputElement;
onSubmit(textEl.value);
textEl.value = "";
}}
>
<Input
className="-ml-3 -my-2 flex-grow flex-3 text-base"
name="text"
placeholder={placeholder}
autoComplete="off"
disabled={disabled}
/>
<Button asChild type="submit" className="flex-shrink flex-1 cursor-pointer">
<Input type="submit" value={label} disabled={disabled} />
</Button>
</form>
);
}

View File

@@ -0,0 +1,47 @@
import { LocalAuthComponent } from "jazz-react-auth-local";
import { useState } from "react";
import { Input } from "./ui/input";
import { Button } from "./ui/button";
export const PrettyAuthComponent: LocalAuthComponent = ({
loading,
logIn,
signUp,
}) => {
const [username, setUsername] = useState<string>("");
return (
<div className="w-full h-full flex items-center justify-center p-5">
{loading ? (
<div>Loading...</div>
) : (
<div className="w-72 flex flex-col gap-4">
<form
className="w-72 flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
signUp(username);
}}
>
<Input
placeholder="Display name"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="webauthn"
className="text-base"
/>
<Button asChild>
<Input
type="submit"
value="Sign Up as new account"
/>
</Button>
</form>
<Button onClick={logIn}>
Log In with existing account
</Button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,72 @@
import { createContext, useContext, useEffect, useState } from "react";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: string;
storageKey?: string;
};
type ThemeProviderState = {
theme: string;
setTheme: (theme: string) => void;
};
const initialState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState(
() => localStorage.getItem(storageKey) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: string) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,192 @@
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@@ -3,12 +3,28 @@ import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { WithJazz } from "jazz-react";
import { LocalAuth } from "./LocalAuth.tsx";
import { LocalAuth } from "jazz-react-auth-local";
import { PrettyAuthComponent } from "./components/prettyAuth.tsx";
import { ThemeProvider } from "./components/themeProvider.tsx";
import { Toaster } from "./components/ui/toaster.tsx";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<WithJazz auth={LocalAuth}>
<App />
</WithJazz>
<ThemeProvider>
<div className="flex items-center gap-2 justify-center mt-5"><img src="jazz-logo.png" className="h-5"/> Jazz Todo List Example</div>
<WithJazz
auth={LocalAuth({
appName: "Jazz Todo List Example",
Component: PrettyAuthComponent,
})}
syncAddress={
new URLSearchParams(window.location.search).get("sync") ||
undefined
}
>
<App />
<Toaster />
</WithJazz>
</ThemeProvider>
</React.StrictMode>
);

View File

@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

View File

@@ -9,5 +9,8 @@ export default defineConfig({
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
minify: false
}
})

File diff suppressed because it is too large Load Diff

6
jest.config.js Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('jest').Config} */
const config = {
projects: ['<rootDir>/packages/cojson'],
};
module.exports = config;

View File

@@ -0,0 +1,18 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
project: './tsconfig.json',
},
root: true,
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/no-floating-promises": "error",
},
};

173
packages/cojson-simple-sync/.gitignore vendored Normal file
View File

@@ -0,0 +1,173 @@
# 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
out

View File

@@ -0,0 +1,34 @@
{
"name": "cojson-simple-sync",
"module": "dist/index.js",
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.1.1",
"devDependencies": {
"@types/jest": "^29.5.3",
"@types/ws": "^8.5.5",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"eslint": "^8.46.0",
"jest": "^29.6.2",
"ts-jest": "^29.1.1",
"typescript": "5.0.2"
},
"dependencies": {
"cojson": "^0.1.2",
"ws": "^8.13.0"
},
"scripts": {
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist && npm run add-shebang && chmod +x ./dist/index.js",
"add-shebang": "echo \"#!/usr/bin/env node\" | cat - ./dist/index.js > /tmp/out && mv /tmp/out ./dist/index.js",
"start": "node dist/index.js",
"test": "jest",
"prepublishOnly": "npm run build"
},
"bin": "./dist/index.js",
"jest": {
"preset": "ts-jest",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,58 @@
import { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
import { WebSocketServer, createWebSocketStream } from "ws";
import { Duplex } from "node:stream";
import { TransformStream } from "node:stream/web"
const wss = new WebSocketServer({ port: 4200 });
console.log("COJSON sync server listening on port " + wss.options.port)
const agentSecret = cojsonInternals.newRandomAgentSecret();
const agentID = cojsonInternals.getAgentID(agentSecret);
const localNode = new LocalNode(
new AnonymousControlledAccount(agentSecret),
cojsonInternals.newRandomSessionID(agentID)
);
wss.on("connection", function connection(ws, req) {
const duplexStream = createWebSocketStream(ws, {
decodeStrings: false,
readableObjectMode: true,
writableObjectMode: true,
encoding: "utf-8",
defaultEncoding: "utf-8",
});
const { readable: incomingStrings, writable: outgoingStrings } = Duplex.toWeb(duplexStream);
const toJSON = new TransformStream({
transform: (chunk, controller) => {
controller.enqueue(JSON.parse(chunk));
}
})
const fromJSON = new TransformStream({
transform: (chunk, controller) => {
controller.enqueue(JSON.stringify(chunk));
}
});
const clientAddress =
(req.headers["x-forwarded-for"] as string | undefined)
?.split(",")[0]
?.trim() || req.socket.remoteAddress;
const clientId = clientAddress + "@" + new Date().toISOString();
localNode.sync.addPeer({
id: clientId,
role: "client",
incoming: incomingStrings.pipeThrough(toJSON),
outgoing: fromJSON.writable,
});
void fromJSON.readable.pipeTo(outgoingStrings);
ws.on("error", (e) => console.error(`Error on connection ${clientId}:`, e));
});

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "preserve",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
},
"include": ["./src/**/*"],
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +1,3 @@
# CoJSON
CoJSON ("Collaborative JSON") will be a minimal protocol and implementation for collaborative values (CRDTs + public-key cryptography).
CoJSON is developed by [Garden Computing](https://gcmp.io) as the underpinnings of [Jazz](https://jazz.tools), a framework for building apps with telepathic data.
The protocol and implementation will cover:
- how to represent collaborative values internally
- the APIs collaborative values expose
- how to sync and query for collaborative values between peers
- how to enforce access rights within collaborative values locally and at sync boundaries
THIS IS WORK IN PROGRESS
## Core Value Types
### `Immutable` Values (JSON)
- null
- boolean
- number
- string
- stringly-encoded CoJSON identifiers & data (`CoID`, `AgentID`, `SessionID`, `SignerID`, `SignerSecret`, `Signature`, `SealerID`, `SealerSecret`, `Sealed`, `Hash`, `ShortHash`, `KeySecret`, `KeyID`, `Encrypted`, `Role`)
- array
- object
### `Collaborative` Values
- CoMap (`string``Immutable`, last-writer-wins per key)
- Team (`AgentID``Role`)
- CoList (`Immutable[]`, addressable positions, insertAfter semantics)
- Agent (`{signerID, sealerID}[]`)
- CoStream (independent per-session streams of `Immutable`s)
- Static (single addressable `Immutable`)
## Implementation Abstractions
- CoValue
- Session Logs
- Transactions
- Private (encrypted) transactions
- Trusting (unencrypted) transactions
- Rulesets
- CoValue Content Types
- LocalNode
- Peers
- AgentCredentials
- Peer
## Extensions & higher-level protocols
### More complex datastructures
- CoText: a clean way to collaboratively mark up rich text with CoJSON
- CoJSON Tree: a clean way to represent collaborative tree structures with CoJSON
[See the top-level README](../../README.md#cojson)

View File

@@ -2,10 +2,10 @@
"name": "cojson",
"module": "dist/index.js",
"main": "dist/index.js",
"types": "src/index.ts",
"types": "dist/index.d.ts",
"type": "module",
"license": "MIT",
"version": "0.0.16",
"version": "0.1.2",
"devDependencies": {
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.2.1",

View File

@@ -19,14 +19,14 @@ test("Can create a node while creating a new account with profile", async () =>
);
});
test("A node with an account can create teams and and objects within them", async () => {
test("A node with an account can create groups and and objects within them", async () => {
const { node, accountID } =
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
const team = await node.createTeam();
expect(team).not.toBeNull();
const group = await node.createGroup();
expect(group).not.toBeNull();
let map = team.createMap();
let map = group.createMap();
map = map.edit((edit) => {
edit.set("foo", "bar", "private");
expect(edit.get("foo")).toEqual("bar");
@@ -41,10 +41,10 @@ test("Can create account with one node, and then load it on another", async () =
const { node, accountID, accountSecret } =
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
const team = await node.createTeam();
expect(team).not.toBeNull();
const group = await node.createGroup();
expect(group).not.toBeNull();
let map = team.createMap();
let map = group.createMap();
map = map.edit((edit) => {
edit.set("foo", "bar", "private");
expect(edit.get("foo")).toEqual("bar");

View File

@@ -14,7 +14,7 @@ import {
} from "./crypto.js";
import { AgentID } from "./ids.js";
import { CoMap, LocalNode } from "./index.js";
import { Team, TeamContent } from "./permissions.js";
import { Group, GroupContent } from "./group.js";
export function accountHeaderForInitialAgentSecret(
agentSecret: AgentSecret
@@ -22,7 +22,7 @@ export function accountHeaderForInitialAgentSecret(
const agent = getAgentID(agentSecret);
return {
type: "comap",
ruleset: { type: "team", initialAdmin: agent },
ruleset: { type: "group", initialAdmin: agent },
meta: {
type: "account",
},
@@ -31,13 +31,13 @@ export function accountHeaderForInitialAgentSecret(
};
}
export class Account extends Team {
export class Account extends Group {
get id(): AccountID {
return this.teamMap.id as AccountID;
return this.groupMap.id as AccountID;
}
getCurrentAgentID(): AgentID {
const agents = this.teamMap
const agents = this.groupMap
.keys()
.filter((k): k is AgentID => k.startsWith("sealer_"));
@@ -70,10 +70,10 @@ export class ControlledAccount
constructor(
agentSecret: AgentSecret,
teamMap: CoMap<AccountContent, AccountMeta>,
groupMap: CoMap<AccountContent, AccountMeta>,
node: LocalNode
) {
super(teamMap, node);
super(groupMap, node);
this.agentSecret = agentSecret;
}
@@ -133,7 +133,7 @@ export class AnonymousControlledAccount
}
}
export type AccountContent = TeamContent & { profile: CoID<Profile> };
export type AccountContent = GroupContent & { profile: CoID<Profile> };
export type AccountMeta = { type: "account" };
export type AccountID = CoID<CoMap<AccountContent, AccountMeta>>;

View File

@@ -2,6 +2,9 @@ import { Transaction } from "./coValue.js";
import { LocalNode } from "./node.js";
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
import { AccountID } from "./index.js";
import { Role } from "./permissions.js";
test("Can create coValue with new agent credentials and add transaction to it", () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID();
@@ -121,3 +124,58 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
)
).toBe(false);
});
test("New transactions in a group correctly update owned values, including subscriptions", async () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(account, sessionID);
const group = node.createGroup();
const timeBeforeEdit = Date.now();
await new Promise((resolve) => setTimeout(resolve, 10));
let map = group.createMap();
let mapAfterEdit = map.edit((map) => {
map.set("hello", "world");
});
const listener = jest.fn().mockImplementation();
map.subscribe(listener);
expect(listener.mock.calls[0][0].get("hello")).toBe("world");
const resignationThatWeJustLearnedAbout = {
privacy: "trusting",
madeAt: timeBeforeEdit,
changes: [
{
op: "set",
key: account.id,
value: "revoked"
} satisfies MapOpPayload<typeof account.id, Role>
]
} satisfies Transaction;
const { expectedNewHash } = group.groupMap.coValue.expectedNewHashAfter(sessionID, [
resignationThatWeJustLearnedAbout,
]);
const signature = sign(
node.account.currentSignerSecret(),
expectedNewHash
);
expect(map.coValue.getValidSortedTransactions().length).toBe(1);
const manuallyAdddedTxSuccess = group.groupMap.coValue.tryAddTransactions(node.ownSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
expect(manuallyAdddedTxSuccess).toBe(true);
expect(listener.mock.calls.length).toBe(2);
expect(listener.mock.calls[1][0].get("hello")).toBe(undefined);
expect(map.coValue.getValidSortedTransactions().length).toBe(0);
});

View File

@@ -24,11 +24,10 @@ import { JsonObject, JsonValue } from "./jsonValue.js";
import { base58 } from "@scure/base";
import {
PermissionsDef as RulesetDef,
Team,
determineValidTransactions,
expectTeamContent,
isKeyForKeyField,
} from "./permissions.js";
import { Group, expectGroupContent } from "./group.js";
import { LocalNode } from "./node.js";
import { CoValueKnownState, NewContentMessage } from "./sync.js";
import { RawCoID, SessionID, TransactionID } from "./ids.js";
@@ -93,19 +92,41 @@ export type DecryptedTransaction = {
madeAt: number;
};
const readKeyCache = new WeakMap<CoValue, { [id: KeyID]: KeySecret }>();
export class CoValue {
id: RawCoID;
node: LocalNode;
header: CoValueHeader;
sessions: { [key: SessionID]: SessionLog };
content?: ContentType;
_sessions: { [key: SessionID]: SessionLog };
_cachedContent?: ContentType;
listeners: Set<(content?: ContentType) => void> = new Set();
constructor(header: CoValueHeader, node: LocalNode) {
constructor(
header: CoValueHeader,
node: LocalNode,
internalInitSessions: { [key: SessionID]: SessionLog } = {}
) {
this.id = idforHeader(header);
this.header = header;
this.sessions = {};
this._sessions = internalInitSessions;
this.node = node;
if (header.ruleset.type == "ownedByGroup") {
this.node
.expectCoValueLoaded(header.ruleset.group)
.subscribe((_groupUpdate) => {
this._cachedContent = undefined;
const newContent = this.getCurrentContent();
for (const listener of this.listeners) {
listener(newContent);
}
});
}
}
get sessions(): Readonly<{ [key: SessionID]: SessionLog }> {
return this._sessions;
}
testWithDifferentAccount(
@@ -172,7 +193,10 @@ export class CoValue {
);
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
console.warn("Invalid hash", { expectedNewHash, givenExpectedNewHash });
console.warn("Invalid hash", {
expectedNewHash,
givenExpectedNewHash,
});
return false;
}
@@ -190,14 +214,14 @@ export class CoValue {
transactions.push(...newTransactions);
this.sessions[sessionID] = {
this._sessions[sessionID] = {
transactions,
lastHash: expectedNewHash,
streamingHash: newStreamingHash,
lastSignature: newSignature,
};
this.content = undefined;
this._cachedContent = undefined;
const content = this.getCurrentContent();
@@ -296,23 +320,23 @@ export class CoValue {
}
getCurrentContent(): ContentType {
if (this.content) {
return this.content;
if (this._cachedContent) {
return this._cachedContent;
}
if (this.header.type === "comap") {
this.content = new CoMap(this);
this._cachedContent = new CoMap(this);
} else if (this.header.type === "colist") {
this.content = new CoList(this);
this._cachedContent = new CoList(this);
} else if (this.header.type === "costream") {
this.content = new CoStream(this);
this._cachedContent = new CoStream(this);
} else if (this.header.type === "static") {
this.content = new Static(this);
this._cachedContent = new Static(this);
} else {
throw new Error(`Unknown coValue type ${this.header.type}`);
}
return this.content;
return this._cachedContent;
}
getValidSortedTransactions(): DecryptedTransaction[] {
@@ -367,8 +391,8 @@ export class CoValue {
}
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
if (this.header.ruleset.type === "group") {
const content = expectGroupContent(this.getCurrentContent());
const currentKeyId = content.get("readKey");
@@ -382,24 +406,29 @@ export class CoValue {
secret: secret,
id: currentKeyId,
};
} else if (this.header.ruleset.type === "ownedByTeam") {
} else if (this.header.ruleset.type === "ownedByGroup") {
return this.node
.expectCoValueLoaded(this.header.ruleset.team)
.expectCoValueLoaded(this.header.ruleset.group)
.getCurrentReadKey();
} else {
throw new Error(
"Only teams or values owned by teams have read secrets"
"Only groups or values owned by groups have read secrets"
);
}
}
getReadKey(keyID: KeyID): KeySecret | undefined {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
if (readKeyCache.get(this)?.[keyID]) {
return readKeyCache.get(this)?.[keyID];
}
if (this.header.ruleset.type === "group") {
const content = expectGroupContent(this.getCurrentContent());
// Try to find key revelation for us
const readKeyEntry = content.getLastEntry(`${keyID}_for_${this.node.account.id}`);
const readKeyEntry = content.getLastEntry(
`${keyID}_for_${this.node.account.id}`
);
if (readKeyEntry) {
const revealer = accountOrAgentIDfromSessionID(
@@ -420,7 +449,16 @@ export class CoValue {
}
);
if (secret) return secret as KeySecret;
if (secret) {
let cache = readKeyCache.get(this);
if (!cache) {
cache = {};
readKeyCache.set(this, cache);
}
cache[keyID] = secret;
return secret as KeySecret;
}
}
// Try to find indirect revelation through previousKeys
@@ -428,7 +466,8 @@ export class CoValue {
for (const field of content.keys()) {
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
const encryptingKeyID = field.split("_for_")[1] as KeyID;
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
const encryptingKeySecret =
this.getReadKey(encryptingKeyID);
if (!encryptingKeySecret) {
continue;
@@ -446,37 +485,43 @@ export class CoValue {
);
if (secret) {
return secret;
let cache = readKeyCache.get(this);
if (!cache) {
cache = {};
readKeyCache.set(this, cache);
}
cache[keyID] = secret;
return secret as KeySecret;
} else {
console.error(
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`
);
}
}
}
return undefined;
} else if (this.header.ruleset.type === "ownedByTeam") {
} else if (this.header.ruleset.type === "ownedByGroup") {
return this.node
.expectCoValueLoaded(this.header.ruleset.team)
.expectCoValueLoaded(this.header.ruleset.group)
.getReadKey(keyID);
} else {
throw new Error(
"Only teams or values owned by teams have read secrets"
"Only groups or values owned by groups have read secrets"
);
}
}
getTeam(): Team {
if (this.header.ruleset.type !== "ownedByTeam") {
throw new Error("Only values owned by teams have teams");
getGroup(): Group {
if (this.header.ruleset.type !== "ownedByGroup") {
throw new Error("Only values owned by groups have groups");
}
return new Team(
expectTeamContent(
return new Group(
expectGroupContent(
this.node
.expectCoValueLoaded(this.header.ruleset.team)
.expectCoValueLoaded(this.header.ruleset.group)
.getCurrentContent()
),
this.node
@@ -525,10 +570,7 @@ export class CoValue {
),
};
if (
!newContent.header &&
Object.keys(newContent.new).length === 0
) {
if (!newContent.header && Object.keys(newContent.new).length === 0) {
return undefined;
}
@@ -536,12 +578,12 @@ export class CoValue {
}
getDependedOnCoValues(): RawCoID[] {
return this.header.ruleset.type === "team"
? expectTeamContent(this.getCurrentContent())
return this.header.ruleset.type === "group"
? expectGroupContent(this.getCurrentContent())
.keys()
.filter((k): k is AccountID => k.startsWith("co_"))
: this.header.ruleset.type === "ownedByTeam"
? [this.header.ruleset.team]
: this.header.ruleset.type === "ownedByGroup"
? [this.header.ruleset.group]
: [];
}
}
}

View File

@@ -12,12 +12,12 @@ type MapOp<K extends string, V extends JsonValue> = {
// TODO: add after TransactionID[] for conflicts/ordering
export type MapOpPayload<K extends string, V extends JsonValue> = {
op: "insert";
op: "set";
key: K;
value: V;
} |
{
op: "delete";
op: "del";
key: K;
};
@@ -81,7 +81,7 @@ export class CoMap<
const lastEntry = ops[ops.length - 1]!;
if (lastEntry.op === "delete") {
if (lastEntry.op === "del") {
return undefined;
} else {
return lastEntry.value;
@@ -100,7 +100,7 @@ export class CoMap<
return undefined;
}
if (lastOpBeforeOrAtTime.op === "delete") {
if (lastOpBeforeOrAtTime.op === "del") {
return undefined;
} else {
return lastOpBeforeOrAtTime.value;
@@ -139,7 +139,7 @@ export class CoMap<
const lastEntry = ops[ops.length - 1]!;
if (lastEntry.op === "delete") {
if (lastEntry.op === "del") {
return undefined;
} else {
return { at: lastEntry.madeAt, txID: lastEntry.txID, value: lastEntry.value };
@@ -155,7 +155,7 @@ export class CoMap<
const history: { at: number; txID: TransactionID; value: M[K] | undefined; }[] = [];
for (const op of ops) {
if (op.op === "delete") {
if (op.op === "del") {
history.push({ at: op.madeAt, txID: op.txID, value: undefined });
} else {
history.push({ at: op.madeAt, txID: op.txID, value: op.value });
@@ -199,7 +199,7 @@ export class WriteableCoMap<
set<K extends MapK<M>>(key: K, value: M[K], privacy: "private" | "trusting" = "private"): void {
this.coValue.makeTransaction([
{
op: "insert",
op: "set",
key,
value,
},
@@ -211,7 +211,7 @@ export class WriteableCoMap<
delete(key: MapK<M>, privacy: "private" | "trusting" = "private"): void {
this.coValue.makeTransaction([
{
op: "delete",
op: "del",
key,
},
], privacy);

View File

@@ -20,6 +20,7 @@ import { x25519 } from "@noble/curves/ed25519";
import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
import { blake3 } from "@noble/hashes/blake3";
import stableStringify from "fast-json-stable-stringify";
import { SessionID } from './ids.js';
test("Signatures round-trip and use stable stringify", () => {
const data = { b: "world", a: "hello" };
@@ -49,7 +50,7 @@ test("encrypting round-trips, but invalid receiver can't unseal", () => {
const nOnceMaterial = {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
} as const;
const sealed = seal(
@@ -101,22 +102,22 @@ test("Encryption for transactions round-trips", () => {
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
});
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
});
const decrypted1 = decryptForTransaction(encrypted1, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
});
const decrypted2 = decryptForTransaction(encrypted2, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
});
expect([decrypted1, decrypted2]).toEqual([{ a: "hello" }, { b: "world" }]);
@@ -128,22 +129,22 @@ test("Encryption for transactions doesn't decrypt with a wrong key", () => {
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
});
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
});
const decrypted1 = decryptForTransaction(encrypted1, secret2, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
});
const decrypted2 = decryptForTransaction(encrypted2, secret2, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
});
expect([decrypted1, decrypted2]).toEqual([undefined, undefined]);

View File

@@ -1,11 +1,11 @@
import { ed25519, x25519 } from "@noble/curves/ed25519";
import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa";
import { JsonValue } from './jsonValue.js';
import { JsonValue } from "./jsonValue.js";
import { base58, base64url } from "@scure/base";
import stableStringify from "fast-json-stable-stringify";
import { blake3 } from "@noble/hashes/blake3";
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
import { AgentID, RawCoID, TransactionID } from './ids.js';
import { AgentID, RawCoID, TransactionID } from "./ids.js";
export type SignerSecret = `signerSecret_z${string}`;
export type SignerID = `signer_z${string}`;
@@ -21,9 +21,7 @@ const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export function newRandomSigner(): SignerSecret {
return `signerSecret_z${base58.encode(
ed25519.utils.randomPrivateKey()
)}`;
return `signerSecret_z${base58.encode(ed25519.utils.randomPrivateKey())}`;
}
export function signerSecretToBytes(secret: SignerSecret): Uint8Array {
@@ -95,20 +93,16 @@ export function agentSecretToBytes(secret: AgentSecret): Uint8Array {
}
export function agentSecretFromBytes(bytes: Uint8Array): AgentSecret {
const sealerSecret = sealerSecretFromBytes(
bytes.slice(0, 32)
);
const signerSecret = signerSecretFromBytes(
bytes.slice(32)
);
const sealerSecret = sealerSecretFromBytes(bytes.slice(0, 32));
const signerSecret = signerSecretFromBytes(bytes.slice(32));
return `${sealerSecret}/${signerSecret}`;
}
export function getAgentID(secret: AgentSecret): AgentID {
const [sealerSecret, signerSecret] = secret.split("/");
return `${getSealerID(
sealerSecret as SealerSecret
)}/${getSignerID(signerSecret as SignerSecret)}`;
return `${getSealerID(sealerSecret as SealerSecret)}/${getSignerID(
signerSecret as SignerSecret
)}`;
}
export function getAgentSignerID(agentId: AgentID): SignerID {
@@ -139,24 +133,17 @@ export function seal<T extends JsonValue>(
const sealerPub = base58.decode(to.substring("sealer_z".length));
const senderPriv = base58.decode(
from.substring("sealerSecret_z".length)
);
const senderPriv = base58.decode(from.substring("sealerSecret_z".length));
const plaintext = textEncoder.encode(stableStringify(message));
const sharedSecret = x25519.getSharedSecret(
senderPriv,
sealerPub
);
const sharedSecret = x25519.getSharedSecret(senderPriv, sealerPub);
const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt(
plaintext
);
return `sealed_U${base64url.encode(
sealedBytes
)}` as Sealed<T>
return `sealed_U${base64url.encode(sealedBytes)}` as Sealed<T>;
}
export function unseal<T extends JsonValue>(
@@ -169,9 +156,7 @@ export function unseal<T extends JsonValue>(
textEncoder.encode(stableStringify(nOnceMaterial))
).slice(0, 24);
const sealerPriv = base58.decode(
sealer.substring("sealerSecret_z".length)
);
const sealerPriv = base58.decode(sealer.substring("sealerSecret_z".length));
const senderPub = base58.decode(from.substring("sealer_z".length));
@@ -221,10 +206,11 @@ export class StreamingHash {
}
export type ShortHash = `shortHash_z${string}`;
export const shortHashLength = 19;
export function shortHash(value: JsonValue): ShortHash {
return `shortHash_z${base58.encode(
blake3(textEncoder.encode(stableStringify(value))).slice(0, 19)
blake3(textEncoder.encode(stableStringify(value))).slice(0, shortHashLength)
)}`;
}
@@ -274,7 +260,10 @@ export function encryptKeySecret(keys: {
}): {
encryptedID: KeyID;
encryptingID: KeyID;
encrypted: Encrypted<KeySecret, { encryptedID: KeyID; encryptingID: KeyID }>;
encrypted: Encrypted<
KeySecret,
{ encryptedID: KeyID; encryptingID: KeyID }
>;
} {
const nOnceMaterial = {
encryptedID: keys.toEncrypt.id,
@@ -328,7 +317,10 @@ export function decryptKeySecret(
encryptedInfo: {
encryptedID: KeyID;
encryptingID: KeyID;
encrypted: Encrypted<KeySecret, { encryptedID: KeyID; encryptingID: KeyID }>;
encrypted: Encrypted<
KeySecret,
{ encryptedID: KeyID; encryptingID: KeyID }
>;
},
sealingSecret: KeySecret
): KeySecret | undefined {
@@ -344,10 +336,35 @@ export function uniquenessForHeader(): `z${string}` {
return `z${base58.encode(randomBytes(12))}`;
}
export function createdNowUnique(): {createdAt: `2${string}`, uniqueness: `z${string}`} {
const createdAt = (new Date()).toISOString() as `2${string}`;
export function createdNowUnique(): {
createdAt: `2${string}`;
uniqueness: `z${string}`;
} {
const createdAt = new Date().toISOString() as `2${string}`;
return {
createdAt,
uniqueness: uniquenessForHeader(),
};
}
export const secretSeedLength = 32;
export function newRandomSecretSeed(): Uint8Array {
return randomBytes(secretSeedLength);
}
export function agentSecretFromSecretSeed(secretSeed: Uint8Array): AgentSecret {
if (secretSeed.length !== secretSeedLength) {
throw new Error(`Secret seed needs to be ${secretSeedLength} bytes long`);
}
}
return `sealerSecret_z${base58.encode(
blake3(secretSeed, {
context: textEncoder.encode("seal"),
})
)}/signerSecret_z${base58.encode(
blake3(secretSeed, {
context: textEncoder.encode("sign"),
})
)}`;
}

View File

@@ -0,0 +1,233 @@
import { CoID, ContentType } from "./contentType.js";
import { CoMap } from "./contentTypes/coMap.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import {
Encrypted,
KeyID,
KeySecret,
createdNowUnique,
newRandomKeySecret,
seal,
encryptKeySecret,
getAgentSealerID,
Sealed,
newRandomSecretSeed,
agentSecretFromSecretSeed,
getAgentID,
} from "./crypto.js";
import { LocalNode } from "./node.js";
import { SessionID, isAgentID } from "./ids.js";
import {
AccountIDOrAgentID,
GeneralizedControlledAccount,
Profile,
} from "./account.js";
import { Role } from "./permissions.js";
import { base58 } from "@scure/base";
export type GroupContent = {
profile: CoID<Profile> | null;
[key: AccountIDOrAgentID]: Role;
readKey: KeyID;
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
KeySecret,
{ encryptedID: KeyID; encryptingID: KeyID }
>;
};
export function expectGroupContent(
content: ContentType
): CoMap<GroupContent, JsonObject | null> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<GroupContent, JsonObject | null>;
}
export class Group {
groupMap: CoMap<GroupContent, JsonObject | null>;
node: LocalNode;
constructor(
groupMap: CoMap<GroupContent, JsonObject | null>,
node: LocalNode
) {
this.groupMap = groupMap;
this.node = node;
}
get id(): CoID<CoMap<GroupContent, JsonObject | null>> {
return this.groupMap.id;
}
roleOf(accountID: AccountIDOrAgentID): Role | undefined {
return this.groupMap.get(accountID);
}
myRole(): Role | undefined {
return this.roleOf(this.node.account.id);
}
addMember(accountID: AccountIDOrAgentID, role: Role) {
this.groupMap = this.groupMap.edit((map) => {
const currentReadKey = this.groupMap.coValue.getCurrentReadKey();
if (!currentReadKey.secret) {
throw new Error("Can't add member without read key secret");
}
const agent = this.node.resolveAccountAgent(
accountID,
"Expected to know agent to add them to group"
);
map.set(accountID, role, "trusting");
if (map.get(accountID) !== role) {
throw new Error("Failed to set role");
}
map.set(
`${currentReadKey.id}_for_${accountID}`,
seal(
currentReadKey.secret,
this.groupMap.coValue.node.account.currentSealerSecret(),
getAgentSealerID(agent),
{
in: this.groupMap.coValue.id,
tx: this.groupMap.coValue.nextTransactionID(),
}
),
"trusting"
);
});
}
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
const secretSeed = newRandomSecretSeed();
const inviteSecret = agentSecretFromSecretSeed(secretSeed);
const inviteID = getAgentID(inviteSecret);
this.addMember(inviteID, `${role}Invite` as Role);
return inviteSecretFromSecretSeed(secretSeed);
}
rotateReadKey() {
const currentlyPermittedReaders = this.groupMap.keys().filter((key) => {
if (key.startsWith("co_") || isAgentID(key)) {
const role = this.groupMap.get(key);
return (
role === "admin" || role === "writer" || role === "reader"
);
} else {
return false;
}
}) as AccountIDOrAgentID[];
const maybeCurrentReadKey = this.groupMap.coValue.getCurrentReadKey();
if (!maybeCurrentReadKey.secret) {
throw new Error(
"Can't rotate read key secret we don't have access to"
);
}
const currentReadKey = {
id: maybeCurrentReadKey.id,
secret: maybeCurrentReadKey.secret,
};
const newReadKey = newRandomKeySecret();
this.groupMap = this.groupMap.edit((map) => {
for (const readerID of currentlyPermittedReaders) {
const reader = this.node.resolveAccountAgent(
readerID,
"Expected to know currently permitted reader"
);
map.set(
`${newReadKey.id}_for_${readerID}`,
seal(
newReadKey.secret,
this.groupMap.coValue.node.account.currentSealerSecret(),
getAgentSealerID(reader),
{
in: this.groupMap.coValue.id,
tx: this.groupMap.coValue.nextTransactionID(),
}
),
"trusting"
);
}
map.set(
`${currentReadKey.id}_for_${newReadKey.id}`,
encryptKeySecret({
encrypting: newReadKey,
toEncrypt: currentReadKey,
}).encrypted,
"trusting"
);
map.set("readKey", newReadKey.id, "trusting");
});
}
removeMember(accountID: AccountIDOrAgentID) {
this.groupMap = this.groupMap.edit((map) => {
map.set(accountID, "revoked", "trusting");
});
this.rotateReadKey();
}
createMap<
M extends { [key: string]: JsonValue },
Meta extends JsonObject | null = null
>(meta?: Meta): CoMap<M, Meta> {
return this.node
.createCoValue({
type: "comap",
ruleset: {
type: "ownedByGroup",
group: this.groupMap.id,
},
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as CoMap<M, Meta>;
}
testWithDifferentAccount(
account: GeneralizedControlledAccount,
sessionId: SessionID
): Group {
return new Group(
expectGroupContent(
this.groupMap.coValue
.testWithDifferentAccount(account, sessionId)
.getCurrentContent()
),
this.node
);
}
}
export type InviteSecret = `inviteSecret_z${string}`;
function inviteSecretFromSecretSeed(secretSeed: Uint8Array): InviteSecret {
return `inviteSecret_z${base58.encode(secretSeed)}`;
}
export function secretSeedFromInviteSecret(inviteSecret: InviteSecret) {
if (!inviteSecret.startsWith("inviteSecret_z")) {
throw new Error("Invalid invite secret");
}
return base58.decode(inviteSecret.slice("inviteSecret_z".length));
}

View File

@@ -1,7 +1,20 @@
import { AccountIDOrAgentID } from './account.js';
import { base58 } from "@scure/base";
import { shortHashLength } from './crypto.js';
export type RawCoID = `co_z${string}`;
export function rawCoIDtoBytes(id: RawCoID): Uint8Array {
return base58.decode(
id.substring("co_z".length)
)
}
export function rawCoIDfromBytes(bytes: Uint8Array): RawCoID {
return `co_z${base58.encode(bytes.slice(0, shortHashLength))}` as RawCoID;
}
export type TransactionID = { sessionID: SessionID; txIndex: number };
export type AgentID = `sealer_z${string}/signer_z${string}`;

View File

@@ -1,25 +1,60 @@
import { CoValue, newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { CoMap } from "./contentTypes/coMap.js";
import { agentSecretFromBytes, agentSecretToBytes } from "./crypto.js";
import {
agentSecretFromBytes,
agentSecretToBytes,
getAgentID,
newRandomAgentSecret,
newRandomSecretSeed,
agentSecretFromSecretSeed,
secretSeedLength,
shortHashLength,
} from "./crypto.js";
import { connectedPeers } from "./streamUtils.js";
import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
import { Group, expectGroupContent } from "./group.js"
import type { SessionID } from "./ids.js";
import type { SessionID, AgentID } from "./ids.js";
import type { CoID, ContentType } from "./contentType.js";
import type { JsonValue } from "./jsonValue.js";
import type { SyncMessage } from "./sync.js";
import type { SyncMessage, Peer } from "./sync.js";
import type { AgentSecret } from "./crypto.js";
import type {
AccountID,
AccountContent,
ProfileContent,
Profile,
} from "./account.js";
import type { InviteSecret } from "./group.js";
type Value = JsonValue | ContentType;
const internals = {
export const cojsonInternals = {
agentSecretFromBytes,
agentSecretToBytes,
newRandomSessionID,
connectedPeers
newRandomAgentSecret,
connectedPeers,
getAgentID,
rawCoIDtoBytes,
rawCoIDfromBytes,
newRandomSecretSeed,
agentSecretFromSecretSeed,
secretSeedLength,
shortHashLength,
expectGroupContent
};
export { LocalNode, CoValue, CoMap, internals };
export {
LocalNode,
CoValue,
CoMap,
AnonymousControlledAccount,
ControlledAccount,
Group
};
export type {
Value,
@@ -29,4 +64,25 @@ export type {
AgentSecret,
SessionID,
SyncMessage,
AgentID,
AccountID,
Peer,
AccountContent,
Profile,
ProfileContent,
InviteSecret
};
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace CojsonInternalTypes {
export type CoValueKnownState = import("./sync.js").CoValueKnownState;
export type DoneMessage = import("./sync.js").DoneMessage;
export type KnownStateMessage = import("./sync.js").KnownStateMessage;
export type LoadMessage = import("./sync.js").LoadMessage;
export type NewContentMessage = import("./sync.js").NewContentMessage;
export type CoValueHeader = import("./coValue.js").CoValueHeader;
export type Transaction = import("./coValue.js").Transaction;
export type Signature = import("./crypto.js").Signature;
export type RawCoID = import("./ids.js").RawCoID;
export type AccountIDOrAgentID = import("./account.js").AccountIDOrAgentID;
}

View File

@@ -1,6 +1,6 @@
import { CoID, ContentType } from './contentType.js';
import { RawCoID } from './ids.js';
export type JsonAtom = string | number | boolean | null;
export type JsonValue = JsonAtom | JsonArray | JsonObject | CoID<ContentType>;
export type JsonValue = JsonAtom | JsonArray | JsonObject | RawCoID;
export type JsonArray = JsonValue[];
export type JsonObject = { [key: string]: JsonValue; };

View File

@@ -1,5 +1,6 @@
import {
AgentSecret,
agentSecretFromSecretSeed,
createdNowUnique,
getAgentID,
getAgentSealerID,
@@ -9,7 +10,13 @@ import {
seal,
} from "./crypto.js";
import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js";
import { Team, TeamContent, expectTeamContent } from "./permissions.js";
import {
InviteSecret,
Group,
GroupContent,
expectGroupContent,
secretSeedFromInviteSecret,
} from "./group.js";
import { Peer, SyncManager } from "./sync.js";
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
import { CoID, ContentType } from "./contentType.js";
@@ -43,7 +50,10 @@ export class LocalNode {
this.ownSessionID = ownSessionID;
}
static withNewlyCreatedAccount(name: string): {
static withNewlyCreatedAccount(
name: string,
initialAgentSecret = newRandomAgentSecret()
): {
node: LocalNode;
accountID: AccountID;
accountSecret: AgentSecret;
@@ -55,7 +65,7 @@ export class LocalNode {
newRandomSessionID(getAgentID(throwawayAgent))
);
const account = setupNode.createAccount(name);
const account = setupNode.createAccount(name, initialAgentSecret);
const nodeWithAccount = account.node.testWithDifferentAccount(
account,
@@ -70,8 +80,16 @@ export class LocalNode {
};
}
static async withLoadedAccount(accountID: AccountID, accountSecret: AgentSecret, sessionID: SessionID, peersToLoadFrom: Peer[]): Promise<LocalNode> {
const loadingNode = new LocalNode(new AnonymousControlledAccount(accountSecret), newRandomSessionID(accountID));
static async withLoadedAccount(
accountID: AccountID,
accountSecret: AgentSecret,
sessionID: SessionID,
peersToLoadFrom: Peer[]
): Promise<LocalNode> {
const loadingNode = new LocalNode(
new AnonymousControlledAccount(accountSecret),
newRandomSessionID(accountID)
);
const accountPromise = loadingNode.load(accountID);
@@ -82,7 +100,10 @@ export class LocalNode {
const account = await accountPromise;
// since this is all synchronous, we can just swap out nodes for the SyncManager
const node = loadingNode.testWithDifferentAccount(new ControlledAccount(accountSecret, account, loadingNode), sessionID);
const node = loadingNode.testWithDifferentAccount(
new ControlledAccount(accountSecret, account, loadingNode),
sessionID
);
node.sync = loadingNode.sync;
node.sync.local = node;
@@ -124,7 +145,87 @@ export class LocalNode {
if (!profileID) {
throw new Error(`Account ${id} has no profile`);
}
return (await this.loadCoValue(profileID)).getCurrentContent() as Profile;
return (
await this.loadCoValue(profileID)
).getCurrentContent() as Profile;
}
async acceptInvite<T extends ContentType>(
groupOrOwnedValueID: CoID<T>,
inviteSecret: InviteSecret
): Promise<void> {
const groupOrOwnedValue = await this.load(groupOrOwnedValueID);
if (groupOrOwnedValue.coValue.header.ruleset.type === "ownedByGroup") {
return this.acceptInvite(
groupOrOwnedValue.coValue.header.ruleset.group as CoID<
CoMap<GroupContent>
>,
inviteSecret
);
} else if (groupOrOwnedValue.coValue.header.ruleset.type !== "group") {
throw new Error("Can only accept invites to groups");
}
const group = new Group(expectGroupContent(groupOrOwnedValue), this);
const inviteAgentSecret = agentSecretFromSecretSeed(
secretSeedFromInviteSecret(inviteSecret)
);
const inviteAgentID = getAgentID(inviteAgentSecret);
const inviteRole = await new Promise((resolve, reject) => {
group.groupMap.subscribe((groupMap) => {
const role = groupMap.get(inviteAgentID);
if (role) {
resolve(role);
}
});
setTimeout(
() =>
reject(
new Error("Couldn't find invite before timeout")
),
1000
);
});
if (!inviteRole) {
throw new Error("No invite found");
}
const existingRole = group.groupMap.get(this.account.id);
if (
existingRole === "admin" ||
(existingRole === "writer" && inviteRole === "writerInvite") ||
(existingRole === "writer" && inviteRole === "reader") ||
(existingRole === "reader" && inviteRole === "readerInvite")
) {
console.debug("Not accepting invite that would replace or downgrade role");
return;
}
const groupAsInvite = group.testWithDifferentAccount(
new AnonymousControlledAccount(inviteAgentSecret),
newRandomSessionID(inviteAgentID)
);
groupAsInvite.addMember(
this.account.id,
inviteRole === "adminInvite"
? "admin"
: inviteRole === "writerInvite"
? "writer"
: "reader"
);
group.groupMap.coValue._sessions = groupAsInvite.groupMap.coValue.sessions;
group.groupMap.coValue._cachedContent = undefined;
for (const groupListener of group.groupMap.coValue.listeners) {
groupListener(group.groupMap.coValue.getCurrentContent());
}
}
expectCoValueLoaded(id: RawCoID, expectation?: string): CoValue {
@@ -146,7 +247,9 @@ export class LocalNode {
expectProfileLoaded(id: AccountID, expectation?: string): Profile {
const account = this.expectCoValueLoaded(id, expectation);
const profileID = expectTeamContent(account.getCurrentContent()).get("profile");
const profileID = expectGroupContent(account.getCurrentContent()).get(
"profile"
);
if (!profileID) {
throw new Error(
`${
@@ -154,12 +257,16 @@ export class LocalNode {
}Account ${id} has no profile`
);
}
return this.expectCoValueLoaded(profileID, expectation).getCurrentContent() as Profile;
return this.expectCoValueLoaded(
profileID,
expectation
).getCurrentContent() as Profile;
}
createAccount(name: string): ControlledAccount {
const agentSecret = newRandomAgentSecret();
createAccount(
name: string,
agentSecret = newRandomAgentSecret()
): ControlledAccount {
const account = this.createCoValue(
accountHeaderForInitialAgentSecret(agentSecret)
).testWithDifferentAccount(
@@ -167,9 +274,12 @@ export class LocalNode {
newRandomSessionID(getAgentID(agentSecret))
);
const accountAsTeam = new Team(expectTeamContent(account.getCurrentContent()), account.node);
const accountAsGroup = new Group(
expectGroupContent(account.getCurrentContent()),
account.node
);
accountAsTeam.teamMap.edit((editable) => {
accountAsGroup.groupMap.edit((editable) => {
editable.set(getAgentID(agentSecret), "admin", "trusting");
const readKey = newRandomKeySecret();
@@ -197,16 +307,23 @@ export class LocalNode {
account.node
);
const profile = accountAsTeam.createMap<ProfileContent, ProfileMeta>({ type: "profile" });
const profile = accountAsGroup.createMap<ProfileContent, ProfileMeta>({
type: "profile",
});
profile.edit((editable) => {
editable.set("name", name, "trusting");
});
accountAsTeam.teamMap.edit((editable) => {
accountAsGroup.groupMap.edit((editable) => {
editable.set("profile", profile.id, "trusting");
});
const accountOnThisNode = this.expectCoValueLoaded(account.id);
accountOnThisNode._sessions = {...accountAsGroup.groupMap.coValue.sessions};
accountOnThisNode._cachedContent = undefined;
return controlledAccount;
}
@@ -219,7 +336,7 @@ export class LocalNode {
if (
coValue.header.type !== "comap" ||
coValue.header.ruleset.type !== "team" ||
coValue.header.ruleset.type !== "group" ||
!coValue.header.meta ||
!("type" in coValue.header.meta) ||
coValue.header.meta.type !== "account"
@@ -232,22 +349,22 @@ export class LocalNode {
}
return new Account(
coValue.getCurrentContent() as CoMap<TeamContent, AccountMeta>,
coValue.getCurrentContent() as CoMap<GroupContent, AccountMeta>,
this
).getCurrentAgentID();
}
createTeam(): Team {
const teamCoValue = this.createCoValue({
createGroup(): Group {
const groupCoValue = this.createCoValue({
type: "comap",
ruleset: { type: "team", initialAdmin: this.account.id },
ruleset: { type: "group", initialAdmin: this.account.id },
meta: null,
...createdNowUnique(),
});
let teamContent = expectTeamContent(teamCoValue.getCurrentContent());
let groupContent = expectGroupContent(groupCoValue.getCurrentContent());
teamContent = teamContent.edit((editable) => {
groupContent = groupContent.edit((editable) => {
editable.set(this.account.id, "admin", "trusting");
const readKey = newRandomKeySecret();
@@ -259,8 +376,8 @@ export class LocalNode {
this.account.currentSealerSecret(),
this.account.currentSealerID(),
{
in: teamCoValue.id,
tx: teamCoValue.nextTransactionID(),
in: groupCoValue.id,
tx: groupCoValue.nextTransactionID(),
}
),
"trusting"
@@ -269,7 +386,7 @@ export class LocalNode {
editable.set("readKey", readKey.id, "trusting");
});
return new Team(teamContent, this);
return new Group(groupContent, this);
}
testWithDifferentAccount(
@@ -278,24 +395,36 @@ export class LocalNode {
): LocalNode {
const newNode = new LocalNode(account, ownSessionID);
newNode.coValues = Object.fromEntries(
Object.entries(this.coValues)
.map(([id, entry]) => {
if (entry.state === "loading") {
return undefined;
}
const coValuesToCopy = Object.entries(this.coValues);
const newCoValue = new CoValue(
entry.coValue.header,
newNode
);
while (coValuesToCopy.length > 0) {
const [coValueID, entry] =
coValuesToCopy[coValuesToCopy.length - 1]!;
newCoValue.sessions = entry.coValue.sessions;
if (entry.state === "loading") {
coValuesToCopy.pop();
continue;
} else {
const allDepsCopied = entry.coValue
.getDependedOnCoValues()
.every((dep) => newNode.coValues[dep]?.state === "loaded");
return [id, { state: "loaded", coValue: newCoValue }];
})
.filter((x): x is Exclude<typeof x, undefined> => !!x)
);
if (!allDepsCopied) {
// move to end of queue
coValuesToCopy.unshift(coValuesToCopy.pop()!);
continue;
}
const newCoValue = new CoValue(entry.coValue.header, newNode, {...entry.coValue.sessions});
newNode.coValues[coValueID as RawCoID] = {
state: "loaded",
coValue: newCoValue,
};
coValuesToCopy.pop();
}
}
return newNode;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,8 @@
import { CoID, ContentType } from "./contentType.js";
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import { CoID } from "./contentType.js";
import { MapOpPayload } from "./contentTypes/coMap.js";
import { JsonValue } from "./jsonValue.js";
import {
Encrypted,
KeyID,
KeySecret,
createdNowUnique,
newRandomKeySecret,
seal,
encryptKeySecret,
getAgentSealerID,
Sealed,
} from "./crypto.js";
import {
CoValue,
@@ -18,21 +10,30 @@ import {
TrustingTransaction,
accountOrAgentIDfromSessionID,
} from "./coValue.js";
import { LocalNode } from "./node.js";
import { RawCoID, SessionID, TransactionID, isAgentID } from "./ids.js";
import { AccountIDOrAgentID, GeneralizedControlledAccount, Profile } from "./account.js";
import { RawCoID, SessionID, TransactionID } from "./ids.js";
import {
AccountIDOrAgentID,
Profile,
} from "./account.js";
export type PermissionsDef =
| { type: "team"; initialAdmin: AccountIDOrAgentID }
| { type: "ownedByTeam"; team: RawCoID }
| { type: "group"; initialAdmin: AccountIDOrAgentID }
| { type: "ownedByGroup"; group: RawCoID }
| { type: "unsafeAllowAll" };
export type Role = "reader" | "writer" | "admin" | "revoked";
export type Role =
| "reader"
| "writer"
| "admin"
| "revoked"
| "adminInvite"
| "writerInvite"
| "readerInvite";
export function determineValidTransactions(
coValue: CoValue
): { txID: TransactionID; tx: Transaction }[] {
if (coValue.header.ruleset.type === "team") {
if (coValue.header.ruleset.type === "group") {
const allTrustingTransactionsSorted = Object.entries(
coValue.sessions
).flatMap(([sessionID, sessionLog]) => {
@@ -42,7 +43,7 @@ export function determineValidTransactions(
if (tx.privacy === "trusting") {
return true;
} else {
console.warn("Unexpected private transaction in Team");
console.warn("Unexpected private transaction in Group");
return false;
}
}) as {
@@ -59,7 +60,7 @@ export function determineValidTransactions(
const initialAdmin = coValue.header.ruleset.initialAdmin;
if (!initialAdmin) {
throw new Error("Team must have initialAdmin");
throw new Error("Group must have initialAdmin");
}
const memberState: { [agent: AccountIDOrAgentID]: Role } = {};
@@ -80,12 +81,12 @@ export function determineValidTransactions(
| MapOpPayload<"readKey", JsonValue>
| MapOpPayload<"profile", CoID<Profile>>;
if (tx.changes.length !== 1) {
console.warn("Team transaction must have exactly one change");
console.warn("Group transaction must have exactly one change");
continue;
}
if (change.op !== "insert") {
console.warn("Team transaction must set a role or readKey");
if (change.op !== "set") {
console.warn("Group transaction must set a role or readKey");
continue;
}
@@ -97,7 +98,7 @@ export function determineValidTransactions(
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (change.key === 'profile') {
} else if (change.key === "profile") {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set profile");
continue;
@@ -105,8 +106,16 @@ export function determineValidTransactions(
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (isKeyForKeyField(change.key) || isKeyForAccountField(change.key)) {
if (memberState[transactor] !== "admin") {
} else if (
isKeyForKeyField(change.key) ||
isKeyForAccountField(change.key)
) {
if (
memberState[transactor] !== "admin" &&
memberState[transactor] !== "adminInvite" &&
memberState[transactor] !== "writerInvite" &&
memberState[transactor] !== "readerInvite"
) {
console.warn("Only admins can reveal keys");
continue;
}
@@ -124,35 +133,53 @@ export function determineValidTransactions(
change.value !== "admin" &&
change.value !== "writer" &&
change.value !== "reader" &&
change.value !== "revoked"
change.value !== "revoked" &&
change.value !== "adminInvite" &&
change.value !== "writerInvite" &&
change.value !== "readerInvite"
) {
console.warn("Team transaction must set a valid role");
console.warn("Group transaction must set a valid role");
continue;
}
const isFirstSelfAppointment =
!memberState[transactor] &&
transactor === initialAdmin &&
change.op === "insert" &&
change.op === "set" &&
change.key === transactor &&
change.value === "admin";
if (!isFirstSelfAppointment) {
if (memberState[transactor] !== "admin") {
if (memberState[transactor] === "admin") {
if (
memberState[affectedMember] === "admin" &&
affectedMember !== transactor &&
assignedRole !== "admin"
) {
console.warn("Admins can only demote themselves.");
continue;
}
} else if (memberState[transactor] === "adminInvite") {
if (change.value !== "admin") {
console.warn("AdminInvites can only create admins.");
continue;
}
} else if (memberState[transactor] === "writerInvite") {
if (change.value !== "writer") {
console.warn("WriterInvites can only create writers.");
continue;
}
} else if (memberState[transactor] === "readerInvite") {
if (change.value !== "reader") {
console.warn("ReaderInvites can only create reader.");
continue;
}
} else {
console.warn(
"Team transaction must be made by current admin"
"Group transaction must be made by current admin or invite"
);
continue;
}
if (
memberState[affectedMember] === "admin" &&
affectedMember !== transactor &&
assignedRole !== "admin"
) {
console.warn("Admins can only demote themselves.");
continue;
}
}
memberState[affectedMember] = change.value;
@@ -162,16 +189,16 @@ export function determineValidTransactions(
}
return validTransactions;
} else if (coValue.header.ruleset.type === "ownedByTeam") {
const teamContent = coValue.node
} else if (coValue.header.ruleset.type === "ownedByGroup") {
const groupContent = coValue.node
.expectCoValueLoaded(
coValue.header.ruleset.team,
"Determining valid transaction in owned object but its team wasn't loaded"
coValue.header.ruleset.group,
"Determining valid transaction in owned object but its group wasn't loaded"
)
.getCurrentContent();
if (teamContent.type !== "comap") {
throw new Error("Team must be a map");
if (groupContent.type !== "comap") {
throw new Error("Group must be a map");
}
return Object.entries(coValue.sessions).flatMap(
@@ -181,7 +208,7 @@ export function determineValidTransactions(
);
return sessionLog.transactions
.filter((tx) => {
const transactorRoleAtTxTime = teamContent.getAtTime(
const transactorRoleAtTxTime = groupContent.getAtTime(
transactor,
tx.madeAt
);
@@ -213,180 +240,17 @@ export function determineValidTransactions(
}
}
export type TeamContent = {
profile: CoID<Profile> | null;
[key: AccountIDOrAgentID]: Role;
readKey: KeyID;
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
KeySecret,
{ encryptedID: KeyID; encryptingID: KeyID }
>;
};
export function expectTeamContent(
content: ContentType
): CoMap<TeamContent, JsonObject | null> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<TeamContent, JsonObject | null>;
}
export class Team {
teamMap: CoMap<TeamContent, JsonObject | null>;
node: LocalNode;
constructor(teamMap: CoMap<TeamContent, JsonObject | null>, node: LocalNode) {
this.teamMap = teamMap;
this.node = node;
}
get id(): CoID<CoMap<TeamContent, JsonObject | null>> {
return this.teamMap.id;
}
addMember(accountID: AccountIDOrAgentID, role: Role) {
this.teamMap = this.teamMap.edit((map) => {
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
if (!currentReadKey.secret) {
throw new Error("Can't add member without read key secret");
}
const agent = this.node.resolveAccountAgent(
accountID,
"Expected to know agent to add them to team"
);
map.set(accountID, role, "trusting");
if (map.get(accountID) !== role) {
throw new Error("Failed to set role");
}
map.set(
`${currentReadKey.id}_for_${accountID}`,
seal(
currentReadKey.secret,
this.teamMap.coValue.node.account.currentSealerSecret(),
getAgentSealerID(agent),
{
in: this.teamMap.coValue.id,
tx: this.teamMap.coValue.nextTransactionID(),
}
),
"trusting"
);
});
}
rotateReadKey() {
const currentlyPermittedReaders = this.teamMap.keys().filter((key) => {
if (key.startsWith("co_") || isAgentID(key)) {
const role = this.teamMap.get(key);
return (
role === "admin" || role === "writer" || role === "reader"
);
} else {
return false;
}
}) as AccountIDOrAgentID[];
const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey();
if (!maybeCurrentReadKey.secret) {
throw new Error(
"Can't rotate read key secret we don't have access to"
);
}
const currentReadKey = {
id: maybeCurrentReadKey.id,
secret: maybeCurrentReadKey.secret,
};
const newReadKey = newRandomKeySecret();
this.teamMap = this.teamMap.edit((map) => {
for (const readerID of currentlyPermittedReaders) {
const reader = this.node.resolveAccountAgent(
readerID,
"Expected to know currently permitted reader"
);
map.set(
`${newReadKey.id}_for_${readerID}`,
seal(
newReadKey.secret,
this.teamMap.coValue.node.account.currentSealerSecret(),
getAgentSealerID(reader),
{
in: this.teamMap.coValue.id,
tx: this.teamMap.coValue.nextTransactionID(),
}
),
"trusting"
);
}
map.set(
`${currentReadKey.id}_for_${newReadKey.id}`,
encryptKeySecret({
encrypting: newReadKey,
toEncrypt: currentReadKey,
}).encrypted,
"trusting"
);
map.set("readKey", newReadKey.id, "trusting");
});
}
removeMember(accountID: AccountIDOrAgentID) {
this.teamMap = this.teamMap.edit((map) => {
map.set(accountID, "revoked", "trusting");
});
this.rotateReadKey();
}
createMap<M extends { [key: string]: JsonValue }, Meta extends JsonObject | null>(
meta?: Meta
): CoMap<M, Meta> {
return this.node
.createCoValue({
type: "comap",
ruleset: {
type: "ownedByTeam",
team: this.teamMap.id,
},
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as CoMap<M, Meta>;
}
testWithDifferentAccount(
account: GeneralizedControlledAccount,
sessionId: SessionID
): Team {
return new Team(
expectTeamContent(
this.teamMap.coValue
.testWithDifferentAccount(account, sessionId)
.getCurrentContent()
),
this.node
);
}
}
export function isKeyForKeyField(field: string): field is `${KeyID}_for_${KeyID}` {
export function isKeyForKeyField(
field: string
): field is `${KeyID}_for_${KeyID}` {
return field.startsWith("key_") && field.includes("_for_key");
}
export function isKeyForAccountField(field: string): field is `${KeyID}_for_${AccountIDOrAgentID}` {
return field.startsWith("key_") && (field.includes("_for_sealer") || field.includes("_for_co"));
}
export function isKeyForAccountField(
field: string
): field is `${KeyID}_for_${AccountIDOrAgentID}` {
return (
field.startsWith("key_") &&
(field.includes("_for_sealer") || field.includes("_for_co"))
);
}

View File

@@ -26,7 +26,7 @@ export function connectedPeers(
chunk: SyncMessage,
controller: { enqueue: (msg: SyncMessage) => void; }
) {
trace && console.log(`${peer2id} -> ${peer1id}`, JSON.stringify(chunk, null, 2));
trace && console.debug(`${peer2id} -> ${peer1id}`, JSON.stringify(chunk, null, 2));
controller.enqueue(chunk);
},
})
@@ -40,7 +40,7 @@ export function connectedPeers(
chunk: SyncMessage,
controller: { enqueue: (msg: SyncMessage) => void; }
) {
trace && console.log(`${peer1id} -> ${peer2id}`, JSON.stringify(chunk, null, 2));
trace && console.debug(`${peer1id} -> ${peer2id}`, JSON.stringify(chunk, null, 2));
controller.enqueue(chunk);
},
})
@@ -100,14 +100,14 @@ export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
);
},
cancel(reason) {
cancel(_reason) {
console.log("Manually closing reader");
readerClosed = true;
},
});
const writable = new WritableStream<T>({
write(chunk, controller) {
write(chunk) {
if (readerClosed) {
console.log("Reader closed, not writing chunk", chunk);
throw new Error("Reader closed, not writing chunk");
@@ -118,7 +118,7 @@ export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
setTimeout(() => resolveNextItemReady());
}
},
abort(reason) {
abort(_reason) {
console.log("Manually closing writer");
writerClosed = true;
resolveNextItemReady();

View File

@@ -3,7 +3,7 @@ import { LocalNode } from "./node.js";
import { Peer, PeerID, SyncMessage } from "./sync.js";
import { expectMap } from "./contentType.js";
import { MapOpPayload } from "./contentTypes/coMap.js";
import { Team } from "./permissions.js";
import { Group } from "./group.js";
import {
ReadableStream,
WritableStream,
@@ -23,9 +23,9 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const map = team.createMap();
const map = group.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
@@ -53,7 +53,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
@@ -62,7 +62,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
const newContentMsg = await reader.read();
@@ -71,7 +71,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
id: map.coValue.id,
header: {
type: "comap",
ruleset: { type: "ownedByTeam", team: team.id },
ruleset: { type: "ownedByGroup", group: group.id },
meta: null,
createdAt: map.coValue.header.createdAt,
uniqueness: map.coValue.header.uniqueness,
@@ -86,7 +86,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
.transactions[0]!.madeAt,
changes: [
{
op: "insert",
op: "set",
key: "hello",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -104,9 +104,9 @@ test("Node replies with only new tx to subscribe with some known state", async (
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const map = team.createMap();
const map = group.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
@@ -137,7 +137,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
@@ -146,7 +146,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
const mapNewContentMsg = await reader.read();
@@ -164,7 +164,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
.transactions[1]!.madeAt,
changes: [
{
op: "insert",
op: "set",
key: "goodbye",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -186,9 +186,9 @@ test("After subscribing, node sends own known state and new txs to peer", async
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const map = team.createMap();
const map = group.createMap();
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -214,7 +214,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
@@ -223,7 +223,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
const mapNewContentHeaderOnlyMsg = await reader.read();
@@ -253,7 +253,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
.transactions[0]!.madeAt,
changes: [
{
op: "insert",
op: "set",
key: "hello",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -285,7 +285,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
.transactions[1]!.madeAt,
changes: [
{
op: "insert",
op: "set",
key: "goodbye",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -303,9 +303,9 @@ test("Client replies with known new content to tellKnownState from server", asyn
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const map = team.createMap();
const map = group.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
@@ -323,7 +323,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(teamStateEx(team));
// expect((await reader.read()).value).toMatchObject(groupStateEx(group));
const writer = inTx.getWriter();
@@ -337,7 +337,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
});
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
@@ -346,7 +346,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
const mapNewContentMsg = await reader.read();
@@ -364,7 +364,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
.transactions[0]!.madeAt,
changes: [
{
op: "insert",
op: "set",
key: "hello",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -382,9 +382,9 @@ test("No matter the optimistic known state, node respects invalid known state me
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const map = team.createMap();
const map = group.createMap();
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -410,7 +410,7 @@ test("No matter the optimistic known state, node respects invalid known state me
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
@@ -419,7 +419,7 @@ test("No matter the optimistic known state, node respects invalid known state me
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
const mapNewContentHeaderOnlyMsg = await reader.read();
@@ -467,7 +467,7 @@ test("No matter the optimistic known state, node respects invalid known state me
.transactions[1]!.madeAt,
changes: [
{
op: "insert",
op: "set",
key: "goodbye",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -485,9 +485,9 @@ test("If we add a peer, but it never subscribes to a coValue, it won't get any m
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const map = team.createMap();
const map = group.createMap();
const [inRx, _inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -514,9 +514,9 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const map = team.createMap();
const map = group.createMap();
const [inRx, _inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -535,7 +535,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
// });
expect((await reader.read()).value).toMatchObject({
action: "load",
id: team.teamMap.coValue.id,
id: group.groupMap.coValue.id,
});
const mapSubscribeMsg = await reader.read();
@@ -552,7 +552,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
});
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
const mapNewContentMsg = await reader.read();
@@ -570,7 +570,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
.transactions[0]!.madeAt,
changes: [
{
op: "insert",
op: "set",
key: "hello",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -588,7 +588,7 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const [inRx, _inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -607,10 +607,10 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
// });
expect((await reader.read()).value).toMatchObject({
action: "load",
id: team.teamMap.coValue.id,
id: group.groupMap.coValue.id,
});
const map = team.createMap();
const map = group.createMap();
const mapSubscribeMsg = await reader.read();
@@ -620,7 +620,7 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(adminID));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
const mapContentMsg = await reader.read();
@@ -640,9 +640,9 @@ test("When we connect a new server peer, we try to sync all existing coValues to
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const map = team.createMap();
const map = group.createMap();
const [inRx, _inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -657,11 +657,11 @@ test("When we connect a new server peer, we try to sync all existing coValues to
const reader = outRx.getReader();
// const _adminSubscribeMessage = await reader.read();
const teamSubscribeMessage = await reader.read();
const groupSubscribeMessage = await reader.read();
expect(teamSubscribeMessage.value).toEqual({
expect(groupSubscribeMessage.value).toEqual({
action: "load",
...team.teamMap.coValue.knownState(),
...group.groupMap.coValue.knownState(),
} satisfies SyncMessage);
const secondMessage = await reader.read();
@@ -676,9 +676,9 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const map = team.createMap();
const map = group.createMap();
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -704,7 +704,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
const mapTellKnownState = await reader.read();
expect(mapTellKnownState.value).toEqual({
@@ -719,7 +719,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
const node1 = new LocalNode(admin, session);
const team = node1.createTeam();
const group = node1.createGroup();
const [inRx1, inTx1] = newStreamPair<SyncMessage>();
const [outRx1, outTx1] = newStreamPair<SyncMessage>();
@@ -754,40 +754,40 @@ test.skip("When replaying creation and transactions of a coValue as new content,
action: "load",
id: admin.id,
});
const teamSubscribeMsg = await from1.read();
expect(teamSubscribeMsg.value).toMatchObject({
const groupSubscribeMsg = await from1.read();
expect(groupSubscribeMsg.value).toMatchObject({
action: "load",
id: team.teamMap.coValue.id,
id: group.groupMap.coValue.id,
});
await to2.write(adminSubscribeMessage.value!);
await to2.write(teamSubscribeMsg.value!);
await to2.write(groupSubscribeMsg.value!);
// const adminTellKnownStateMsg = await from2.read();
// expect(adminTellKnownStateMsg.value).toMatchObject(admStateEx(admin.id));
const teamTellKnownStateMsg = await from2.read();
expect(teamTellKnownStateMsg.value).toMatchObject(teamStateEx(team));
const groupTellKnownStateMsg = await from2.read();
expect(groupTellKnownStateMsg.value).toMatchObject(groupStateEx(group));
expect(
node2.sync.peers["test1"]!.optimisticKnownStates[
team.teamMap.coValue.id
group.groupMap.coValue.id
]
).toBeDefined();
// await to1.write(adminTellKnownStateMsg.value!);
await to1.write(teamTellKnownStateMsg.value!);
await to1.write(groupTellKnownStateMsg.value!);
// const adminContentMsg = await from1.read();
// expect(adminContentMsg.value).toMatchObject(admContEx(admin.id));
const teamContentMsg = await from1.read();
expect(teamContentMsg.value).toMatchObject(teamContentEx(team));
const groupContentMsg = await from1.read();
expect(groupContentMsg.value).toMatchObject(groupContentEx(group));
// await to2.write(adminContentMsg.value!);
await to2.write(teamContentMsg.value!);
await to2.write(groupContentMsg.value!);
const map = team.createMap();
const map = group.createMap();
const mapSubscriptionMsg = await from1.read();
expect(mapSubscriptionMsg.value).toMatchObject({
@@ -840,9 +840,9 @@ test.skip("When loading a coValue on one node, the server node it is requested f
const node1 = new LocalNode(admin, session);
const team = node1.createTeam();
const group = node1.createGroup();
const map = team.createMap();
const map = group.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
});
@@ -868,9 +868,9 @@ test("Can sync a coValue through a server to another client", async () => {
const client1 = new LocalNode(admin, session);
const team = client1.createTeam();
const group = client1.createGroup();
const map = team.createMap();
const map = group.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
});
@@ -910,9 +910,9 @@ test("Can sync a coValue with private transactions through a server to another c
const client1 = new LocalNode(admin, session);
const team = client1.createTeam();
const group = client1.createGroup();
const map = team.createMap();
const map = group.createMap();
map.edit((editable) => {
editable.set("hello", "world", "private");
});
@@ -952,7 +952,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -971,10 +971,10 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
// });
expect((await reader.read()).value).toMatchObject({
action: "load",
id: team.teamMap.coValue.id,
id: group.groupMap.coValue.id,
});
const map = team.createMap();
const map = group.createMap();
const mapSubscribeMsg = await reader.read();
@@ -984,7 +984,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
const mapContentMsg = await reader.read();
@@ -1006,7 +1006,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
const [admin, session] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, session);
const team = node.createTeam();
const group = node.createGroup();
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -1025,10 +1025,10 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
// });
expect((await reader.read()).value).toMatchObject({
action: "load",
id: team.teamMap.coValue.id,
id: group.groupMap.coValue.id,
});
const map = team.createMap();
const map = group.createMap();
const mapSubscribeMsg = await reader.read();
@@ -1038,7 +1038,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
const mapContentMsg = await reader.read();
@@ -1066,9 +1066,9 @@ test("If we start loading a coValue before connecting to a peer that has it, it
const node1 = new LocalNode(admin, session);
const team = node1.createTeam();
const group = node1.createGroup();
const map = team.createMap();
const map = group.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
});
@@ -1096,10 +1096,10 @@ test("If we start loading a coValue before connecting to a peer that has it, it
);
});
function teamContentEx(team: Team) {
function groupContentEx(group: Group) {
return {
action: "content",
id: team.teamMap.coValue.id,
id: group.groupMap.coValue.id,
};
}
@@ -1110,10 +1110,10 @@ function admContEx(adminID: AccountID) {
};
}
function teamStateEx(team: Team) {
function groupStateEx(group: Group) {
return {
action: "known",
id: team.teamMap.coValue.id,
id: group.groupMap.coValue.id,
};
}

View File

@@ -1,4 +1,4 @@
import { Hash, Signature } from "./crypto.js";
import { Signature } from "./crypto.js";
import { CoValueHeader, Transaction } from "./coValue.js";
import { CoValue } from "./coValue.js";
import { LocalNode } from "./node.js";
@@ -285,7 +285,7 @@ export class SyncManager {
trySendToPeer(peer: PeerState, msg: SyncMessage) {
return peer.outgoing.write(msg).catch((e) => {
console.error(new Error("Error writing to peer, disconnecting", {cause: e}));
console.error(new Error(`Error writing to peer ${peer.id}, disconnecting`, {cause: e}));
delete this.peers[peer.id];
});
}

View File

@@ -1,7 +1,7 @@
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "./crypto.js";
import { newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { expectTeamContent } from "./permissions.js";
import { expectGroupContent } from "./group.js";
import { AnonymousControlledAccount } from "./account.js";
import { SessionID } from "./ids.js";
@@ -13,69 +13,69 @@ export function randomAnonymousAccountAndSessionID(): [AnonymousControlledAccoun
return [new AnonymousControlledAccount(agentSecret), sessionID];
}
export function newTeam() {
export function newGroup() {
const [admin, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, sessionID);
const team = node.createCoValue({
const group = node.createCoValue({
type: "comap",
ruleset: { type: "team", initialAdmin: admin.id },
ruleset: { type: "group", initialAdmin: admin.id },
meta: null,
...createdNowUnique(),
});
const teamContent = expectTeamContent(team.getCurrentContent());
const groupContent = expectGroupContent(group.getCurrentContent());
teamContent.edit((editable) => {
groupContent.edit((editable) => {
editable.set(admin.id, "admin", "trusting");
expect(editable.get(admin.id)).toEqual("admin");
});
return { node, team, admin };
return { node, group, admin };
}
export function teamWithTwoAdmins() {
const { team, admin, node } = newTeam();
export function groupWithTwoAdmins() {
const { group, admin, node } = newGroup();
const otherAdmin = node.createAccount("otherAdmin");
let content = expectTeamContent(team.getCurrentContent());
let content = expectGroupContent(group.getCurrentContent());
content.edit((editable) => {
editable.set(otherAdmin.id, "admin", "trusting");
expect(editable.get(otherAdmin.id)).toEqual("admin");
});
content = expectTeamContent(team.getCurrentContent());
content = expectGroupContent(group.getCurrentContent());
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.get(otherAdmin.id)).toEqual("admin");
return { team, admin, otherAdmin, node };
return { group, admin, otherAdmin, node };
}
export function newTeamHighLevel() {
export function newGroupHighLevel() {
const [admin, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(admin, sessionID);
const team = node.createTeam();
const group = node.createGroup();
return { admin, node, team };
return { admin, node, group };
}
export function teamWithTwoAdminsHighLevel() {
const { admin, node, team } = newTeamHighLevel();
export function groupWithTwoAdminsHighLevel() {
const { admin, node, group } = newGroupHighLevel();
const otherAdmin = node.createAccount("otherAdmin");
team.addMember(otherAdmin.id, "admin");
group.addMember(otherAdmin.id, "admin");
return { admin, node, team, otherAdmin };
return { admin, node, group, otherAdmin };
}
export function shouldNotResolve<T>(

View File

@@ -12,4 +12,5 @@
"esModuleInterop": true,
},
"include": ["./src/**/*"],
"exclude": ["./src/**/*.test.*"],
}

View File

@@ -0,0 +1,21 @@
module.exports = {
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:require-extensions/recommended",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "require-extensions"],
parserOptions: {
project: "./tsconfig.json",
},
root: true,
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-floating-promises": "error",
},
};

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

View File

@@ -0,0 +1,2 @@
coverage
node_modules

View File

@@ -0,0 +1,16 @@
{
"name": "jazz-browser-auth-local",
"version": "0.1.2",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"jazz-browser": "^0.1.2",
"typescript": "^5.1.6"
},
"scripts": {
"lint": "eslint src/**/*.ts",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"prepublishOnly": "npm run build"
}
}

View File

@@ -0,0 +1,212 @@
import {
AccountID,
AgentSecret,
cojsonInternals,
LocalNode,
Peer,
} from "cojson";
import { agentSecretFromSecretSeed } from "cojson/src/crypto";
import { AuthProvider, SessionProvider } from "jazz-browser";
type SessionStorageData = {
accountID: AccountID;
accountSecret: AgentSecret;
};
const sessionStorageKey = "jazz-logged-in-secret";
export interface BrowserLocalAuthDriver {
onReady: (next: {
signUp: (username: string) => Promise<void>;
logIn: () => Promise<void>;
}) => void;
onSignedIn: (next: { logOut: () => void }) => void;
}
export class BrowserLocalAuth implements AuthProvider {
driver: BrowserLocalAuthDriver;
appName: string;
appHostname: string;
constructor(
driver: BrowserLocalAuthDriver,
appName: string,
// TODO: is this a safe default?
appHostname: string = window.location.hostname
) {
this.driver = driver;
this.appName = appName;
this.appHostname = appHostname;
}
async createNode(
getSessionFor: SessionProvider,
initialPeers: Peer[]
): Promise<LocalNode> {
if (sessionStorage[sessionStorageKey]) {
const sessionStorageData = JSON.parse(
sessionStorage[sessionStorageKey]
) as SessionStorageData;
const sessionID = await getSessionFor(sessionStorageData.accountID);
const node = await LocalNode.withLoadedAccount(
sessionStorageData.accountID,
sessionStorageData.accountSecret,
sessionID,
initialPeers
);
this.driver.onSignedIn({ logOut });
return Promise.resolve(node);
} else {
const node = await new Promise<LocalNode>(
(doneSigningUpOrLoggingIn) => {
this.driver.onReady({
signUp: async (username) => {
const node = await signUp(
username,
getSessionFor,
this.appName,
this.appHostname
);
for (const peer of initialPeers) {
node.sync.addPeer(peer);
}
doneSigningUpOrLoggingIn(node);
this.driver.onSignedIn({ logOut });
},
logIn: async () => {
const node = await logIn(
getSessionFor,
this.appHostname,
initialPeers
);
doneSigningUpOrLoggingIn(node);
this.driver.onSignedIn({ logOut });
},
});
}
);
return node;
}
}
}
async function signUp(
username: string,
getSessionFor: SessionProvider,
appName: string,
appHostname: string
): Promise<LocalNode> {
const secretSeed = cojsonInternals.newRandomSecretSeed();
const { node, accountID, accountSecret } =
LocalNode.withNewlyCreatedAccount(
username,
agentSecretFromSecretSeed(secretSeed)
);
const webAuthNCredentialPayload = new Uint8Array(
cojsonInternals.secretSeedLength + cojsonInternals.shortHashLength
);
webAuthNCredentialPayload.set(secretSeed);
webAuthNCredentialPayload.set(
cojsonInternals.rawCoIDtoBytes(accountID),
cojsonInternals.secretSeedLength
);
const webAuthNCredential = await navigator.credentials.create({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
rp: {
name: appName,
id: appHostname,
},
user: {
id: webAuthNCredentialPayload,
name: username + `(${new Date().toLocaleString()})`,
displayName: username,
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
authenticatorSelection: {
authenticatorAttachment: "platform",
},
timeout: 60000,
attestation: "direct",
},
});
console.log(webAuthNCredential, accountID);
sessionStorage[sessionStorageKey] = JSON.stringify({
accountID,
accountSecret,
} satisfies SessionStorageData);
node.ownSessionID = await getSessionFor(accountID);
return node;
}
async function logIn(
getSessionFor: SessionProvider,
appHostname: string,
initialPeers: Peer[]
): Promise<LocalNode> {
const webAuthNCredential = (await navigator.credentials.get({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
rpId: appHostname,
allowCredentials: [],
timeout: 60000,
},
})) as unknown as {
response: { userHandle: ArrayBuffer };
};
if (!webAuthNCredential) {
throw new Error("Couldn't log in");
}
const webAuthNCredentialPayload = new Uint8Array(
webAuthNCredential.response.userHandle
);
const accountSecretSeed = webAuthNCredentialPayload.slice(
0,
cojsonInternals.secretSeedLength
);
const accountID = cojsonInternals.rawCoIDfromBytes(
webAuthNCredentialPayload.slice(
cojsonInternals.secretSeedLength,
cojsonInternals.secretSeedLength + cojsonInternals.shortHashLength
)
) as AccountID;
const accountSecret = agentSecretFromSecretSeed(accountSecretSeed);
if (!accountSecret) {
throw new Error("Invalid credential");
}
sessionStorage[sessionStorageKey] = JSON.stringify({
accountID,
accountSecret,
} satisfies SessionStorageData);
const node = await LocalNode.withLoadedAccount(
accountID,
accountSecret,
await getSessionFor(accountID),
initialPeers
);
return node;
}
function logOut() {
delete sessionStorage[sessionStorageKey];
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"module": "esnext",
"target": "ES2020",
"moduleResolution": "bundler",
"moduleDetection": "force",
"strict": true,
"jsx": "react",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
},
"include": ["./src/**/*"],
}

View File

@@ -0,0 +1,83 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@noble/ciphers@^0.1.3":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.1.4.tgz#96327dca147829ed9eee0d96cfdf7c57915765f0"
integrity sha512-d3ZR8vGSpy3v/nllS+bD/OMN5UZqusWiQqkyj7AwzTnhXFH72pF5oB4Ach6DQ50g5kXxC28LdaYBEpsyv9KOUQ==
"@noble/curves@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d"
integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==
dependencies:
"@noble/hashes" "1.3.1"
"@noble/hashes@1.3.1", "@noble/hashes@^1.3.1":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9"
integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==
"@scure/base@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==
"@types/prop-types@*":
version "15.7.5"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
"@types/react@^18.2.19":
version "18.2.19"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.19.tgz#f77cb2c8307368e624d464a25b9675fa35f95a8b"
integrity sha512-e2S8wmY1ePfM517PqCG80CcE48Xs5k0pwJzuDZsfE8IZRRBfOMCF+XqnFxu6mWtyivum1MQm4aco+WIt6Coimw==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/scheduler@*":
version "0.16.3"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"
integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==
cojson@^0.0.14:
version "0.0.14"
resolved "https://registry.yarnpkg.com/cojson/-/cojson-0.0.14.tgz#e7b190ade1efc20d6f0fa12411d7208cdc0f19f7"
integrity sha512-TFenIGswEEhnZlCmq+B1NZPztjovZ72AjK1YkkZca54ZFbB1lAHdPt2hqqu/QBO24C9+6DtuoS2ixm6gbSBWCg==
dependencies:
"@noble/ciphers" "^0.1.3"
"@noble/curves" "^1.1.0"
"@noble/hashes" "^1.3.1"
"@scure/base" "^1.1.1"
fast-json-stable-stringify "^2.1.0"
isomorphic-streams "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
csstype@^3.0.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
fast-json-stable-stringify@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
"isomorphic-streams@git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae":
version "1.0.3"
resolved "git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
jazz-react@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/jazz-react/-/jazz-react-0.0.6.tgz#53b0245720b10ec31ac8deba45fd8a052b313b06"
integrity sha512-JlYTKUVPpuK3T7cLfk2YwHh3yH+2BPVSuWIQui35U52/gce+HmTMGolqFYGghWMVQtwclaZ0IoEbtuycPiADOQ==
dependencies:
cojson "^0.0.14"
typescript "^5.1.6"
typescript@^5.1.6:
version "5.1.6"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274"
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==

View File

@@ -0,0 +1,17 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
project: "./tsconfig.json",
},
root: true,
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/no-floating-promises": "error",
},
};

171
packages/jazz-browser/.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

View File

@@ -0,0 +1,2 @@
coverage
node_modules

View File

@@ -0,0 +1,17 @@
{
"name": "jazz-browser",
"version": "0.1.2",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.1.2",
"jazz-storage-indexeddb": "^0.1.2",
"typescript": "^5.1.6"
},
"scripts": {
"lint": "eslint src/**/*.ts",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"prepublishOnly": "npm run build"
}
}

View File

@@ -0,0 +1,313 @@
import { InviteSecret } from "cojson";
import {
LocalNode,
cojsonInternals,
CojsonInternalTypes,
SessionID,
SyncMessage,
Peer,
ContentType,
Group,
CoID,
} from "cojson";
import { ReadableStream, WritableStream } from "isomorphic-streams";
import { IDBStorage } from "jazz-storage-indexeddb";
export type BrowserNodeHandle = {
node: LocalNode;
// TODO: Symbol.dispose?
done: () => void;
};
export async function createBrowserNode({
auth,
syncAddress = "wss://sync.jazz.tools",
reconnectionTimeout = 300,
}: {
auth: AuthProvider;
syncAddress?: string;
reconnectionTimeout?: number;
}): Promise<BrowserNodeHandle> {
let sessionDone: () => void;
const firstWsPeer = createWebSocketPeer(syncAddress);
let shouldTryToReconnect = true;
const node = await auth.createNode(
(accountID) => {
const sessionHandle = getSessionHandleFor(accountID);
sessionDone = sessionHandle.done;
return sessionHandle.session;
},
[await IDBStorage.asPeer({ trace: true }), firstWsPeer]
);
void async function websocketReconnectLoop() {
while (shouldTryToReconnect) {
if (
Object.keys(node.sync.peers).some((peerId) =>
peerId.includes(syncAddress)
)
) {
await new Promise((resolve) =>
setTimeout(resolve, reconnectionTimeout)
);
} else {
console.log("Websocket disconnected, trying to reconnect");
node.sync.addPeer(createWebSocketPeer(syncAddress));
await new Promise((resolve) =>
setTimeout(resolve, reconnectionTimeout)
);
}
}
};
return {
node,
done: () => {
shouldTryToReconnect = false;
sessionDone?.();
},
};
}
export interface AuthProvider {
createNode(
getSessionFor: SessionProvider,
initialPeers: Peer[]
): Promise<LocalNode>;
}
export type SessionProvider = (
accountID: CojsonInternalTypes.AccountIDOrAgentID
) => Promise<SessionID>;
export type SessionHandle = {
session: Promise<SessionID>;
done: () => void;
};
function getSessionHandleFor(
accountID: CojsonInternalTypes.AccountIDOrAgentID
): SessionHandle {
let done!: () => void;
const donePromise = new Promise<void>((resolve) => {
done = resolve;
});
let resolveSession: (sessionID: SessionID) => void;
const sessionPromise = new Promise<SessionID>((resolve) => {
resolveSession = resolve;
});
void (async function () {
for (let idx = 0; idx < 100; idx++) {
// To work better around StrictMode
for (let retry = 0; retry < 2; retry++) {
console.debug("Trying to get lock", accountID + "_" + idx);
const sessionFinishedOrNoLock = await navigator.locks.request(
accountID + "_" + idx,
{ ifAvailable: true },
async (lock) => {
if (!lock) return "noLock";
const sessionID =
localStorage[accountID + "_" + idx] ||
cojsonInternals.newRandomSessionID(accountID);
localStorage[accountID + "_" + idx] = sessionID;
console.debug(
"Got lock",
accountID + "_" + idx,
sessionID
);
resolveSession(sessionID);
await donePromise;
console.log(
"Done with lock",
accountID + "_" + idx,
sessionID
);
return "sessionFinished";
}
);
if (sessionFinishedOrNoLock === "sessionFinished") {
return;
}
}
}
throw new Error("Couldn't get lock on session after 100x2 tries");
})();
return {
session: sessionPromise,
done,
};
}
function websocketReadableStream<T>(ws: WebSocket) {
ws.binaryType = "arraybuffer";
return new ReadableStream<T>({
start(controller) {
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "ping") {
console.debug(
"Got ping from",
msg.dc,
"latency",
Date.now() - msg.time,
"ms"
);
return;
}
controller.enqueue(msg);
};
ws.onclose = () => controller.close();
ws.onerror = () =>
controller.error(new Error("The WebSocket errored!"));
},
cancel() {
ws.close();
},
});
}
function createWebSocketPeer(syncAddress: string): Peer {
const ws = new WebSocket(syncAddress);
const incoming = websocketReadableStream<SyncMessage>(ws);
const outgoing = websocketWritableStream<SyncMessage>(ws);
return {
id: syncAddress + "@" + new Date().toISOString(),
incoming,
outgoing,
role: "server",
};
}
function websocketWritableStream<T>(ws: WebSocket) {
return new WritableStream<T>({
start(controller) {
ws.onerror = () => {
controller.error(new Error("The WebSocket errored!"));
ws.onclose = null;
};
ws.onclose = () =>
controller.error(
new Error("The server closed the connection unexpectedly!")
);
return new Promise((resolve) => (ws.onopen = resolve));
},
write(chunk) {
ws.send(JSON.stringify(chunk));
// Return immediately, since the web socket gives us no easy way to tell
// when the write completes.
},
close() {
return closeWS(1000);
},
abort(reason) {
return closeWS(4000, reason && reason.message);
},
});
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.close(code, reasonString);
});
}
}
export function createInviteLink(
value: ContentType,
role: "reader" | "writer" | "admin",
// default to same address as window.location, but without hash
{
baseURL = window.location.href.replace(/#.*$/, ""),
}: { baseURL?: string } = {}
): string {
const coValue = value.coValue;
const node = coValue.node;
let currentCoValue = coValue;
while (currentCoValue.header.ruleset.type === "ownedByGroup") {
currentCoValue = node.expectCoValueLoaded(
currentCoValue.header.ruleset.group
);
}
if (currentCoValue.header.ruleset.type !== "group") {
throw new Error("Can't create invite link for object without group");
}
const group = new Group(
cojsonInternals.expectGroupContent(currentCoValue.getCurrentContent()),
node
);
const inviteSecret = group.createInvite(role);
return `${baseURL}#invitedTo=${value.id}&${inviteSecret}`;
}
export function parseInviteLink(inviteURL: string):
| {
valueID: CoID<ContentType>;
inviteSecret: InviteSecret;
}
| undefined {
const url = new URL(inviteURL);
const valueID = url.hash
.split("&")[0]
?.replace(/^#invitedTo=/, "") as CoID<ContentType>;
const inviteSecret = url.hash
.split("&")[1] as InviteSecret;
if (!valueID || !inviteSecret) {
return undefined;
}
return { valueID, inviteSecret };
}
export function consumeInviteLinkFromWindowLocation(node: LocalNode): Promise<
| {
valueID: string;
inviteSecret: string;
}
| undefined
> {
return new Promise((resolve, reject) => {
const result = parseInviteLink(window.location.href);
if (result) {
node.acceptInvite(result.valueID, result.inviteSecret)
.then(() => {
resolve(result);
window.history.replaceState(
{},
"",
window.location.href.replace(/#.*$/, "")
);
})
.catch(reject);
} else {
resolve(undefined);
}
});
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"module": "esnext",
"target": "ES2020",
"moduleResolution": "bundler",
"moduleDetection": "force",
"strict": true,
"skipLibCheck": true,
"jsx": "react",
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
},
"include": ["./src/**/*"],
}

View File

@@ -0,0 +1,75 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@noble/ciphers@^0.1.3":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.1.4.tgz#96327dca147829ed9eee0d96cfdf7c57915765f0"
integrity sha512-d3ZR8vGSpy3v/nllS+bD/OMN5UZqusWiQqkyj7AwzTnhXFH72pF5oB4Ach6DQ50g5kXxC28LdaYBEpsyv9KOUQ==
"@noble/curves@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d"
integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==
dependencies:
"@noble/hashes" "1.3.1"
"@noble/hashes@1.3.1", "@noble/hashes@^1.3.1":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9"
integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==
"@scure/base@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==
"@types/prop-types@*":
version "15.7.5"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
"@types/react@^18.2.19":
version "18.2.19"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.19.tgz#f77cb2c8307368e624d464a25b9675fa35f95a8b"
integrity sha512-e2S8wmY1ePfM517PqCG80CcE48Xs5k0pwJzuDZsfE8IZRRBfOMCF+XqnFxu6mWtyivum1MQm4aco+WIt6Coimw==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/scheduler@*":
version "0.16.3"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"
integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==
cojson@^0.0.14:
version "0.0.14"
resolved "https://registry.yarnpkg.com/cojson/-/cojson-0.0.14.tgz#e7b190ade1efc20d6f0fa12411d7208cdc0f19f7"
integrity sha512-TFenIGswEEhnZlCmq+B1NZPztjovZ72AjK1YkkZca54ZFbB1lAHdPt2hqqu/QBO24C9+6DtuoS2ixm6gbSBWCg==
dependencies:
"@noble/ciphers" "^0.1.3"
"@noble/curves" "^1.1.0"
"@noble/hashes" "^1.3.1"
"@scure/base" "^1.1.1"
fast-json-stable-stringify "^2.1.0"
isomorphic-streams "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
csstype@^3.0.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
fast-json-stable-stringify@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
"isomorphic-streams@git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae":
version "1.0.3"
resolved "git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
typescript@^5.1.6:
version "5.1.6"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274"
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==

View File

@@ -1,18 +1,21 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:require-extensions/recommended",
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "require-extensions"],
parserOptions: {
project: './tsconfig.json',
project: "./tsconfig.json",
},
root: true,
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-floating-promises": "error",
},
};
};

View File

@@ -0,0 +1,2 @@
coverage
node_modules

View File

@@ -1,17 +1,23 @@
{
"name": "jazz-react-auth-local",
"version": "0.0.5",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"jazz-react": "^0.0.8",
"typescript": "^5.1.6"
},
"devDependencies": {
"@types/react": "^18.2.19"
},
"peerDependencies": {
"react": "^17.0.2"
}
"name": "jazz-react-auth-local",
"version": "0.1.2",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"jazz-browser-auth-local": "^0.1.2",
"jazz-react": "^0.1.2",
"typescript": "^5.1.6"
},
"devDependencies": {
"@types/react": "^18.2.19"
},
"peerDependencies": {
"react": "^17.0.2"
},
"scripts": {
"lint": "eslint src/**/*.tsx",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"prepublishOnly": "npm run build"
}
}

View File

@@ -1,81 +0,0 @@
import { newRandomAgentSecret, AgentSecret, agentSecretToBytes, agentSecretFromBytes} from "cojson/src/crypto";
import React, { useCallback, useEffect, useState } from "react";
export function useLocalAuth(onCredential: (credentials: AgentSecret) => void) {
const [displayName, setDisplayName] = useState<string>("");
useEffect(() => {
if (sessionStorage.credential) {
const credential = JSON.parse(sessionStorage.credential);
onCredential(credential);
}
}, [onCredential]);
const signUp = useCallback(() => {
(async function () {
const credential = newRandomAgentSecret();
console.log(credential);
const webAuthNCredential = await navigator.credentials.create({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
rp: {
name: "TodoApp",
// TODO: something safer as default?
id: window.location.hostname,
},
user: {
id: agentSecretToBytes(credential),
name: displayName,
displayName: displayName,
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
authenticatorSelection: {
authenticatorAttachment: "platform",
},
timeout: 60000,
attestation: "direct",
},
});
console.log(
webAuthNCredential,
credential,
agentSecretToBytes(credential)
);
sessionStorage.credential = JSON.stringify(credential);
onCredential(credential);
})();
}, [displayName]);
const signIn = useCallback(() => {
(async function () {
const webAuthNCredential = await navigator.credentials.get({
publicKey: {
challenge: Uint8Array.from([0, 1, 2]),
// TODO: something safer as default?
rpId: window.location.hostname,
allowCredentials: [],
timeout: 60000,
},
});
const userIdBytes = new Uint8Array(
(webAuthNCredential as any).response.userHandle
);
const credential =
agentSecretFromBytes(userIdBytes);
if (!credential) {
throw new Error("Invalid credential");
}
sessionStorage.credential = JSON.stringify(credential);
onCredential(credential);
})();
}, []);
return { displayName, setDisplayName, signUp, signIn };
}

View File

@@ -0,0 +1,149 @@
import React from "react";
import { useMemo, useState, ReactNode } from "react";
import { BrowserLocalAuth } from "jazz-browser-auth-local";
import { ReactAuthHook } from "jazz-react";
export type LocalAuthComponent = (props: {
loading: boolean;
logIn: () => void;
signUp: (username: string) => void;
}) => ReactNode;
export function LocalAuth({
appName,
appHostname,
Component = LocalAuthBasicUI,
}: {
appName: string;
appHostname?: string;
Component?: LocalAuthComponent;
}): ReactAuthHook {
return function useLocalAuth() {
const [authState, setAuthState] = useState<
| { state: "loading" }
| {
state: "ready";
logIn: () => void;
signUp: (username: string) => void;
}
| { state: "signedIn"; logOut: () => void }
>({ state: "loading" });
const [logOutCounter, setLogOutCounter] = useState(0);
const auth = useMemo(() => {
return new BrowserLocalAuth(
{
onReady(next) {
setAuthState({
state: "ready",
logIn: next.logIn,
signUp: next.signUp,
});
},
onSignedIn(next) {
setAuthState({
state: "signedIn",
logOut: () => {
next.logOut();
setAuthState({ state: "loading" });
setLogOutCounter((c) => c + 1);
},
});
},
},
appName,
appHostname
);
}, [appName, appHostname, logOutCounter]);
const AuthUI =
authState.state === "ready"
? Component({
loading: false,
logIn: authState.logIn,
signUp: authState.signUp,
})
: Component({
loading: false,
logIn: () => {},
signUp: (_) => {},
});
return {
auth,
AuthUI,
logOut:
authState.state === "signedIn" ? authState.logOut : undefined,
};
};
}
export const LocalAuthBasicUI = ({
logIn,
signUp,
}: {
logIn: () => void;
signUp: (username: string) => void;
}) => {
const [username, setUsername] = useState<string>("");
return (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
width: "18rem",
display: "flex",
flexDirection: "column",
gap: "2rem",
}}
>
<form
style={{
width: "18rem",
display: "flex",
flexDirection: "column",
gap: "0.5rem",
}}
onSubmit={(e) => {
e.preventDefault();
signUp(username);
}}
>
<input
placeholder="Display name"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="webauthn"
style={{
border: "1px solid #333",
padding: "10px 5px",
}}
/>
<input
type="submit"
value="Sign Up as new account"
style={{
background: "#aaa",
padding: "10px 5px",
}}
/>
</form>
<button
onClick={logIn}
style={{ background: "#aaa", padding: "10px 5px" }}
>
Log In with existing account
</button>
</div>
</div>
);
};

View File

@@ -2,18 +2,13 @@
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"module": "esnext",
"target": "esnext",
"target": "ES2020",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"strict": true,
"downlevelIteration": true,
"jsx": "react",
"skipLibCheck": true,
"jsx": "preserve",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"noEmit": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
},

View File

@@ -6,7 +6,7 @@ module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
project: './tsconfig.json',
project: "./tsconfig.json",
},
root: true,
rules: {
@@ -14,5 +14,4 @@ module.exports = {
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/no-floating-promises": "error",
},
};
};

View File

@@ -0,0 +1,2 @@
coverage
node_modules

View File

@@ -1,11 +1,12 @@
{
"name": "jazz-react",
"version": "0.0.8",
"main": "src/index.tsx",
"types": "src/index.tsx",
"version": "0.1.2",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.0.16",
"cojson": "^0.1.2",
"jazz-browser": "^0.1.2",
"typescript": "^5.1.6"
},
"devDependencies": {
@@ -13,5 +14,10 @@
},
"peerDependencies": {
"react": "^17.0.2"
},
"scripts": {
"lint": "eslint src/**/*.tsx",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"prepublishOnly": "npm run build"
}
}

View File

@@ -1,180 +1,81 @@
import {
LocalNode,
internals as cojsonInternals,
SessionID,
ContentType,
SyncMessage,
AgentSecret,
CoID,
ProfileContent,
CoMap,
AccountID,
Profile,
} from "cojson";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { ReadableStream, WritableStream } from "isomorphic-streams";
import { CoID } from "cojson";
import { AgentID } from "cojson/src/ids";
import { getAgentID } from "cojson/src/crypto";
import { AnonymousControlledAccount } from "cojson/src/account";
import { IDBStorage } from "jazz-storage-indexeddb";
import React, { useEffect, useState } from "react";
import { AuthProvider, createBrowserNode } from "jazz-browser";
export {
createInviteLink,
parseInviteLink,
consumeInviteLinkFromWindowLocation,
} from "jazz-browser";
type JazzContext = {
localNode: LocalNode;
logOut: () => void;
};
const JazzContext = React.createContext<JazzContext | undefined>(undefined);
export type AuthComponent = (props: {
onCredential: (credentials: AgentSecret) => void;
}) => React.ReactElement;
export type ReactAuthHook = () => {
auth: AuthProvider;
AuthUI: React.ReactNode;
logOut?: () => void;
};
export function WithJazz({
children,
auth: Auth,
syncAddress = "wss://sync.jazz.tools",
auth: authHook,
syncAddress,
}: {
children: React.ReactNode;
auth: AuthComponent;
auth: ReactAuthHook;
syncAddress?: string;
}) {
const [node, setNode] = useState<LocalNode | undefined>();
const sessionDone = useRef<() => void>();
const onCredential = useCallback((credential: AgentSecret) => {
const agentID = getAgentID(
credential
);
const sessionHandle = getSessionFor(agentID);
sessionHandle.session.then((sessionID) =>
setNode(new LocalNode(new AnonymousControlledAccount(credential), sessionID))
);
sessionDone.current = sessionHandle.done;
}, []);
const { auth, AuthUI, logOut } = authHook();
useEffect(() => {
let done: (() => void) | undefined = undefined;
(async () => {
const nodeHandle = await createBrowserNode({
auth: auth,
syncAddress,
});
setNode(nodeHandle.node);
done = nodeHandle.done;
})().catch((e) => {
console.log("Failed to create browser node", e);
});
return () => {
sessionDone.current && sessionDone.current();
done && done();
};
}, []);
}, [auth, syncAddress]);
useEffect(() => {
if (node) {
IDBStorage.connectTo(node, {trace: true})
let shouldTryToReconnect = true;
let ws: WebSocket | undefined;
(async function websocketReconnectLoop() {
while (shouldTryToReconnect) {
ws = new WebSocket(syncAddress);
const timeToReconnect = new Promise<void>((resolve) => {
if (
!ws ||
ws.readyState === WebSocket.CLOSING ||
ws.readyState === WebSocket.CLOSED
)
resolve();
ws?.addEventListener(
"close",
() => {
console.log(
"Connection closed, reconnecting in 5s"
);
setTimeout(resolve, 5000);
},
{ once: true }
);
});
const incoming = websocketReadableStream<SyncMessage>(ws);
const outgoing = websocketWritableStream<SyncMessage>(ws);
node.sync.addPeer({
id: syncAddress + "@" + new Date().toISOString(),
incoming,
outgoing,
role: "server",
});
await timeToReconnect;
}
})();
return () => {
shouldTryToReconnect = false;
ws?.close();
};
}
}, [node, syncAddress]);
return node ? (
<JazzContext.Provider value={{ localNode: node }}>
<>{children}</>
</JazzContext.Provider>
) : (
<Auth onCredential={onCredential} />
return (
<>
{node && logOut ? (
<JazzContext.Provider value={{ localNode: node, logOut }}>
<>{children}</>
</JazzContext.Provider>
) : (
AuthUI
)}
</>
);
}
type SessionHandle = {
session: Promise<SessionID>;
done: () => void;
};
function getSessionFor(agentID: AgentID): SessionHandle {
let done!: () => void;
const donePromise = new Promise<void>((resolve) => {
done = resolve;
});
let resolveSession: (sessionID: SessionID) => void;
const sessionPromise = new Promise<SessionID>((resolve) => {
resolveSession = resolve;
});
(async function () {
for (let idx = 0; idx < 100; idx++) {
// To work better around StrictMode
for (let retry = 0; retry < 2; retry++) {
console.log("Trying to get lock", agentID + "_" + idx);
const sessionFinishedOrNoLock = await navigator.locks.request(
agentID + "_" + idx,
{ ifAvailable: true },
async (lock) => {
if (!lock) return "noLock";
const sessionID =
localStorage[agentID + "_" + idx] ||
cojsonInternals.newRandomSessionID(agentID);
localStorage[agentID + "_" + idx] = sessionID;
console.log("Got lock", agentID + "_" + idx, sessionID);
resolveSession(sessionID);
await donePromise;
console.log(
"Done with lock",
agentID + "_" + idx,
sessionID
);
return "sessionFinished";
}
);
if (sessionFinishedOrNoLock === "sessionFinished") {
return;
}
}
}
throw new Error("Couldn't get lock on session after 100x2 tries");
})();
return {
session: sessionPromise,
done,
};
}
export function useJazz() {
const context = React.useContext(JazzContext);
@@ -185,28 +86,33 @@ export function useJazz() {
return context;
}
export function useTelepathicState<T extends ContentType>(id: CoID<T>) {
export function useTelepathicState<T extends ContentType>(id?: CoID<T>) {
const [state, setState] = useState<T>();
const { localNode } = useJazz();
useEffect(() => {
if (!id) return;
let unsubscribe: (() => void) | undefined = undefined;
let done = false;
localNode.load(id).then((state) => {
if (done) return;
unsubscribe = state.subscribe((newState) => {
console.log(
"Got update",
id,
newState.toJSON(),
newState.coValue.sessions
);
setState(newState as T);
localNode
.load(id)
.then((state) => {
if (done) return;
unsubscribe = state.subscribe((newState) => {
// console.log(
// "Got update",
// id,
// newState.toJSON(),
// );
setState(newState as T);
});
})
.catch((e) => {
console.log("Failed to load", id, e);
});
});
return () => {
done = true;
@@ -217,75 +123,22 @@ export function useTelepathicState<T extends ContentType>(id: CoID<T>) {
return state;
}
function websocketReadableStream<T>(ws: WebSocket) {
ws.binaryType = "arraybuffer";
export function useProfile<P extends ProfileContent = ProfileContent>({
accountID,
}: {
accountID?: AccountID;
}): (Profile & CoMap<P>) | undefined {
const [profileID, setProfileID] = useState<CoID<Profile & CoMap<P>>>();
return new ReadableStream<T>({
start(controller) {
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "ping") {
console.debug(
"Got ping from",
msg.dc,
"latency",
Date.now() - msg.time,
"ms"
);
return;
}
controller.enqueue(msg);
};
ws.onclose = () => controller.close();
ws.onerror = () =>
controller.error(new Error("The WebSocket errored!"));
},
const { localNode } = useJazz();
cancel() {
ws.close();
},
});
}
function websocketWritableStream<T>(ws: WebSocket) {
return new WritableStream<T>({
start(controller) {
ws.onerror = () => {
controller.error(new Error("The WebSocket errored!"));
ws.onclose = null;
};
ws.onclose = () =>
controller.error(
new Error("The server closed the connection unexpectedly!")
);
return new Promise((resolve) => (ws.onopen = resolve));
},
write(chunk) {
ws.send(JSON.stringify(chunk));
// Return immediately, since the web socket gives us no easy way to tell
// when the write completes.
},
close() {
return closeWS(1000);
},
abort(reason) {
return closeWS(4000, reason && reason.message);
},
});
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.close(code, reasonString);
});
}
useEffect(() => {
accountID &&
localNode
.loadProfile(accountID)
.then((profile) => setProfileID(profile.id as typeof profileID))
.catch((e) => console.log("Failed to load profile", e));
}, [localNode, accountID]);
return useTelepathicState(profileID);
}

View File

@@ -2,18 +2,13 @@
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"module": "esnext",
"target": "esnext",
"target": "ES2020",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "preserve",
"allowSyntheticDefaultImports": true,
"jsx": "react",
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"noEmit": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
},

View File

@@ -6,13 +6,12 @@ module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
project: './tsconfig.json',
project: "./tsconfig.json",
},
root: true,
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/no-floating-promises": "error",
// "@typescript-eslint/no-floating-promises": "error",
},
};
};

View File

@@ -0,0 +1,2 @@
coverage
node_modules

View File

@@ -1,11 +1,11 @@
{
"name": "jazz-storage-indexeddb",
"version": "0.0.3",
"main": "src/index.ts",
"types": "src/index.ts",
"version": "0.1.2",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.0.16",
"cojson": "^0.1.2",
"typescript": "^5.1.6"
},
"devDependencies": {
@@ -14,6 +14,9 @@
"webdriverio": "^8.15.0"
},
"scripts": {
"test": "vitest --browser chrome"
"test": "vitest --browser chrome",
"lint": "eslint src/**/*.ts",
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
"prepublishOnly": "npm run build"
}
}

View File

@@ -1,22 +1,66 @@
import { expect, test } from 'vitest'
import { LocalNode, } from 'cojson';
import { getAgentID, newRandomAgentSecret } from 'cojson/src/crypto';
import { newRandomSessionID } from 'cojson/src/coValue';
import { AnonymousControlledAccount } from 'cojson/src/account';
import { IDBStorage } from '.';
import { expect, test } from "vitest";
import { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
import { IDBStorage } from ".";
test("Should be able to initialize and load from empty DB", async () => {
const agentSecret = newRandomAgentSecret();
test.skip("Should be able to initialize and load from empty DB", async () => {
const agentSecret = cojsonInternals.newRandomAgentSecret();
const node = new LocalNode(new AnonymousControlledAccount(agentSecret), newRandomSessionID(getAgentID(agentSecret)));
const node = new LocalNode(
new AnonymousControlledAccount(agentSecret),
cojsonInternals.newRandomSessionID(
cojsonInternals.getAgentID(agentSecret)
)
);
await IDBStorage.connectTo(node, {trace: true});
node.sync.addPeer(await IDBStorage.asPeer({ trace: true }));
console.log("yay!");
const team = node.createTeam();
const _group = node.createGroup();
await new Promise(resolve => setTimeout(resolve, 200));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(node.sync.peers["storage"]).toBeDefined();
});
});
test("Should be able to sync data to database and then load that from a new node", async () => {
const agentSecret = cojsonInternals.newRandomAgentSecret();
const node1 = new LocalNode(
new AnonymousControlledAccount(agentSecret),
cojsonInternals.newRandomSessionID(
cojsonInternals.getAgentID(agentSecret)
)
);
node1.sync.addPeer(
await IDBStorage.asPeer({ trace: true, localNodeName: "node1" })
);
console.log("yay!");
const group = node1.createGroup();
const map = group.createMap();
map.edit((m) => {
m.set("hello", "world");
});
await new Promise((resolve) => setTimeout(resolve, 200));
const node2 = new LocalNode(
new AnonymousControlledAccount(agentSecret),
cojsonInternals.newRandomSessionID(
cojsonInternals.getAgentID(agentSecret)
)
);
node2.sync.addPeer(
await IDBStorage.asPeer({ trace: true, localNodeName: "node2" })
);
const map2 = await node2.load(map.id);
expect(map2.get("hello")).toBe("world");
});

View File

@@ -1,41 +1,31 @@
import { cojsonInternals, SessionID, SyncMessage, Peer, CojsonInternalTypes } from "cojson";
import {
LocalNode,
internals as cojsonInternals,
SessionID,
ContentType,
SyncMessage,
JsonValue,
} from "cojson";
import { CoValueHeader, Transaction } from "cojson/src/coValue";
import { Signature } from "cojson/src/crypto";
import { RawCoID } from "cojson/src/ids";
import {
CoValueKnownState,
DoneMessage,
KnownStateMessage,
LoadMessage,
NewContentMessage,
} from "cojson/src/sync";
import { ReadableStream, WritableStream, ReadableStreamDefaultReader, WritableStreamDefaultWriter } from "isomorphic-streams";
ReadableStream,
WritableStream,
ReadableStreamDefaultReader,
WritableStreamDefaultWriter,
} from "isomorphic-streams";
type CoValueRow = {
id: RawCoID;
header: CoValueHeader;
id: CojsonInternalTypes.RawCoID;
header: CojsonInternalTypes.CoValueHeader;
};
type StoredCoValueRow = CoValueRow & { rowID: number };
type SessionRow = {
coValue: number;
sessionID: SessionID;
lastIdx: number;
lastSignature: Signature;
lastSignature: CojsonInternalTypes.Signature;
};
type StoredSessionRow = SessionRow & { rowID: number };
type TransactionRow = {
ses: SessionID;
ses: number;
idx: number;
tx: Transaction;
tx: CojsonInternalTypes.Transaction;
};
export class IDBStorage {
@@ -43,32 +33,54 @@ export class IDBStorage {
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
constructor(db: IDBDatabase, fromLocalNode: ReadableStream<SyncMessage>, toLocalNode: WritableStream<SyncMessage>) {
constructor(
db: IDBDatabase,
fromLocalNode: ReadableStream<SyncMessage>,
toLocalNode: WritableStream<SyncMessage>
) {
this.db = db;
this.fromLocalNode = fromLocalNode.getReader();
this.toLocalNode = toLocalNode.getWriter();
(async () => {
while (true) {
const { done, value } = await this.fromLocalNode.read();
if (done) {
break;
}
let done = false;
while (!done) {
const result = await this.fromLocalNode.read();
done = result.done;
this.handleSyncMessage(value);
if (result.value) {
this.handleSyncMessage(result.value);
}
}
})();
}
static async connectTo(localNode: LocalNode, {trace}: {trace?: boolean} = {}) {
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers("local", "storage", {peer1role: "client", peer2role: "server", trace});
static async asPeer(
{
trace,
localNodeName = "local",
}: { trace?: boolean; localNodeName?: string } | undefined = {
localNodeName: "local",
}
): Promise<Peer> {
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
localNodeName,
"storage",
{ peer1role: "client", peer2role: "server", trace }
);
await IDBStorage.open(localNodeAsPeer.incoming, localNodeAsPeer.outgoing);
await IDBStorage.open(
localNodeAsPeer.incoming,
localNodeAsPeer.outgoing
);
localNode.sync.addPeer(storageAsPeer);
return storageAsPeer;
}
static async open(fromLocalNode: ReadableStream<SyncMessage>, toLocalNode: WritableStream<SyncMessage>) {
static async open(
fromLocalNode: ReadableStream<SyncMessage>,
toLocalNode: WritableStream<SyncMessage>
) {
const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("jazz-storage", 1);
request.onerror = () => {
@@ -85,17 +97,23 @@ export class IDBStorage {
keyPath: "rowID",
});
coValues.createIndex("id", "id", {
coValues.createIndex("coValuesById", "id", {
unique: true,
});
const sessions = db.createObjectStore("sessions", {
keyPath: ["coValue", "sessionID"],
autoIncrement: true,
keyPath: "rowID",
});
sessions.createIndex("sessionID", "sessionID", {
unique: true,
});
sessions.createIndex("sessionsByCoValue", "coValue");
sessions.createIndex(
"uniqueSessions",
["coValue", "sessionID"],
{
unique: true,
}
);
db.createObjectStore("transactions", {
keyPath: ["ses", "idx"],
@@ -124,153 +142,277 @@ export class IDBStorage {
}
async sendNewContentAfter(
theirKnown: CoValueKnownState
theirKnown: CojsonInternalTypes.CoValueKnownState,
{
coValues,
sessions,
transactions,
}: {
coValues: IDBObjectStore;
sessions: IDBObjectStore;
transactions: IDBObjectStore;
},
asDependencyOf?: CojsonInternalTypes.RawCoID
) {
const tx = this.db.transaction(
["coValues", "sessions", "transactions"],
"readonly"
);
tx.onerror = () => {
throw(new Error("Error in transaction", { cause: tx.error }));
};
const coValues = tx.objectStore("coValues");
const sessions = tx.objectStore("sessions");
const transactions = tx.objectStore("transactions");
const header = await new Promise<CoValueHeader | undefined>(
(resolve) => {
if (theirKnown.header) {
resolve(undefined);
} else {
const headerRequest = coValues.get(theirKnown.id);
headerRequest.onsuccess = () => {
resolve(
(headerRequest.result as CoValueRow).header
);
};
}
}
);
const allOurSessions = await new Promise<StoredSessionRow[]>(
(resolve) => {
const allOurSessionsRequest = sessions
.index("sessionID")
.getAll(
IDBKeyRange.bound(
[theirKnown.id, "\u0000"],
[theirKnown.id, "\uffff"]
)
);
allOurSessionsRequest.onsuccess = () => {
resolve(
allOurSessionsRequest.result as StoredSessionRow[]
);
};
}
);
const ourKnown: CoValueKnownState = {
id: theirKnown.id,
header: !!header,
sessions: {},
};
let shouldTellOurKnown = (
Object.keys(theirKnown.sessions) as SessionID[]
).some((sessionID) => {
return !allOurSessions.some(
(row) => row.sessionID === sessionID
);
});
const newContent: NewContentMessage = {
action: "content",
id: theirKnown.id,
header,
new: {},
};
for (const sessionRow of allOurSessions) {
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
if (
sessionRow.lastIdx <
(theirKnown.sessions[sessionRow.sessionID] || 0)
) {
shouldTellOurKnown = true;
} else if (
sessionRow.lastIdx >
(theirKnown.sessions[sessionRow.sessionID] || 0)
) {
const firstNewTxIdx =
theirKnown.sessions[sessionRow.sessionID] || 0;
const newTxInSession = await new Promise<TransactionRow[]>(
(resolve) => {
const newTxRequest = transactions.getAll(
IDBKeyRange.bound(
[sessionRow.rowID, firstNewTxIdx],
[sessionRow.rowID, Infinity]
)
);
newTxRequest.onsuccess = () => {
resolve(
newTxRequest.result as TransactionRow[]
);
};
}
);
newContent.new[sessionRow.sessionID] = {
after: firstNewTxIdx,
lastSignature: sessionRow.lastSignature,
newTransactions: newTxInSession.map((row) => row.tx),
};
}
}
if (shouldTellOurKnown) {
await this.toLocalNode.write({
action: "known",
...ourKnown,
});
}
if (newContent.header || Object.keys(newContent.new).length > 0) {
await this.toLocalNode.write(newContent);
}
}
handleLoad(msg: LoadMessage) {
return this.sendNewContentAfter(msg);
}
handleContent(msg: NewContentMessage) {
const tx = this.db.transaction(
["coValues", "sessions", "transactions"],
"readwrite"
const coValueRow = await promised<StoredCoValueRow | undefined>(
coValues.index("coValuesById").get(theirKnown.id)
);
tx.onerror = () => {
throw(new Error("Error in transaction", { cause: tx.error }));
const allOurSessions = coValueRow
? await promised<StoredSessionRow[]>(
sessions.index("sessionsByCoValue").getAll(coValueRow.rowID)
)
: [];
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
id: theirKnown.id,
header: !!coValueRow,
sessions: {},
};
const newContent: CojsonInternalTypes.NewContentMessage = {
action: "content",
id: theirKnown.id,
header: theirKnown.header ? undefined : coValueRow?.header,
new: {},
};
for (const sessionRow of allOurSessions) {
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
if (
sessionRow.lastIdx >
(theirKnown.sessions[sessionRow.sessionID] || 0)
) {
const firstNewTxIdx =
theirKnown.sessions[sessionRow.sessionID] || 0;
const newTxInSession = await promised<TransactionRow[]>(
transactions.getAll(
IDBKeyRange.bound(
[sessionRow.rowID, firstNewTxIdx],
[sessionRow.rowID, Infinity]
)
)
);
newContent.new[sessionRow.sessionID] = {
after: firstNewTxIdx,
lastSignature: sessionRow.lastSignature,
newTransactions: newTxInSession.map((row) => row.tx),
};
}
}
const dependedOnCoValues =
coValueRow?.header.ruleset.type === "group"
? Object.values(newContent.new).flatMap((sessionEntry) =>
sessionEntry.newTransactions.flatMap((tx) => {
if (tx.privacy !== "trusting") return [];
return tx.changes
.map(
(change) =>
change &&
typeof change === "object" &&
"op" in change &&
change.op === "set" &&
"key" in change &&
change.key
)
.filter(
(key): key is CojsonInternalTypes.RawCoID =>
typeof key === "string" &&
key.startsWith("co_")
);
})
)
: coValueRow?.header.ruleset.type === "ownedByGroup"
? [coValueRow?.header.ruleset.group]
: [];
for (const dependedOnCoValue of dependedOnCoValues) {
await this.sendNewContentAfter(
{ id: dependedOnCoValue, header: false, sessions: {} },
{ coValues, sessions, transactions },
asDependencyOf || theirKnown.id
);
}
await this.toLocalNode.write({
action: "known",
...ourKnown,
asDependencyOf,
});
if (newContent.header || Object.keys(newContent.new).length > 0) {
await this.toLocalNode.write(newContent);
}
}
handleLoad(msg: CojsonInternalTypes.LoadMessage) {
return this.sendNewContentAfter(msg, this.inTransaction("readonly"));
}
async handleContent(msg: CojsonInternalTypes.NewContentMessage) {
const { coValues, sessions, transactions } =
this.inTransaction("readwrite");
let storedCoValueRowID = (
await promised<StoredCoValueRow | undefined>(
coValues.index("coValuesById").get(msg.id)
)
)?.rowID;
if (storedCoValueRowID === undefined) {
const header = msg.header;
if (!header) {
console.error("Expected to be sent header first");
await this.toLocalNode.write({
action: "known",
id: msg.id,
header: false,
sessions: {},
isCorrection: true,
});
return;
}
storedCoValueRowID = (await promised<IDBValidKey>(
coValues.put({
id: msg.id,
header: header,
} satisfies CoValueRow)
)) as number;
}
const allOurSessions = await new Promise<{
[sessionID: SessionID]: StoredSessionRow;
}>((resolve) => {
const allOurSessionsRequest = sessions
.index("sessionsByCoValue")
.getAll(storedCoValueRowID);
allOurSessionsRequest.onsuccess = () => {
resolve(
Object.fromEntries(
(
allOurSessionsRequest.result as StoredSessionRow[]
).map((row) => [row.sessionID, row])
)
);
};
});
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
id: msg.id,
header: true,
sessions: {},
};
let invalidAssumptions = false;
for (const sessionID of Object.keys(msg.new) as SessionID[]) {
const sessionRow = allOurSessions[sessionID];
if (sessionRow) {
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
}
if ((sessionRow?.lastIdx || 0) < (msg.new[sessionID]?.after || 0)) {
invalidAssumptions = true;
} else {
const newTransactions =
msg.new[sessionID]?.newTransactions || [];
const actuallyNewOffset =
(sessionRow?.lastIdx || 0) -
(msg.new[sessionID]?.after || 0);
const actuallyNewTransactions =
newTransactions.slice(actuallyNewOffset);
let nextIdx = sessionRow?.lastIdx || 0;
const sessionUpdate = {
coValue: storedCoValueRowID,
sessionID: sessionID,
lastIdx:
(sessionRow?.lastIdx || 0) +
actuallyNewTransactions.length,
lastSignature: msg.new[sessionID]!.lastSignature,
};
const sessionRowID = (await promised(
sessions.put(
sessionRow?.rowID
? {
rowID: sessionRow.rowID,
...sessionUpdate,
}
: sessionUpdate
)
)) as number;
for (const newTransaction of actuallyNewTransactions) {
nextIdx++;
await promised(
transactions.add({
ses: sessionRowID,
idx: nextIdx,
tx: newTransaction,
} satisfies TransactionRow)
);
}
}
}
if (invalidAssumptions) {
await this.toLocalNode.write({
action: "known",
...ourKnown,
isCorrection: invalidAssumptions,
});
}
}
handleKnown(msg: CojsonInternalTypes.KnownStateMessage) {
return this.sendNewContentAfter(msg, this.inTransaction("readonly"));
}
handleDone(_msg: CojsonInternalTypes.DoneMessage) {}
inTransaction(mode: "readwrite" | "readonly"): {
coValues: IDBObjectStore;
sessions: IDBObjectStore;
transactions: IDBObjectStore;
} {
const tx = this.db.transaction(
["coValues", "sessions", "transactions"],
mode
);
tx.onerror = (event) => {
const target = event.target as unknown as {
error: DOMException;
source?: { name: string };
} | null;
throw new Error(
`Error in transaction (${target?.source?.name}): ${target?.error}`,
{ cause: target?.error }
);
};
const coValues = tx.objectStore("coValues");
const sessions = tx.objectStore("sessions");
const transactions = tx.objectStore("transactions");
return { coValues, sessions, transactions };
}
handleKnown(msg: KnownStateMessage) {
return this.sendNewContentAfter(msg);
}
handleDone(msg: DoneMessage) {}
}
function promised<T>(request: IDBRequest<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
});
}

View File

@@ -2,18 +2,12 @@
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"module": "esnext",
"target": "esnext",
"target": "ES2020",
"moduleResolution": "bundler",
"moduleDetection": "force",
"allowImportingTsExtensions": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "preserve",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"noEmit": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
},

View File

@@ -1175,6 +1175,17 @@
"@radix-ui/react-use-previous" "1.0.1"
"@radix-ui/react-use-size" "1.0.1"
"@radix-ui/react-collection@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"
integrity sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-compose-refs@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
@@ -1189,6 +1200,26 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-dismissable-layer@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz#883a48f5f938fa679427aa17fcba70c5494c6978"
integrity sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown" "1.0.3"
"@radix-ui/react-portal@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.3.tgz#ffb961244c8ed1b46f039e6c215a6c4d9989bda1"
integrity sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-presence@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba"
@@ -1214,6 +1245,25 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-toast@^1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.1.4.tgz#9a7fc2d71700886f3292f7699c905f1e01be59e1"
integrity sha512-wf+fc8DOywrpRK3jlPlWVe+ELYGHdKDaaARJZNuUTWyWYq7+ANCFLp4rTjZ/mcGkJJQ/vZ949Zis9xxEpfq9OA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-collection" "1.0.3"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.4"
"@radix-ui/react-portal" "1.0.3"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-visually-hidden" "1.0.3"
"@radix-ui/react-use-callback-ref@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
@@ -1229,6 +1279,14 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755"
integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-layout-effect@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399"
@@ -1251,6 +1309,14 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-visually-hidden@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz#51aed9dd0fe5abcad7dee2a234ad36106a6984ac"
integrity sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@rollup/pluginutils@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
@@ -1574,7 +1640,7 @@
resolved "https://registry.yarnpkg.com/@types/which/-/which-2.0.2.tgz#54541d02d6b1daee5ec01ac0d1b37cecf37db1ae"
integrity sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==
"@types/ws@^8.5.3":
"@types/ws@^8.5.3", "@types/ws@^8.5.5":
version "8.5.5"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.5.tgz#af587964aa06682702ee6dcbc7be41a80e4b28eb"
integrity sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==
@@ -7678,6 +7744,11 @@ unbzip2-stream@1.4.3:
buffer "^5.2.1"
through "^2.3.8"
uniqolor@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/uniqolor/-/uniqolor-1.1.0.tgz#7519f81133cd54a1f4a59c33c81dbe04a3ad155d"
integrity sha512-j2XyokF24fsj+L5u6fbu4rM3RQc6VWJuAngYM2k0ZdG3yiVxt0smLkps2GmQIYqK8VkELGdM9vFU/HfOkK/zoQ==
unique-filename@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea"
@@ -8043,7 +8114,7 @@ write-pkg@4.0.0:
type-fest "^0.4.1"
write-json-file "^3.2.0"
ws@8.13.0, ws@^8.8.0:
ws@8.13.0, ws@^8.13.0, ws@^8.8.0:
version "8.13.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0"
integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==