Compare commits

..

9 Commits

Author SHA1 Message Date
Anselm
5dfd74d7f5 Publish
- jazz-example-todo@0.0.5
 - jazz-react@0.0.11
 - jazz-react-auth-local@0.0.8
2023-08-17 14:02:50 +01:00
Anselm
5fbd598cfe Fix missing dependency 2023-08-17 14:01:22 +01:00
Anselm
d54b53737d Try removing awareness of workspace 2023-08-17 13:59:35 +01:00
Anselm
0f0cd08c36 Try removing local yarn lock 2023-08-17 13:51:11 +01:00
Anselm
4bb3834333 Try newer node version 2023-08-17 13:45:31 +01:00
Anselm
b9007dd1b5 Publish
- jazz-example-todo@0.0.4
 - cojson@0.0.18
 - jazz-react@0.0.10
 - jazz-react-auth-local@0.0.7
 - jazz-storage-indexeddb@0.0.5
2023-08-17 13:43:46 +01:00
Anselm
ee1e5b06e4 Fix bundling of packages 2023-08-17 13:43:30 +01:00
Anselm
fae290c4cf Add exports to cojson 2023-08-17 12:36:58 +01:00
Anselm
381d68019f Try deploying jazz-example 2023-08-17 12:33:28 +01:00
113 changed files with 2631 additions and 17333 deletions

View File

@@ -71,7 +71,11 @@ jobs:
export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
export DOCKER_TAG=${{ env.DOCKER_TAG }};
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN}' < job-template.nomad > job-instance.nomad;
cat job-instance.nomad;
NOMAD_ADDR='http://control1v2-london:4646' nomad job run job-instance.nomad;
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

3
.gitignore vendored
View File

@@ -1,4 +1,3 @@
node_modules
yarn-error.log
lerna-debug.log
docsTmp
lerna-debug.log

View File

@@ -1,3 +0,0 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

3271
DOCS.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +1,9 @@
# Jazz - instant sync
Homepage: [jazz.tools](https://jazz.tools) &mdash; [Discord](https://discord.gg/utDMjHYg42)
Jazz is an open-source toolkit for telepathic data.
Jazz is an open-source toolkit for *secure telepathic data.*
Ship faster and simplify frontend, backend & devops by building with Telepathic Data.
Get real-time multiplayer and cross-device sync for free.
- 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 Secure Telepathic Data?
**Telepathic** means:
- **Read and write data as if it was local,** from anywhere in your app.
- **Always have that data synced, instantly.** Across devices of the same user &mdash; or to other users (coming soon: to your backend, workers, etc.)
**Secure** 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.
# Documentation
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`** → [DOCS](./DOCS.md#cojson)
A library implementing abstractions and protocols for "Collaborative JSON". This will soon be standardized and forms the basis of secure telepathic data.
**`jazz-react`** → [DOCS](./DOCS.md#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`** → [DOCS](./DOCS.md#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>
## What is Telepathic Data?
...

View File

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

View File

@@ -1,65 +1,27 @@
# Jazz Todo List Example
# React + TypeScript + Vite
Live version: https://example-todo.jazz.tools
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Installing & running the example locally
Currently, two official plugins are available:
Start by checking out just the example app to a folder:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
```bash
npx degit gardencmp/jazz/examples/todo jazz-example-todo
cd jazz-example-todo
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
```
(This ensures that you have the example app without git history or our multi-package monorepo)
Install dependencies:
```bash
npm install
```
Start the dev server:
```bash
npm run dev
```
## Structure
- [`src/basicComponents`](./src/basicComponents) contains simple components to build the UI, unrelated to Jazz (powered by [shadcn/ui](https://ui.shadcn.com))
- [`src/components`](./src/components/) contains helper components that do contain Jazz-specific logic, but are not super relevant to understand the basics of Jazz and CoJSON
- [`src/0_main.tsx`](./src/0_main.tsx), [`src/1_types.ts`](./src/1_types.ts), [`src/2_App.tsx`](./src/2_App.tsx), [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx), [`src/router.ts`](./src/router.ts) - the main files for this example, see the walkthrough below
## Walkthrough
### Main parts
- The top-level provider `<WithJazz/>`: [`src/0_main.tsx`](./src/0_main.tsx)
- Defining the data model with CoJSON: [`src/1_types.ts`](./src/1_types.ts)
- Creating todo projects & routing in `<App/>`: [`src/2_App.tsx`](./src/2_App.tsx)
- Reactively rendering a todo project as a table, adding and editing tasks: [`src/3_TodoTable.tsx`](./src/3_TodoTable.tsx)
### Helpers
- Getting user profiles in `<NameBadge/>`: [`src/components/NameBadge.tsx`](./src/components/NameBadge.tsx)
- (not yet commented) Creating invite links/QR codes with `<InviteButton/>`: [`src/components/InviteButton.tsx`](./src/components/InviteButton.tsx)
- (not yet commented) `location.hash`-based routing and accepting invite links with `useSimpleHashRouterThatAcceptsInvites()` in [`src/router.ts`](./src/router.ts)
This is the whole Todo List app!
## Questions / problems / feedback
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
## Configuration: sync server
By default, the example app uses [Jazz Global Mesh](https://jazz.tools/mesh) (`wss://sync.jazz.tools`) - so cross-device use, invites and collaboration should just work.
You can also run a local sync server by running `npx cojson-simple-sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?sync=ws://localhost:4200`), or by setting the `sync` parameter of the `<WithJazz>` provider component in [./src/0_main.tsx](./src/0_main.tsx).
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

View File

@@ -0,0 +1,76 @@
@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

@@ -10,7 +10,7 @@
"cssVariables": true
},
"aliases": {
"components": "@/basicComponents",
"utils": "@/basicComponents/lib/utils"
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

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

View File

@@ -1,9 +1,9 @@
job "example-todo$BRANCH_SUFFIX" {
region = "global"
datacenters = ["*"]
region = "$REGION"
datacenters = ["$REGION"]
group "static" {
count = 8
// count = 3
network {
port "http" {
@@ -14,17 +14,13 @@ job "example-todo$BRANCH_SUFFIX" {
constraint {
attribute = "${node.class}"
operator = "="
value = "mesh"
value = "edge"
}
spread {
attribute = "${node.datacenter}"
weight = 100
}
constraint {
distinct_hosts = true
}
// spread {
// attribute = "${node.datacenter}"
// weight = 100
// }
task "server" {
driver = "docker"
@@ -41,7 +37,9 @@ job "example-todo$BRANCH_SUFFIX" {
service {
tags = ["public"]
name = "example-todo$BRANCH_SUFFIX"
meta {
public_name = "${BRANCH_SUBDOMAIN}example-todo"
}
port = "http"
provider = "consul"
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,38 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { WithJazz } from "jazz-react";
import { LocalAuth } from "jazz-react-auth-local";
import { ThemeProvider, TitleAndLogo } from "./basicComponents/index.ts";
import { PrettyAuthUI } from "./components/Auth.tsx";
import App from "./2_App.tsx";
/** Walkthrough: The top-level provider `<WithJazz/>`
*
* This shows how to use the top-level provider `<WithJazz/>`,
* which provides the rest of the app with a `LocalNode` (used through `useJazz` later),
* based on `LocalAuth` that uses PassKeys (aka WebAuthn) to store a user's account secret
* - no backend needed. */
const appName = "Jazz Todo List Example";
const auth = LocalAuth({
appName,
Component: PrettyAuthUI,
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ThemeProvider>
<TitleAndLogo name={appName} />
<WithJazz auth={auth}>
<App />
</WithJazz>
</ThemeProvider>
</React.StrictMode>
);
/** Walkthrough: Continue with ./1_types.ts */

View File

@@ -1,28 +0,0 @@
import { CoMap, CoList, CoID } from "cojson";
/** Walkthrough: Defining the data model with CoJSON
*
* Here, we define our main data model of tasks, lists of tasks and projects
* using CoJSON's collaborative map and list types, CoMap & CoList.
*
* CoMap values and CoLists items can be:
* - arbitrary immutable JSON
* - references to other CoValues by their CoID
* - CoIDs are strings that look like `co_zXPuWmH1D1cKdMpDW6CMzWb3LpY`
* - In TypeScript, CoIDs take a generic parameter for the type of the
* referenced CoValue, e.g. `CoID<Task>` - to make the references precise
**/
/** An individual task which collaborators can tick or rename */
export type Task = CoMap<{ done: boolean; text: string; }>;
/** A collaborative, ordered list of task references */
export type ListOfTasks = CoList<CoID<Task>>;
/** Our top level object: a project with a title, referencing a list of tasks */
export type TodoProject = CoMap<{
title: string;
tasks: CoID<ListOfTasks>;
}>;
/** Walkthrough: Continue with ./2_App.tsx */

View File

@@ -1,78 +0,0 @@
import { useCallback } from "react";
import { useJazz } from "jazz-react";
import { TodoProject, ListOfTasks } from "./1_types";
import { SubmittableInput, Button } from "./basicComponents";
import { useSimpleHashRouterThatAcceptsInvites } from "./router";
import { TodoTable } from "./3_TodoTable";
/** Walkthrough: Creating todo projects & routing in `<App/>`
*
* <App> is the main app component, handling client-side routing based
* on the CoValue ID (CoID) of our TodoProject, stored in the URL hash
* - which can also contain invite links.
*/
export default function App() {
// A `LocalNode` represents a local view of loaded & created CoValues.
// It is associated with a current user account, which will determine
// access rights to CoValues. We get it from the top-level provider `<WithJazz/>`.
const { localNode, logOut } = useJazz();
// This sets up routing and accepting invites, skip for now
const [currentProjectId, navigateToProjectId] =
useSimpleHashRouterThatAcceptsInvites<TodoProject>(localNode);
const createProject = useCallback(
(title: string) => {
if (!title) return;
// To create a new todo project, we first create a `Group`,
// which is a scope for defining access rights (reader/writer/admin)
// of its members, which will apply to all CoValues owned by that group.
const projectGroup = localNode.createGroup();
// Then we create an empty todo project and list of tasks within that group.
const project = projectGroup.createMap<TodoProject>();
const tasks = projectGroup.createList<ListOfTasks>();
// We edit the todo project to initialise it.
// Inside the `.edit` callback we can mutate a CoValue
project.edit((project) => {
project.set("title", title);
project.set("tasks", tasks.id);
});
navigateToProjectId(project.id);
},
[localNode, navigateToProjectId]
);
return (
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
{currentProjectId ? (
<TodoTable projectId={currentProjectId} />
) : (
<SubmittableInput
onSubmit={createProject}
label="Create New Project"
placeholder="New project title"
/>
)}
<Button
onClick={() => {
navigateToProjectId(undefined);
logOut();
}}
variant="outline"
>
Log Out
</Button>
</div>
);
}
/** Walkthrough: continue with ./3_TodoTable.tsx */

View File

@@ -1,162 +0,0 @@
import { useCallback } from "react";
import { CoID } from "cojson";
import { useTelepathicState } from "jazz-react";
import { TodoProject, Task } from "./1_types";
import {
Checkbox,
SubmittableInput,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "./basicComponents";
import { InviteButton } from "./components/InviteButton";
import { NameBadge } from "./components/NameBadge";
/** Walkthrough: Reactively rendering a todo project as a table,
* adding and editing tasks
*
* Here in `<TodoTable/>`, we use `useTelepathicData()` for the first time,
* in this case to load the CoValue for our `TodoProject` as well as
* the `ListOfTasks` referenced in it.
*/
export function TodoTable({ projectId }: { projectId: CoID<TodoProject> }) {
// `useTelepathicData()` reactively subscribes to updates to a CoValue's
// content - whether we create edits locally, load persisted data, or receive
// sync updates from other devices or participants!
const project = useTelepathicState(projectId);
const projectTasks = useTelepathicState(project?.get("tasks"));
// `createTask` is similar to `createProject` we saw earlier, creating a new CoMap
// for a new task (in the same group as the list of tasks/the project), and then
// adding it as an item to the project's list of tasks.
const createTask = useCallback(
(text: string) => {
if (!projectTasks || !text) return;
const task = projectTasks.group.createMap<Task>();
task.edit((task) => {
task.set("text", text);
task.set("done", false);
});
projectTasks.edit((projectTasks) => {
projectTasks.push(task.id);
});
},
[projectTasks]
);
return (
<div className="max-w-full w-4xl">
<div className="flex justify-between items-center gap-4 mb-4">
<h1>
{
// This is how we can access properties from the project,
// accounting for the fact that it might not be loaded yet
project?.get("title") ? (
<>
{project.get("title")}{" "}
<span className="text-sm">({project.id})</span>
</>
) : (
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
)
}
</h1>
<InviteButton list={project} />
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">Done</TableHead>
<TableHead>Task</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{
// Here, we iterate over the items of our `ListOfTasks`
// and render a `<TaskRow>` for each.
projectTasks?.map((taskId: CoID<Task>) => (
<TaskRow key={taskId} taskId={taskId} />
))
}
<NewTaskInputRow
createTask={createTask}
disabled={!project}
/>
</TableBody>
</Table>
</div>
);
}
export function TaskRow({ taskId }: { taskId: CoID<Task> }) {
// `<TaskRow/>` uses `useTelepathicState()` as well, to granularly load and
// subscribe to changes for that particular task.
const task = useTelepathicState(taskId);
return (
<TableRow>
<TableCell>
<Checkbox
className="mt-1"
checked={task?.get("done")}
onCheckedChange={(checked) => {
// (the only thing we let the user change is the "done" status)
task?.edit((task) => {
task.set("done", !!checked);
});
}}
/>
</TableCell>
<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>
{/* We also use a `<NameBadge/>` helper component to render the name
of the author of the task. We get the author by using the collaboration
feature `whoEdited(key)` on our `Task` CoMap, which returns the accountID
of the last account that changed a given key in the CoMap. */}
<NameBadge accountID={task?.whoEdited("text")} />
</div>
</TableCell>
</TableRow>
);
}
function NewTaskInputRow({
createTask,
disabled,
}: {
createTask: (text: string) => void;
disabled: boolean;
}) {
return (
<TableRow>
<TableCell>
<Checkbox className="mt-1" disabled />
</TableCell>
<TableCell>
<SubmittableInput
onSubmit={(taskText) => createTask(taskText)}
label="Add"
placeholder="New task"
disabled={disabled}
/>
</TableCell>
</TableRow>
);
}

158
examples/todo/src/App.tsx Normal file
View File

@@ -0,0 +1,158 @@
import {
Table,
TableBody,
TableCell,
TableHead,
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";
type TaskContent = { done: boolean; text: string };
type Task = CoMap<TaskContent>;
type TodoListContent = { title: string; [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 { localNode } = useJazz();
const createList = () => {
const listTeam = localNode.createTeam();
const list = listTeam.createMap<TodoListContent>();
list.edit((list) => {
list.set("title", "My Todo List");
});
window.location.hash = list.id;
};
useEffect(() => {
const listener = () => {
setListId(window.location.hash.slice(1) as CoID<TodoList>);
}
window.addEventListener("hashchange", listener);
return () => {
window.removeEventListener("hashchange", listener);
}
}, [])
return (
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 md:pt-[30vh] pb-10">
{listId && <TodoList listId={listId} />}
<Button onClick={createList}>Create New List</Button>
</div>
);
}
export function TodoList({ listId }: { listId: CoID<TodoList> }) {
const list = useTelepathicState(listId);
const createTodo = (text: string) => {
if (!list) return;
let task = list.coValue.getTeam().createMap<TaskContent>();
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.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>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">Done</TableHead>
<TableHead>Task</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list &&
list
.keys()
.filter((key): key is CoID<Task> =>
key.startsWith("co_")
)
.map((taskId) => (
<TodoRow 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>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
);
}
function TodoRow({ taskId }: { taskId: CoID<Task> }) {
const task = useTelepathicState(taskId);
return (
<TableRow>
<TableCell>
<Checkbox
className="mt-1"
checked={task?.get("done")}
onCheckedChange={(checked) => {
task?.edit((task) => {
task.set("done", !!checked);
});
}}
/>
</TableCell>
<TableCell className={task?.get("done") ? "line-through" : ""}>
{task?.get("text")}
</TableCell>
</TableRow>
);
}
export default App;

View File

@@ -0,0 +1,30 @@
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

@@ -1,39 +0,0 @@
import { Input } from "@/basicComponents/ui/input";
import { Button } from "@/basicComponents/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

@@ -1,10 +0,0 @@
import { Toaster } from ".";
export function TitleAndLogo({name}: {name: string}) {
return <>
<div className="flex items-center gap-2 justify-center mt-5">
<img src="jazz-logo.png" className="h-5" /> {name}
</div>
<Toaster />
</>
}

View File

@@ -1,17 +0,0 @@
export { Button } from "./ui/button";
export { Checkbox } from "./ui/checkbox";
export { Input } from "./ui/input";
export { Skeleton } from "./ui/skeleton";
export { Toaster } from "./ui/toaster";
export { useToast } from "./ui/use-toast";
export { SubmittableInput } from "./SubmittableInput";
export { TitleAndLogo } from "./TitleAndLogo";
export { ThemeProvider } from "./themeProvider";
export {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "./ui/table";

View File

@@ -1,72 +0,0 @@
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

@@ -1,15 +0,0 @@
import { cn } from "@/basicComponents/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

@@ -1,127 +0,0 @@
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 "@/basicComponents/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

@@ -1,33 +0,0 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/basicComponents/ui/toast"
import { useToast } from "@/basicComponents/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

@@ -1,192 +0,0 @@
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/basicComponents/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

@@ -1,48 +0,0 @@
import { useState } from "react";
import { LocalAuthComponent } from "jazz-react-auth-local";
import { Input, Button } from "../basicComponents";
export const PrettyAuthUI: 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

@@ -1,46 +0,0 @@
import { useState } from "react";
import { TodoProject } from "../1_types";
import { createInviteLink } from "jazz-react";
import QRCode from "qrcode";
import { useToast, Button } from "../basicComponents";
export function InviteButton({ list }: { list?: TodoProject }) {
const [existingInviteLink, setExistingInviteLink] = useState<string>();
const { toast } = useToast();
return (
list?.group.myRole() === "admin" && (
<Button
size="sm"
className="py-0"
disabled={!list}
variant="outline"
onClick={async () => {
let inviteLink = existingInviteLink;
if (list && !inviteLink) {
inviteLink = createInviteLink(list, "writer");
setExistingInviteLink(inviteLink);
}
if (inviteLink) {
const qr = await QRCode.toDataURL(inviteLink, {
errorCorrectionLevel: "L",
});
navigator.clipboard.writeText(inviteLink).then(() =>
toast({
title: "Copied invite link to clipboard!",
description: (
<img src={qr} className="w-20 h-20" />
),
})
);
}
}}
>
Invite
</Button>
)
);
}

View File

@@ -1,46 +0,0 @@
import { AccountID } from "cojson";
import { useProfile } from "jazz-react";
import { Skeleton } from "@/basicComponents";
import uniqolor from "uniqolor";
/** Walkthrough: Getting user profiles in `<NameBadge/>`
*
* `<NameBadge/>` uses `useProfile(accountID)`, which is a shorthand for
* useTelepathicState on an account's profile.
*
* Profiles are always a `CoMap<{name: string}>`, but they might have app-specific
* additional properties).
*
* In our case, we just display the profile name (which is set by the LocalAuth
* provider when we first create an account).
*/
export function NameBadge({ accountID }: { accountID?: AccountID }) {
const profile = useProfile(accountID);
return accountID && profile?.get("name") ? (
<span
className="rounded-full py-0.5 px-2 text-xs"
style={randomUserColor(accountID)}
>
{profile.get("name")}
</span>
) : (
<Skeleton className="mt-1 w-[50px] h-[1em] rounded-full" />
);
}
function randomUserColor(accountID: 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 {
color: theme == "light" ? darkColor : brightColor,
background: theme == "light" ? brightColor : darkColor,
};
}

View File

@@ -2,7 +2,7 @@ import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/basicComponents/lib/utils"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",

View File

@@ -2,7 +2,7 @@ import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/basicComponents/lib/utils"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { cn } from "@/basicComponents/lib/utils"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { cn } from "@/basicComponents/lib/utils"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,

View File

@@ -1,7 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root, body, #root {
width: 100%;
height: 100%;
}
@layer base {
:root {
--background: 0 0% 100%;
@@ -9,63 +14,63 @@
--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;

View File

@@ -0,0 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { WithJazz } from "jazz-react";
import { LocalAuth } from "./LocalAuth.tsx";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<WithJazz auth={LocalAuth} syncAddress={new URLSearchParams(window.location.search).get("sync") || undefined}>
<App />
</WithJazz>
</React.StrictMode>
);

View File

@@ -1,37 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { CoID, LocalNode, CoValueImpl } from "cojson";
import { consumeInviteLinkFromWindowLocation } from "jazz-react";
export function useSimpleHashRouterThatAcceptsInvites<C extends CoValueImpl>(
localNode: LocalNode
) {
const [currentValueId, setCurrentValueId] = useState<CoID<C>>();
useEffect(() => {
const listener = async () => {
const acceptedInvitation = await consumeInviteLinkFromWindowLocation<C>(localNode);
if (acceptedInvitation) {
setCurrentValueId(acceptedInvitation.valueID);
window.location.hash = acceptedInvitation.valueID;
return;
}
setCurrentValueId(
(window.location.hash.slice(1) as CoID<C>) || undefined
);
};
window.addEventListener("hashchange", listener);
listener();
return () => {
window.removeEventListener("hashchange", listener);
};
}, [localNode]);
const navigateToValue = useCallback((id: CoID<C> | undefined) => {
window.location.hash = id || "";
}, []);
return [currentValueId, navigateToValue] as const;
}

View File

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

View File

@@ -1,441 +0,0 @@
import { readFile, writeFile } from "fs/promises";
import { Application, JSONOutput } from "typedoc";
const manuallyIgnore = new Set(["CojsonInternalTypes"]);
async function main() {
// Application.bootstrap also exists, which will not load plugins
// Also accepts an array of option readers if you want to disable
// TypeDoc's tsconfig.json/package.json/typedoc.json option readers
const packageDocs = Object.entries({
cojson: "index.ts",
"jazz-react": "index.tsx",
"jazz-browser": "index.ts",
}).map(async ([packageName, entryPoint]) => {
const app = await Application.bootstrapWithPlugins({
entryPoints: [`packages/${packageName}/src/${entryPoint}`],
tsconfig: `packages/${packageName}/tsconfig.json`,
sort: ["required-first"],
});
const project = await app.convert();
if (!project) {
throw new Error("Failed to convert project" + packageName);
}
// Alternatively generate JSON output
await app.generateJson(project, `docsTmp/${packageName}.json`);
const docs = JSON.parse(
await readFile(`docsTmp/${packageName}.json`, "utf8")
) as JSONOutput.ProjectReflection;
return (
`# ${packageName}\n\n` +
docs
.groups!.map((group) => {
return group.children
?.map((childId) => {
const child = docs.children!.find(
(child) => child.id === childId
)!;
if (manuallyIgnore.has(child.name)) {
return "";
}
return (
`## \`${renderChildName(child)}\` (${group.title
.toLowerCase()
.replace("ces", "ce")
.replace(/es$/, "")
.replace(
"ns",
"n"
)} in \`${packageName}\`)\n\n` +
renderChildType(child) +
renderComment(child.comment) +
(child.kind === 128 || child.kind === 256
? child.groups
?.map((group) =>
renderChildGroup(child, group)
)
.join("\n\n")
: "TODO: doc generator not implemented yet")
);
})
.join("\n\n----\n\n");
})
.join("\n\n----\n\n")
);
function renderComment(comment?: JSONOutput.Comment): string {
if (comment) {
return (
comment.summary
.map((token) =>
token.kind === "text" || token.kind === "code"
? token.text
: ""
)
.join("") +
"\n\n" +
(comment.blockTags || [])
.map((blockTag) =>
blockTag.tag === "@example"
? "##### Example:\n\n" +
blockTag.content
.map((token) =>
token.kind === "text" ||
token.kind === "code"
? token.text
: ""
)
.join("")
: ""
)
.join("\n\n") +
"\n\n"
);
} else {
return "TODO: document\n\n";
}
}
function renderChildName(child: JSONOutput.DeclarationReflection) {
if (child.signatures) {
if (
child.signatures[0].type?.type === "reference" &&
child.signatures[0].type.qualifiedName ===
"React.JSX.Element"
) {
return `<${child.name}/>`;
} else {
return (
child.name +
`(${(child.signatures[0].parameters || [])
.map(renderParamSimple)
.join(", ")})`
);
}
} else {
return child.name;
}
}
function renderChildType(
child: JSONOutput.DeclarationReflection
): string {
const isClass = child.kind === 128;
const isTypeDef = child.kind === 2097152;
const isInterface = child.kind === 256;
const isFunction = !!child.signatures;
return (
"```typescript\n" +
`export ${
isClass
? "class"
: isTypeDef
? "type"
: isFunction
? "function"
: isInterface
? "interface"
: ""
} ${child.name}` +
(child.typeParameters
? "<" +
child.typeParameters.map(renderTypeParam).join(", ") +
">"
: "") +
(child.extendedTypes
? " extends " +
child.extendedTypes.map(renderType).join(", ")
: "") +
(child.implementedTypes
? " implements " +
child.implementedTypes.map(renderType).join(", ")
: "") +
(isClass || isInterface
? " {...}"
: isTypeDef
? ` = ${renderType(child.type)}`
: child.signatures
? `(${(child.signatures[0].parameters || [])
.map(renderParam)
.join(", ")}): ${renderType(
child.signatures[0].type
)}`
: "") +
"\n```\n"
);
}
function renderChildGroup(
child: JSONOutput.DeclarationReflection,
group: JSONOutput.ReflectionGroup
): string {
return (
`### ${group.title}\n\n` +
group.children
?.map((memberId) => {
const member = child.children!.find(
(member) => member.id === memberId
)!;
if (member.kind === 2048 || member.kind === 512) {
if (member.signatures?.every(sig => sig.comment?.modifierTags?.includes("@internal"))) {
return ""
} else {
return documentConstructorOrMethod(member, child);
}
} else if (
member.kind === 1024 ||
member.kind === 262144
) {
if (member.comment?.modifierTags?.includes("@internal")) {
return ""
} else {
return documentProperty(member, child);
}
} else {
return "Unknown member kind " + member.kind;
}
})
.join("\n\n")
);
}
function renderType(t?: JSONOutput.SomeType): string {
if (!t) return "";
if (t.type === "reference") {
return (
t.name +
(t.typeArguments
? "<" + t.typeArguments.map(renderType).join(", ") + ">"
: "")
);
} else if (t.type === "intrinsic") {
return t.name;
} else if (t.type === "literal") {
return JSON.stringify(t.value);
} else if (t.type === "union") {
return [...new Set(t.types.map(renderType))].join(" | ");
} else if (t.type === "intersection") {
return [...new Set(t.types.map(renderType))].join(" & ");
} else if (t.type === "indexedAccess") {
return (
renderType(t.objectType) +
"[" +
renderType(t.indexType) +
"]"
);
} else if (t.type === "reflection") {
if (t.declaration.indexSignature) {
return (
"{ [" +
t.declaration.indexSignature?.parameters?.[0].name +
": " +
renderType(
t.declaration.indexSignature?.parameters?.[0].type
) +
"]: " +
renderType(t.declaration.indexSignature?.type) +
" }"
);
} else if (t.declaration.children) {
return `{${t.declaration.children
.map(
(child) =>
`${child.name}${
child.flags.isOptional ? "?" : ""
}: ${renderType(child.type)}`
)
.join(", ")}}`;
} else if (t.declaration.signatures) {
if (t.declaration.signatures.length > 1) {
return "COMPLEX_TYPE_MULTIPLE_INLINE_SIGNATURES";
} else {
return `(${(
t.declaration.signatures[0].parameters || []
).map(renderParam)}) => ${renderType(
t.declaration.signatures[0].type
)}`;
}
} else {
return "COMPLEX_TYPE_REFLECTION";
}
} else if (t.type === "array") {
return renderType(t.elementType) + "[]";
} else if (t.type === "templateLiteral") {
const matchingNamedType = docs.children?.find(
(child) =>
child.variant === "declaration" &&
child.type?.type === "templateLiteral" &&
child.type.head === t.head &&
child.type.tail.every(
(piece, i) => piece[1] === t.tail[i][1]
)
);
if (matchingNamedType) {
return matchingNamedType.name;
} else {
if (
t.head === "sealerSecret_z" &&
t.tail[0][1] === "/signerSecret_z"
) {
return "AgentSecret";
} else if (
t.head === "sealer_z" &&
t.tail[0][1] === "/signer_z"
) {
if (t.tail[1] && t.tail[1][1] === "_session_z") {
return "SessionID";
} else {
return "AgentID";
}
} else {
return "TEMPLATE_LITERAL";
}
}
} else {
return "COMPLEX_TYPE_" + t.type;
}
}
// function renderTemplateLiteral(tempLit: JSONOutput.TemplateLiteralType) {
// return tempLit.head + tempLit.tail.map((piece) => piece[0] + piece[1]).join("");
// }
// function resolveTemplateLiteralPieceType(t: SomeType): string {
// if (t.type === "string") {
// return "${string}"
// }
// if (t.type === "reference") {
// const referencedType = docs.children?.find(
// (child) => child.name === t.name
// );
// }
// }
function renderTypeParam(
t?: JSONOutput.TypeParameterReflection
): string {
if (!t) return "";
return t.name + (t.type ? " extends " + renderType(t.type) : "");
}
function renderParam(param: JSONOutput.ParameterReflection) {
return param.name === "__namedParameters"
? renderType(param.type)
: `${param.name}: ${renderType(param.type)}`;
}
function renderParamSimple(param: JSONOutput.ParameterReflection) {
return param.name === "__namedParameters" &&
param.type?.type === "reflection"
? `{${param.type?.declaration.children
?.map(
(child) =>
child.name + (child.flags.isOptional ? "?" : "")
)
.join(", ")}}${param.defaultValue ? "?" : ""}`
: param.name + (param.defaultValue ? "?" : "");
}
function documentConstructorOrMethod(
member: JSONOutput.DeclarationReflection,
child: JSONOutput.DeclarationReflection
) {
const stem =
member.name === "constructor"
? "new " + child.name
: (member.flags.isStatic
? child.name
: child.name[0].toLowerCase() + child.name.slice(1)) +
"." +
member.name;
return (
`<details>\n<summary><code>${stem}(${(
member.signatures?.[0]?.parameters?.map(
renderParamSimple
) || []
).join(", ")})</code> ${
member.inheritedFrom
? "(from <code>" +
member.inheritedFrom.name.split(".")[0] +
"</code>) "
: ""
} ${
member.signatures?.[0]?.comment ? "" : "(undocumented)"
}</summary>\n\n` +
member.signatures?.map((signature) => {
return (
"```typescript\n" +
`${stem}${
signature.typeParameter
? `<${signature.typeParameter
.map(renderTypeParam)
.join(", ")}>`
: ""
}(${
(
signature.parameters?.map(
(param) =>
`\n ${param.name}${
param.defaultValue ? "?" : ""
}: ${renderType(param.type)}${
param.defaultValue
? ` = ${param.defaultValue}`
: ""
}`
) || []
).join(",") +
(signature.parameters?.length ? "\n" : "")
}): ${renderType(signature.type)}\n` +
"```\n" +
renderComment(signature.comment)
);
}) +
"</details>\n\n"
);
}
function documentProperty(
member: JSONOutput.DeclarationReflection,
child: JSONOutput.DeclarationReflection
) {
const stem = member.flags.isStatic
? child.name
: child.name[0].toLowerCase() + child.name.slice(1);
return (
`<details>\n<summary><code>${stem}.${member.name}</code> ${
member.inheritedFrom
? "(from <code>" +
member.inheritedFrom.name.split(".")[0] +
"</code>) "
: ""
} ${
member.comment ? "" : "(undocumented)"
}</summary>\n\n` +
"```typescript\n" +
`${member.getSignature ? "get " : ""}${stem}.${member.name}${
member.getSignature ? "()" : ""
}: ${renderType(member.type || member.getSignature?.type)}\n` +
"```\n" +
renderComment(member.comment) +
"</details>\n\n"
);
}
});
await writeFile(
"./DOCS.md",
(await Promise.all(packageDocs)).join("\n\n\n")
);
}
main().catch(console.error);

View File

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

View File

@@ -7,14 +7,6 @@
],
"dependencies": {},
"devDependencies": {
"lerna": "^7.1.5",
"ts-node": "^10.9.1",
"typedoc": "^0.25.1"
},
"scripts": {
"build-all": "lerna run build",
"updated": "lerna updated --include-merged-tags",
"publish-all": "yarn run gen-docs && lerna publish --include-merged-tags",
"gen-docs": "ts-node generateDocs.ts"
"lerna": "^7.1.5"
}
}

View File

@@ -1,18 +0,0 @@
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",
},
};

View File

@@ -1,174 +0,0 @@
# 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
sync.db*

View File

@@ -1,36 +0,0 @@
{
"name": "cojson-simple-sync",
"module": "dist/index.js",
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.1.11",
"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.10",
"cojson-storage-sqlite": "^0.1.8",
"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"
},
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
}

View File

@@ -1,54 +0,0 @@
import { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
import { WebSocketServer } from "ws";
import { SQLiteStorage } from "cojson-storage-sqlite";
import { websocketReadableStream, websocketWritableStream } from "./websocketStreams.js";
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)
);
SQLiteStorage.asPeer({ filename: "./sync.db" })
.then((storage) => localNode.sync.addPeer(storage))
.catch((e) => console.error(e));
wss.on("connection", function connection(ws, req) {
const pinging = setInterval(() => {
ws.send(
JSON.stringify({
type: "ping",
time: Date.now(),
dc: "cojson-simple-sync",
})
);
}, 2000);
ws.on("close", () => {
clearInterval(pinging);
});
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: websocketReadableStream(ws),
outgoing: websocketWritableStream(ws),
});
ws.on("error", (e) => console.error(`Error on connection ${clientId}:`, e));
});

View File

@@ -1,86 +0,0 @@
import { WebSocket } from "ws";
import { WritableStream, ReadableStream } from "isomorphic-streams";
export function websocketReadableStream<T>(ws: WebSocket) {
ws.binaryType = "arraybuffer";
return new ReadableStream<T>({
start(controller) {
ws.addEventListener("message", (event) => {
if (typeof event.data !== "string")
return console.warn(
"Got non-string message from client",
event.data
);
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.addEventListener("close", () => controller.close());
ws.addEventListener("error", () =>
controller.error(new Error("The WebSocket errored!"))
);
},
cancel() {
ws.close();
},
});
}
export function websocketWritableStream<T>(ws: WebSocket) {
return new WritableStream<T>({
start(controller) {
ws.addEventListener("close", () =>
controller.error(
new Error("The WebSocket closed unexpectedly!")
)
);
ws.addEventListener("error", () =>
controller.error(new Error("The WebSocket errored!"))
);
if (ws.readyState === WebSocket.OPEN) {
return;
}
return new Promise((resolve) => ws.once("open", 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);
});
}
}

View File

@@ -1,19 +0,0 @@
{
"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,17 +0,0 @@
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",
},
};

View File

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

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

View File

@@ -1,22 +0,0 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.1.8",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^8.5.2",
"cojson": "^0.1.10",
"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"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.4"
},
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
}

View File

@@ -1,386 +0,0 @@
import {
cojsonInternals,
SyncMessage,
Peer,
CojsonInternalTypes,
SessionID,
// CojsonInternalTypes,
// SessionID,
} from "cojson";
import {
ReadableStream,
WritableStream,
ReadableStreamDefaultReader,
WritableStreamDefaultWriter,
} from "isomorphic-streams";
import Database, { Database as DatabaseT } from "better-sqlite3";
import { RawCoID } from "cojson/dist/ids";
type CoValueRow = {
id: CojsonInternalTypes.RawCoID;
header: string;
};
type StoredCoValueRow = CoValueRow & { rowID: number };
type SessionRow = {
coValue: number;
sessionID: SessionID;
lastIdx: number;
lastSignature: CojsonInternalTypes.Signature;
};
type StoredSessionRow = SessionRow & { rowID: number };
type TransactionRow = {
ses: number;
idx: number;
tx: string;
};
export class SQLiteStorage {
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
db: DatabaseT;
constructor(
db: DatabaseT,
fromLocalNode: ReadableStream<SyncMessage>,
toLocalNode: WritableStream<SyncMessage>
) {
this.db = db;
this.fromLocalNode = fromLocalNode.getReader();
this.toLocalNode = toLocalNode.getWriter();
(async () => {
let done = false;
while (!done) {
const result = await this.fromLocalNode.read();
done = result.done;
if (result.value) {
this.handleSyncMessage(result.value);
}
}
})();
}
static async asPeer({
filename,
trace,
localNodeName = "local",
}: {
filename: string;
trace?: boolean;
localNodeName?: string;
}): Promise<Peer> {
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
localNodeName,
"storage",
{ peer1role: "client", peer2role: "server", trace }
);
await SQLiteStorage.open(
filename,
localNodeAsPeer.incoming,
localNodeAsPeer.outgoing
);
return storageAsPeer;
}
static async open(
filename: string,
fromLocalNode: ReadableStream<SyncMessage>,
toLocalNode: WritableStream<SyncMessage>
) {
const db = Database(filename);
db.pragma("journal_mode = WAL");
db.prepare(
`CREATE TABLE IF NOT EXISTS transactions (
ses INTEGER,
idx INTEGER,
tx TEXT NOT NULL ,
PRIMARY KEY (ses, idx)
) WITHOUT ROWID;`
).run();
db.prepare(
`CREATE TABLE IF NOT EXISTS sessions (
rowID INTEGER PRIMARY KEY,
coValue INTEGER NOT NULL,
sessionID TEXT NOT NULL,
lastIdx INTEGER,
lastSignature TEXT,
UNIQUE (sessionID, coValue)
);`
).run();
db.prepare(
`CREATE INDEX IF NOT EXISTS sessionsByCoValue ON sessions (coValue);`
).run();
db.prepare(
`CREATE TABLE IF NOT EXISTS coValues (
rowID INTEGER PRIMARY KEY,
id TEXT NOT NULL UNIQUE,
header TEXT NOT NULL UNIQUE
);`
).run();
db.prepare(
`CREATE INDEX IF NOT EXISTS coValuesByID ON coValues (id);`
).run();
return new SQLiteStorage(db, fromLocalNode, toLocalNode);
}
async handleSyncMessage(msg: SyncMessage) {
switch (msg.action) {
case "load":
await this.handleLoad(msg);
break;
case "content":
await this.handleContent(msg);
break;
case "known":
await this.handleKnown(msg);
break;
case "done":
await this.handleDone(msg);
break;
}
}
async sendNewContentAfter(
theirKnown: CojsonInternalTypes.CoValueKnownState,
asDependencyOf?: CojsonInternalTypes.RawCoID
) {
const coValueRow = (await this.db
.prepare(`SELECT * FROM coValues WHERE id = ?`)
.get(theirKnown.id)) as StoredCoValueRow | undefined;
const allOurSessions = coValueRow
? (this.db
.prepare<number>(`SELECT * FROM sessions WHERE coValue = ?`)
.all(coValueRow.rowID) as StoredSessionRow[])
: [];
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
id: theirKnown.id,
header: !!coValueRow,
sessions: {},
};
const parsedHeader = (coValueRow?.header &&
JSON.parse(coValueRow.header)) as
| CojsonInternalTypes.CoValueHeader
| undefined;
const newContent: CojsonInternalTypes.NewContentMessage = {
action: "content",
id: theirKnown.id,
header: theirKnown.header ? undefined : parsedHeader,
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 = this.db
.prepare<[number, number]>(
`SELECT * FROM transactions WHERE ses = ? AND idx > ?`
)
.all(sessionRow.rowID, firstNewTxIdx) as TransactionRow[];
newContent.new[sessionRow.sessionID] = {
after: firstNewTxIdx,
lastSignature: sessionRow.lastSignature,
newTransactions: newTxInSession.map((row) =>
JSON.parse(row.tx)
),
};
}
}
const dependedOnCoValues =
parsedHeader?.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_")
);
})
)
: parsedHeader?.ruleset.type === "ownedByGroup"
? [parsedHeader?.ruleset.group]
: [];
for (const dependedOnCoValue of dependedOnCoValues) {
await this.sendNewContentAfter(
{ id: dependedOnCoValue, header: false, sessions: {} },
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);
}
async handleContent(msg: CojsonInternalTypes.NewContentMessage) {
let storedCoValueRowID = (
this.db
.prepare<RawCoID>(`SELECT rowID FROM coValues WHERE id = ?`)
.get(msg.id) as StoredCoValueRow | undefined
)?.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 = this.db
.prepare<[RawCoID, string]>(
`INSERT INTO coValues (id, header) VALUES (?, ?)`
)
.run(msg.id, JSON.stringify(header)).lastInsertRowid as number;
}
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
id: msg.id,
header: true,
sessions: {},
};
let invalidAssumptions = false;
this.db.transaction(() => {
const allOurSessions = (
this.db
.prepare<number>(`SELECT * FROM sessions WHERE coValue = ?`)
.all(storedCoValueRowID!) as StoredSessionRow[]
).reduce((acc, row) => {
acc[row.sessionID] = row;
return acc;
}, {} as { [sessionID: string]: StoredSessionRow });
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 upsertedSession = (this.db
.prepare<[number, string, number, string]>(
`INSERT INTO sessions (coValue, sessionID, lastIdx, lastSignature) VALUES (?, ?, ?, ?)
ON CONFLICT(coValue, sessionID) DO UPDATE SET lastIdx=excluded.lastIdx, lastSignature=excluded.lastSignature
RETURNING rowID`
)
.get(
sessionUpdate.coValue,
sessionUpdate.sessionID,
sessionUpdate.lastIdx,
sessionUpdate.lastSignature
) as {rowID: number});
const sessionRowID = upsertedSession.rowID;
for (const newTransaction of actuallyNewTransactions) {
nextIdx++;
this.db
.prepare<[number, number, string]>(
`INSERT INTO transactions (ses, idx, tx) VALUES (?, ?, ?)`
)
.run(
sessionRowID,
nextIdx,
JSON.stringify(newTransaction)
);
}
}
}
})();
if (invalidAssumptions) {
await this.toLocalNode.write({
action: "known",
...ourKnown,
isCorrection: invalidAssumptions,
});
}
}
handleKnown(msg: CojsonInternalTypes.KnownStateMessage) {
return this.sendNewContentAfter(msg);
}
handleDone(_msg: CojsonInternalTypes.DoneMessage) {}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,53 @@
# CoJSON
[See the top-level README](../../README.md#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

View File

@@ -2,10 +2,10 @@
"name": "cojson",
"module": "dist/index.js",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"types": "src/index.ts",
"type": "module",
"license": "MIT",
"version": "0.1.10",
"version": "0.0.18",
"devDependencies": {
"@types/jest": "^29.5.3",
"@typescript-eslint/eslint-plugin": "^6.2.1",
@@ -51,6 +51,5 @@
"/node_modules/",
"/dist/"
]
},
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
}
}

View File

@@ -1,4 +1,4 @@
import { newRandomSessionID } from "./coValueCore.js";
import { newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { connectedPeers } from "./streamUtils.js";
@@ -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 groups and and objects within them", async () => {
test("A node with an account can create teams and and objects within them", async () => {
const { node, accountID } =
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
const group = await node.createGroup();
expect(group).not.toBeNull();
const team = await node.createTeam();
expect(team).not.toBeNull();
let map = group.createMap();
let map = team.createMap();
map = map.edit((edit) => {
edit.set("foo", "bar", "private");
expect(edit.get("foo")).toEqual("bar");
@@ -34,17 +34,17 @@ test("A node with an account can create groups and and objects within them", asy
expect(map.get("foo")).toEqual("bar");
expect(map.whoEdited("foo")).toEqual(accountID);
expect(map.getLastEditor("foo")).toEqual(accountID);
});
test("Can create account with one node, and then load it on another", async () => {
const { node, accountID, accountSecret } =
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
const group = await node.createGroup();
expect(group).not.toBeNull();
const team = await node.createTeam();
expect(team).not.toBeNull();
let map = group.createMap();
let map = team.createMap();
map = map.edit((edit) => {
edit.set("foo", "bar", "private");
expect(edit.get("foo")).toEqual("bar");

View File

@@ -1,5 +1,5 @@
import { CoValueHeader } from "./coValueCore.js";
import { CoID } from "./coValue.js";
import { CoValueHeader } from "./coValue.js";
import { CoID } from "./contentType.js";
import {
AgentSecret,
SealerID,
@@ -13,9 +13,8 @@ import {
getAgentSignerSecret,
} from "./crypto.js";
import { AgentID } from "./ids.js";
import { CoMap } from "./coValues/coMap.js";
import { LocalNode } from "./node.js";
import { Group, GroupContent } from "./group.js";
import { CoMap, LocalNode } from "./index.js";
import { Team, TeamContent } from "./permissions.js";
export function accountHeaderForInitialAgentSecret(
agentSecret: AgentSecret
@@ -23,7 +22,7 @@ export function accountHeaderForInitialAgentSecret(
const agent = getAgentID(agentSecret);
return {
type: "comap",
ruleset: { type: "group", initialAdmin: agent },
ruleset: { type: "team", initialAdmin: agent },
meta: {
type: "account",
},
@@ -32,13 +31,13 @@ export function accountHeaderForInitialAgentSecret(
};
}
export class Account extends Group {
export class Account extends Team {
get id(): AccountID {
return this.underlyingMap.id as AccountID;
return this.teamMap.id as AccountID;
}
getCurrentAgentID(): AgentID {
const agents = this.underlyingMap
const agents = this.teamMap
.keys()
.filter((k): k is AgentID => k.startsWith("sealer_"));
@@ -53,7 +52,7 @@ export class Account extends Group {
}
export interface GeneralizedControlledAccount {
id: AccountID | AgentID;
id: AccountIDOrAgentID;
agentSecret: AgentSecret;
currentAgentID: () => AgentID;
@@ -63,7 +62,6 @@ export interface GeneralizedControlledAccount {
currentSealerSecret: () => SealerSecret;
}
/** @hidden */
export class ControlledAccount
extends Account
implements GeneralizedControlledAccount
@@ -72,10 +70,10 @@ export class ControlledAccount
constructor(
agentSecret: AgentSecret,
groupMap: CoMap<AccountContent, AccountMeta>,
teamMap: CoMap<AccountContent, AccountMeta>,
node: LocalNode
) {
super(groupMap, node);
super(teamMap, node);
this.agentSecret = agentSecret;
}
@@ -101,7 +99,6 @@ export class ControlledAccount
}
}
/** @hidden */
export class AnonymousControlledAccount
implements GeneralizedControlledAccount
{
@@ -136,12 +133,15 @@ export class AnonymousControlledAccount
}
}
export type AccountContent = GroupContent & { profile: CoID<Profile> };
export type AccountContent = TeamContent & { profile: CoID<Profile> };
export type AccountMeta = { type: "account" };
export type AccountMap = CoMap<AccountContent, AccountMeta>;
export type AccountID = CoID<AccountMap>;
export type AccountID = CoID<CoMap<AccountContent, AccountMeta>>;
export function isAccountID(id: AccountID | AgentID): id is AccountID {
export type AccountIDOrAgentID = AgentID | AccountID;
export type AccountOrAgentID = AgentID | Account;
export type AccountOrAgentSecret = AgentSecret | Account;
export function isAccountID(id: AccountIDOrAgentID): id is AccountID {
return id.startsWith("co_");
}

View File

@@ -1,291 +1,11 @@
import { accountOrAgentIDfromSessionID } from "./coValueCore.js";
import { BinaryCoStream } from "./coValues/coStream.js";
import { createdNowUnique } from "./crypto.js";
import { Transaction } from "./coValue.js";
import { LocalNode } from "./node.js";
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
test("Empty CoMap works", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
expect([...content.keys()]).toEqual([]);
expect(content.toJSON()).toEqual({});
});
test("Can insert and delete CoMap entries in edit()", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
editable.set("hello", "world", "trusting");
expect(editable.get("hello")).toEqual("world");
editable.set("foo", "bar", "trusting");
expect(editable.get("foo")).toEqual("bar");
expect([...editable.keys()]).toEqual(["hello", "foo"]);
editable.delete("foo", "trusting");
expect(editable.get("foo")).toEqual(undefined);
});
});
test("Can get CoMap entry values at different points in time", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
const beforeA = Date.now();
while (Date.now() < beforeA + 10) {}
editable.set("hello", "A", "trusting");
const beforeB = Date.now();
while (Date.now() < beforeB + 10) {}
editable.set("hello", "B", "trusting");
const beforeC = Date.now();
while (Date.now() < beforeC + 10) {}
editable.set("hello", "C", "trusting");
expect(editable.get("hello")).toEqual("C");
expect(editable.getAtTime("hello", Date.now())).toEqual("C");
expect(editable.getAtTime("hello", beforeA)).toEqual(undefined);
expect(editable.getAtTime("hello", beforeB)).toEqual("A");
expect(editable.getAtTime("hello", beforeC)).toEqual("B");
});
});
test("Can get all historic values of key in CoMap", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
editable.set("hello", "A", "trusting");
const txA = editable.getLastTxID("hello");
editable.set("hello", "B", "trusting");
const txB = editable.getLastTxID("hello");
editable.delete("hello", "trusting");
const txDel = editable.getLastTxID("hello");
editable.set("hello", "C", "trusting");
const txC = editable.getLastTxID("hello");
expect(editable.getHistory("hello")).toEqual([
{
txID: txA,
value: "A",
at: txA && coValue.getTx(txA)?.madeAt,
},
{
txID: txB,
value: "B",
at: txB && coValue.getTx(txB)?.madeAt,
},
{
txID: txDel,
value: undefined,
at: txDel && coValue.getTx(txDel)?.madeAt,
},
{
txID: txC,
value: "C",
at: txC && coValue.getTx(txC)?.madeAt,
},
]);
});
});
test("Can get last tx ID for a key in CoMap", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
expect(editable.getLastTxID("hello")).toEqual(undefined);
editable.set("hello", "A", "trusting");
const sessionID = editable.getLastTxID("hello")?.sessionID;
expect(sessionID && accountOrAgentIDfromSessionID(sessionID)).toEqual(
node.account.id
);
expect(editable.getLastTxID("hello")?.txIndex).toEqual(0);
editable.set("hello", "B", "trusting");
expect(editable.getLastTxID("hello")?.txIndex).toEqual(1);
editable.set("hello", "C", "trusting");
expect(editable.getLastTxID("hello")?.txIndex).toEqual(2);
});
});
test("Empty CoList works", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "colist",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "colist") {
throw new Error("Expected list");
}
expect(content.type).toEqual("colist");
expect(content.toJSON()).toEqual([]);
});
test("Can append, prepend and delete items to CoList", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "colist",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "colist") {
throw new Error("Expected list");
}
expect(content.type).toEqual("colist");
content.edit((editable) => {
editable.append(0, "hello", "trusting");
expect(editable.toJSON()).toEqual(["hello"]);
editable.append(0, "world", "trusting");
expect(editable.toJSON()).toEqual(["hello", "world"]);
editable.prepend(1, "beautiful", "trusting");
expect(editable.toJSON()).toEqual(["hello", "beautiful", "world"]);
editable.prepend(3, "hooray", "trusting");
expect(editable.toJSON()).toEqual([
"hello",
"beautiful",
"world",
"hooray",
]);
editable.delete(2, "trusting");
expect(editable.toJSON()).toEqual(["hello", "beautiful", "hooray"]);
});
});
test("Push is equivalent to append after last item", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "colist",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "colist") {
throw new Error("Expected list");
}
expect(content.type).toEqual("colist");
content.edit((editable) => {
editable.append(0, "hello", "trusting");
expect(editable.toJSON()).toEqual(["hello"]);
editable.push("world", "trusting");
expect(editable.toJSON()).toEqual(["hello", "world"]);
editable.push("hooray", "trusting");
expect(editable.toJSON()).toEqual(["hello", "world", "hooray"]);
});
});
test("Can push into empty list", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "colist",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "colist") {
throw new Error("Expected list");
}
expect(content.type).toEqual("colist");
content.edit((editable) => {
editable.push("hello", "trusting");
expect(editable.toJSON()).toEqual(["hello"]);
});
});
test("Empty CoStream works", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
test("Can create coValue with new agent credentials and add transaction to it", () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(account, sessionID);
const coValue = node.createCoValue({
type: "costream",
@@ -294,19 +14,35 @@ test("Empty CoStream works", () => {
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
if (content.type !== "costream") {
throw new Error("Expected stream");
}
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
[transaction]
);
expect(content.type).toEqual("costream");
expect(content.toJSON()).toEqual({});
expect(content.getSingleStream()).toEqual(undefined);
expect(
coValue.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(account.currentSignerSecret(), expectedNewHash)
)
).toBe(true);
});
test("Can push into CoStream", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
test("transactions with wrong signature are rejected", () => {
const wrongAgent = newRandomAgentSecret();
const [agentSecret, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(agentSecret, sessionID);
const coValue = node.createCoValue({
type: "costream",
@@ -315,73 +51,73 @@ test("Can push into CoStream", () => {
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
if (content.type !== "costream") {
throw new Error("Expected stream");
}
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
[transaction]
);
content.edit((editable) => {
editable.push({ hello: "world" }, "trusting");
expect(editable.toJSON()).toEqual({
[node.currentSessionID]: [{ hello: "world" }],
});
editable.push({ foo: "bar" }, "trusting");
expect(editable.toJSON()).toEqual({
[node.currentSessionID]: [{ hello: "world" }, { foo: "bar" }],
});
expect(editable.getSingleStream()).toEqual([
{ hello: "world" },
{ foo: "bar" },
]);
});
expect(
coValue.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(getAgentSignerSecret(wrongAgent), expectedNewHash)
)
).toBe(false);
});
test("Empty BinaryCoStream works", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
test("transactions with correctly signed, but wrong hash are rejected", () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(account, sessionID);
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: { type: "binary" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
if (content.type !== "costream" || content.meta?.type !== "binary" || !(content instanceof BinaryCoStream)) {
throw new Error("Expected binary stream");
}
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.ownSessionID,
[
{
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "wrong",
},
],
},
]
);
expect(content.type).toEqual("costream");
expect(content.meta.type).toEqual("binary");
expect(content.toJSON()).toEqual({});
expect(content.getBinaryChunks()).toEqual(undefined);
});
test("Can push into BinaryCoStream", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: { type: "binary" },
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "costream" || content.meta?.type !== "binary" || !(content instanceof BinaryCoStream)) {
throw new Error("Expected binary stream");
}
content.edit((editable) => {
editable.startBinaryStream({mimeType: "text/plain", fileName: "test.txt"}, "trusting");
expect(editable.getBinaryChunks()).toEqual({
mimeType: "text/plain",
fileName: "test.txt",
chunks: [],
finished: false,
});
});
expect(
coValue.tryAddTransactions(
node.ownSessionID,
[transaction],
expectedNewHash,
sign(account.currentSignerSecret(), expectedNewHash)
)
).toBe(false);
});

View File

@@ -1,64 +1,547 @@
import { randomBytes } from "@noble/hashes/utils";
import { ContentType } from "./contentType.js";
import { Static } from "./contentTypes/static.js";
import { CoStream } from "./contentTypes/coStream.js";
import { CoMap } from "./contentTypes/coMap.js";
import {
Encrypted,
Hash,
KeySecret,
Signature,
StreamingHash,
unseal,
shortHash,
sign,
verify,
encryptForTransaction,
decryptForTransaction,
KeyID,
decryptKeySecret,
getAgentSignerID,
getAgentSealerID,
} from "./crypto.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import { RawCoID } from "./ids.js";
import { CoMap } from "./coValues/coMap.js";
import { BinaryCoStream, BinaryCoStreamMeta, CoStream } from "./coValues/coStream.js";
import { Static } from "./coValues/static.js";
import { CoList } from "./coValues/coList.js";
import { CoValueCore } from "./coValueCore.js";
import { Group } from "./group.js";
import { base58 } from "@scure/base";
import {
PermissionsDef as RulesetDef,
Team,
determineValidTransactions,
expectTeamContent,
isKeyForKeyField,
} from "./permissions.js";
import { LocalNode } from "./node.js";
import { CoValueKnownState, NewContentMessage } from "./sync.js";
import { RawCoID, SessionID, TransactionID } from "./ids.js";
import { CoList } from "./contentTypes/coList.js";
import {
AccountID,
AccountIDOrAgentID,
GeneralizedControlledAccount,
} from "./account.js";
export type CoID<T extends CoValueImpl> = RawCoID & {
readonly __type: T;
export type CoValueHeader = {
type: ContentType["type"];
ruleset: RulesetDef;
meta: JsonObject | null;
createdAt: `2${string}` | null;
uniqueness: `z${string}` | null;
};
export interface ReadableCoValue extends CoValue {
/** Lets you subscribe to future updates to this CoValue (whether made locally or by other users).
*
* Takes a listener function that will be called with the current state for each update.
*
* Returns an unsubscribe function.
*
* Used internally by `useTelepathicData()` for reactive updates on changes to a `CoValue`. */
subscribe(listener: (coValue: CoValueImpl) => void): () => void;
/** Lets you apply edits to a `CoValue`, inside the changer callback, which receives a `WriteableCoValue`.
*
* A `WritableCoValue` has all the same methods as a `CoValue`, but all edits made to it (with its additional mutator methods)
* are reflected in it immediately - so it behaves mutably, whereas a `CoValue` is always immutable
* (you need to use `subscribe` to receive new versions of it). */
edit?:
| ((changer: (editable: WriteableCoValue) => void) => CoValueImpl)
| undefined;
export function idforHeader(header: CoValueHeader): RawCoID {
const hash = shortHash(header);
return `co_z${hash.slice("shortHash_z".length)}`;
}
export interface CoValue {
/** The `CoValue`'s (precisely typed) `CoID` */
id: CoID<CoValueImpl>;
core: CoValueCore;
/** Specifies which kind of `CoValue` this is */
type: CoValueImpl["type"];
/** The `CoValue`'s (precisely typed) static metadata */
meta: JsonObject | null;
/** The `Group` this `CoValue` belongs to (determining permissions) */
group: Group;
/** Returns an immutable JSON presentation of this `CoValue` */
toJSON(): JsonValue;
export function accountOrAgentIDfromSessionID(
sessionID: SessionID
): AccountIDOrAgentID {
return sessionID.split("_session")[0] as AccountIDOrAgentID;
}
export interface WriteableCoValue extends CoValue {}
export function newRandomSessionID(accountID: AccountIDOrAgentID): SessionID {
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
}
export type CoValueImpl =
| CoMap<{ [key: string]: JsonValue }, JsonObject | null>
| CoList<JsonValue, JsonObject | null>
| CoStream<JsonValue, JsonObject | null>
| BinaryCoStream<BinaryCoStreamMeta>
| Static<JsonObject>;
type SessionLog = {
transactions: Transaction[];
lastHash?: Hash;
streamingHash: StreamingHash;
lastSignature: Signature;
};
export function expectMap(
content: CoValueImpl
): CoMap<{ [key: string]: string }, JsonObject | null> {
if (content.type !== "comap") {
throw new Error("Expected map");
export type PrivateTransaction = {
privacy: "private";
madeAt: number;
keyUsed: KeyID;
encryptedChanges: Encrypted<
JsonValue[],
{ in: RawCoID; tx: TransactionID }
>;
};
export type TrustingTransaction = {
privacy: "trusting";
madeAt: number;
changes: JsonValue[];
};
export type Transaction = PrivateTransaction | TrustingTransaction;
export type DecryptedTransaction = {
txID: TransactionID;
changes: JsonValue[];
madeAt: number;
};
export class CoValue {
id: RawCoID;
node: LocalNode;
header: CoValueHeader;
sessions: { [key: SessionID]: SessionLog };
content?: ContentType;
listeners: Set<(content?: ContentType) => void> = new Set();
constructor(header: CoValueHeader, node: LocalNode) {
this.id = idforHeader(header);
this.header = header;
this.sessions = {};
this.node = node;
}
return content as CoMap<{ [key: string]: string }, JsonObject | null>;
}
testWithDifferentAccount(
account: GeneralizedControlledAccount,
ownSessionID: SessionID
): CoValue {
const newNode = this.node.testWithDifferentAccount(
account,
ownSessionID
);
return newNode.expectCoValueLoaded(this.id);
}
knownState(): CoValueKnownState {
return {
id: this.id,
header: true,
sessions: Object.fromEntries(
Object.entries(this.sessions).map(([k, v]) => [
k,
v.transactions.length,
])
),
};
}
get meta(): JsonValue {
return this.header?.meta ?? null;
}
nextTransactionID(): TransactionID {
const sessionID = this.node.ownSessionID;
return {
sessionID,
txIndex: this.sessions[sessionID]?.transactions.length || 0,
};
}
tryAddTransactions(
sessionID: SessionID,
newTransactions: Transaction[],
givenExpectedNewHash: Hash | undefined,
newSignature: Signature
): boolean {
const signerID = getAgentSignerID(
this.node.resolveAccountAgent(
accountOrAgentIDfromSessionID(sessionID),
"Expected to know signer of transaction"
)
);
if (!signerID) {
console.warn(
"Unknown agent",
accountOrAgentIDfromSessionID(sessionID)
);
return false;
}
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
sessionID,
newTransactions
);
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
console.warn("Invalid hash", { expectedNewHash, givenExpectedNewHash });
return false;
}
if (!verify(newSignature, expectedNewHash, signerID)) {
console.warn(
"Invalid signature",
newSignature,
expectedNewHash,
signerID
);
return false;
}
const transactions = this.sessions[sessionID]?.transactions ?? [];
transactions.push(...newTransactions);
this.sessions[sessionID] = {
transactions,
lastHash: expectedNewHash,
streamingHash: newStreamingHash,
lastSignature: newSignature,
};
this.content = undefined;
const content = this.getCurrentContent();
for (const listener of this.listeners) {
listener(content);
}
return true;
}
subscribe(listener: (content?: ContentType) => void): () => void {
this.listeners.add(listener);
listener(this.getCurrentContent());
return () => {
this.listeners.delete(listener);
};
}
expectedNewHashAfter(
sessionID: SessionID,
newTransactions: Transaction[]
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
const streamingHash =
this.sessions[sessionID]?.streamingHash.clone() ??
new StreamingHash();
for (const transaction of newTransactions) {
streamingHash.update(transaction);
}
const newStreamingHash = streamingHash.clone();
return {
expectedNewHash: streamingHash.digest(),
newStreamingHash,
};
}
makeTransaction(
changes: JsonValue[],
privacy: "private" | "trusting"
): boolean {
const madeAt = Date.now();
let transaction: Transaction;
if (privacy === "private") {
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
if (!keySecret) {
throw new Error(
"Can't make transaction without read key secret"
);
}
transaction = {
privacy: "private",
madeAt,
keyUsed: keyID,
encryptedChanges: encryptForTransaction(changes, keySecret, {
in: this.id,
tx: this.nextTransactionID(),
}),
};
} else {
transaction = {
privacy: "trusting",
madeAt,
changes,
};
}
const sessionID = this.node.ownSessionID;
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
transaction,
]);
const signature = sign(
this.node.account.currentSignerSecret(),
expectedNewHash
);
const success = this.tryAddTransactions(
sessionID,
[transaction],
expectedNewHash,
signature
);
if (success) {
void this.node.sync.syncCoValue(this);
}
return success;
}
getCurrentContent(): ContentType {
if (this.content) {
return this.content;
}
if (this.header.type === "comap") {
this.content = new CoMap(this);
} else if (this.header.type === "colist") {
this.content = new CoList(this);
} else if (this.header.type === "costream") {
this.content = new CoStream(this);
} else if (this.header.type === "static") {
this.content = new Static(this);
} else {
throw new Error(`Unknown coValue type ${this.header.type}`);
}
return this.content;
}
getValidSortedTransactions(): DecryptedTransaction[] {
const validTransactions = determineValidTransactions(this);
const allTransactions: DecryptedTransaction[] = validTransactions
.map(({ txID, tx }) => {
if (tx.privacy === "trusting") {
return {
txID,
madeAt: tx.madeAt,
changes: tx.changes,
};
} else {
const readKey = this.getReadKey(tx.keyUsed);
if (!readKey) {
return undefined;
} else {
const decrytedChanges = decryptForTransaction(
tx.encryptedChanges,
readKey,
{
in: this.id,
tx: txID,
}
);
if (!decrytedChanges) {
console.error(
"Failed to decrypt transaction despite having key"
);
return undefined;
}
return {
txID,
madeAt: tx.madeAt,
changes: decrytedChanges,
};
}
}
})
.filter((x): x is Exclude<typeof x, undefined> => !!x);
allTransactions.sort(
(a, b) =>
a.madeAt - b.madeAt ||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
a.txID.txIndex - b.txID.txIndex
);
return allTransactions;
}
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
const currentKeyId = content.get("readKey");
if (!currentKeyId) {
throw new Error("No readKey set");
}
const secret = this.getReadKey(currentKeyId);
return {
secret: secret,
id: currentKeyId,
};
} else if (this.header.ruleset.type === "ownedByTeam") {
return this.node
.expectCoValueLoaded(this.header.ruleset.team)
.getCurrentReadKey();
} else {
throw new Error(
"Only teams or values owned by teams have read secrets"
);
}
}
getReadKey(keyID: KeyID): KeySecret | undefined {
if (this.header.ruleset.type === "team") {
const content = expectTeamContent(this.getCurrentContent());
// Try to find key revelation for us
const readKeyEntry = content.getLastEntry(`${keyID}_for_${this.node.account.id}`);
if (readKeyEntry) {
const revealer = accountOrAgentIDfromSessionID(
readKeyEntry.txID.sessionID
);
const revealerAgent = this.node.resolveAccountAgent(
revealer,
"Expected to know revealer"
);
const secret = unseal(
readKeyEntry.value,
this.node.account.currentSealerSecret(),
getAgentSealerID(revealerAgent),
{
in: this.id,
tx: readKeyEntry.txID,
}
);
if (secret) return secret as KeySecret;
}
// Try to find indirect revelation through previousKeys
for (const field of content.keys()) {
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
const encryptingKeyID = field.split("_for_")[1] as KeyID;
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
if (!encryptingKeySecret) {
continue;
}
const encryptedPreviousKey = content.get(field)!;
const secret = decryptKeySecret(
{
encryptedID: keyID,
encryptingID: encryptingKeyID,
encrypted: encryptedPreviousKey,
},
encryptingKeySecret
);
if (secret) {
return secret;
} else {
console.error(
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`
);
}
}
}
return undefined;
} else if (this.header.ruleset.type === "ownedByTeam") {
return this.node
.expectCoValueLoaded(this.header.ruleset.team)
.getReadKey(keyID);
} else {
throw new Error(
"Only teams or values owned by teams have read secrets"
);
}
}
getTeam(): Team {
if (this.header.ruleset.type !== "ownedByTeam") {
throw new Error("Only values owned by teams have teams");
}
return new Team(
expectTeamContent(
this.node
.expectCoValueLoaded(this.header.ruleset.team)
.getCurrentContent()
),
this.node
);
}
getTx(txID: TransactionID): Transaction | undefined {
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
}
newContentSince(
knownState: CoValueKnownState | undefined
): NewContentMessage | undefined {
const newContent: NewContentMessage = {
action: "content",
id: this.id,
header: knownState?.header ? undefined : this.header,
new: Object.fromEntries(
Object.entries(this.sessions)
.map(([sessionID, log]) => {
const newTransactions = log.transactions.slice(
knownState?.sessions[sessionID as SessionID] || 0
);
if (
newTransactions.length === 0 ||
!log.lastHash ||
!log.lastSignature
) {
return undefined;
}
return [
sessionID,
{
after:
knownState?.sessions[
sessionID as SessionID
] || 0,
newTransactions,
lastSignature: log.lastSignature,
},
];
})
.filter((x): x is Exclude<typeof x, undefined> => !!x)
),
};
if (
!newContent.header &&
Object.keys(newContent.new).length === 0
) {
return undefined;
}
return newContent;
}
getDependedOnCoValues(): RawCoID[] {
return this.header.ruleset.type === "team"
? expectTeamContent(this.getCurrentContent())
.keys()
.filter((k): k is AccountID => k.startsWith("co_"))
: this.header.ruleset.type === "ownedByTeam"
? [this.header.ruleset.team]
: [];
}
}

View File

@@ -1,180 +0,0 @@
import { Transaction } from "./coValueCore.js";
import { LocalNode } from "./node.js";
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
import { MapOpPayload } from "./coValues/coMap.js";
import { Role } from "./permissions.js";
test("Can create coValue with new agent credentials and add transaction to it", () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(account, sessionID);
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.currentSessionID,
[transaction]
);
expect(
coValue.tryAddTransactions(
node.currentSessionID,
[transaction],
expectedNewHash,
sign(account.currentSignerSecret(), expectedNewHash)
)
).toBe(true);
});
test("transactions with wrong signature are rejected", () => {
const wrongAgent = newRandomAgentSecret();
const [agentSecret, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(agentSecret, sessionID);
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.currentSessionID,
[transaction]
);
expect(
coValue.tryAddTransactions(
node.currentSessionID,
[transaction],
expectedNewHash,
sign(getAgentSignerSecret(wrongAgent), expectedNewHash)
)
).toBe(false);
});
test("transactions with correctly signed, but wrong hash are rejected", () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(account, sessionID);
const coValue = node.createCoValue({
type: "costream",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const transaction: Transaction = {
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "world",
},
],
};
const { expectedNewHash } = coValue.expectedNewHashAfter(
node.currentSessionID,
[
{
privacy: "trusting",
madeAt: Date.now(),
changes: [
{
hello: "wrong",
},
],
},
]
);
expect(
coValue.tryAddTransactions(
node.currentSessionID,
[transaction],
expectedNewHash,
sign(account.currentSignerSecret(), expectedNewHash)
)
).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.underlyingMap.core.expectedNewHashAfter(sessionID, [
resignationThatWeJustLearnedAbout,
]);
const signature = sign(
node.account.currentSignerSecret(),
expectedNewHash
);
expect(map.core.getValidSortedTransactions().length).toBe(1);
const manuallyAdddedTxSuccess = group.underlyingMap.core.tryAddTransactions(node.currentSessionID, [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.core.getValidSortedTransactions().length).toBe(0);
});

View File

@@ -1,592 +0,0 @@
import { randomBytes } from "@noble/hashes/utils";
import { CoValueImpl } from "./coValue.js";
import { Static } from "./coValues/static.js";
import { BinaryCoStream, CoStream } from "./coValues/coStream.js";
import { CoMap } from "./coValues/coMap.js";
import {
Encrypted,
Hash,
KeySecret,
Signature,
StreamingHash,
unseal,
shortHash,
sign,
verify,
encryptForTransaction,
decryptForTransaction,
KeyID,
decryptKeySecret,
getAgentSignerID,
getAgentSealerID,
} from "./crypto.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import { base58 } from "@scure/base";
import {
PermissionsDef as RulesetDef,
determineValidTransactions,
isKeyForKeyField,
} from "./permissions.js";
import { Group, expectGroupContent } from "./group.js";
import { LocalNode } from "./node.js";
import { CoValueKnownState, NewContentMessage } from "./sync.js";
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
import { CoList } from "./coValues/coList.js";
import {
AccountID,
GeneralizedControlledAccount,
} from "./account.js";
export type CoValueHeader = {
type: CoValueImpl["type"];
ruleset: RulesetDef;
meta: JsonObject | null;
createdAt: `2${string}` | null;
uniqueness: `z${string}` | null;
};
export function idforHeader(header: CoValueHeader): RawCoID {
const hash = shortHash(header);
return `co_z${hash.slice("shortHash_z".length)}`;
}
export function accountOrAgentIDfromSessionID(
sessionID: SessionID
): AccountID | AgentID {
return sessionID.split("_session")[0] as AccountID | AgentID;
}
export function newRandomSessionID(accountID: AccountID | AgentID): SessionID {
return `${accountID}_session_z${base58.encode(randomBytes(8))}`;
}
type SessionLog = {
transactions: Transaction[];
lastHash?: Hash;
streamingHash: StreamingHash;
lastSignature: Signature;
};
export type PrivateTransaction = {
privacy: "private";
madeAt: number;
keyUsed: KeyID;
encryptedChanges: Encrypted<
JsonValue[],
{ in: RawCoID; tx: TransactionID }
>;
};
export type TrustingTransaction = {
privacy: "trusting";
madeAt: number;
changes: JsonValue[];
};
export type Transaction = PrivateTransaction | TrustingTransaction;
export type DecryptedTransaction = {
txID: TransactionID;
changes: JsonValue[];
madeAt: number;
};
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
export class CoValueCore {
id: RawCoID;
node: LocalNode;
header: CoValueHeader;
_sessions: { [key: SessionID]: SessionLog };
_cachedContent?: CoValueImpl;
listeners: Set<(content?: CoValueImpl) => void> = new Set();
constructor(
header: CoValueHeader,
node: LocalNode,
internalInitSessions: { [key: SessionID]: SessionLog } = {}
) {
this.id = idforHeader(header);
this.header = header;
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(
account: GeneralizedControlledAccount,
currentSessionID: SessionID
): CoValueCore {
const newNode = this.node.testWithDifferentAccount(
account,
currentSessionID
);
return newNode.expectCoValueLoaded(this.id);
}
knownState(): CoValueKnownState {
return {
id: this.id,
header: true,
sessions: Object.fromEntries(
Object.entries(this.sessions).map(([k, v]) => [
k,
v.transactions.length,
])
),
};
}
get meta(): JsonValue {
return this.header?.meta ?? null;
}
nextTransactionID(): TransactionID {
const sessionID = this.node.currentSessionID;
return {
sessionID,
txIndex: this.sessions[sessionID]?.transactions.length || 0,
};
}
tryAddTransactions(
sessionID: SessionID,
newTransactions: Transaction[],
givenExpectedNewHash: Hash | undefined,
newSignature: Signature
): boolean {
const signerID = getAgentSignerID(
this.node.resolveAccountAgent(
accountOrAgentIDfromSessionID(sessionID),
"Expected to know signer of transaction"
)
);
if (!signerID) {
console.warn(
"Unknown agent",
accountOrAgentIDfromSessionID(sessionID)
);
return false;
}
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
sessionID,
newTransactions
);
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
console.warn("Invalid hash", {
expectedNewHash,
givenExpectedNewHash,
});
return false;
}
if (!verify(newSignature, expectedNewHash, signerID)) {
console.warn(
"Invalid signature",
newSignature,
expectedNewHash,
signerID
);
return false;
}
const transactions = this.sessions[sessionID]?.transactions ?? [];
transactions.push(...newTransactions);
this._sessions[sessionID] = {
transactions,
lastHash: expectedNewHash,
streamingHash: newStreamingHash,
lastSignature: newSignature,
};
this._cachedContent = undefined;
const content = this.getCurrentContent();
for (const listener of this.listeners) {
listener(content);
}
return true;
}
subscribe(listener: (content?: CoValueImpl) => void): () => void {
this.listeners.add(listener);
listener(this.getCurrentContent());
return () => {
this.listeners.delete(listener);
};
}
expectedNewHashAfter(
sessionID: SessionID,
newTransactions: Transaction[]
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
const streamingHash =
this.sessions[sessionID]?.streamingHash.clone() ??
new StreamingHash();
for (const transaction of newTransactions) {
streamingHash.update(transaction);
}
const newStreamingHash = streamingHash.clone();
return {
expectedNewHash: streamingHash.digest(),
newStreamingHash,
};
}
makeTransaction(
changes: JsonValue[],
privacy: "private" | "trusting"
): boolean {
const madeAt = Date.now();
let transaction: Transaction;
if (privacy === "private") {
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
if (!keySecret) {
throw new Error(
"Can't make transaction without read key secret"
);
}
transaction = {
privacy: "private",
madeAt,
keyUsed: keyID,
encryptedChanges: encryptForTransaction(changes, keySecret, {
in: this.id,
tx: this.nextTransactionID(),
}),
};
} else {
transaction = {
privacy: "trusting",
madeAt,
changes,
};
}
const sessionID = this.node.currentSessionID;
const { expectedNewHash } = this.expectedNewHashAfter(sessionID, [
transaction,
]);
const signature = sign(
this.node.account.currentSignerSecret(),
expectedNewHash
);
const success = this.tryAddTransactions(
sessionID,
[transaction],
expectedNewHash,
signature
);
if (success) {
void this.node.sync.syncCoValue(this);
}
return success;
}
getCurrentContent(): CoValueImpl {
if (this._cachedContent) {
return this._cachedContent;
}
if (this.header.type === "comap") {
this._cachedContent = new CoMap(this);
} else if (this.header.type === "colist") {
this._cachedContent = new CoList(this);
} else if (this.header.type === "costream") {
if (this.header.meta && this.header.meta.type === "binary") {
this._cachedContent = new BinaryCoStream(this);
} else {
this._cachedContent = new CoStream(this);
}
} else if (this.header.type === "static") {
this._cachedContent = new Static(this);
} else {
throw new Error(`Unknown coValue type ${this.header.type}`);
}
return this._cachedContent;
}
getValidSortedTransactions(): DecryptedTransaction[] {
const validTransactions = determineValidTransactions(this);
const allTransactions: DecryptedTransaction[] = validTransactions
.map(({ txID, tx }) => {
if (tx.privacy === "trusting") {
return {
txID,
madeAt: tx.madeAt,
changes: tx.changes,
};
} else {
const readKey = this.getReadKey(tx.keyUsed);
if (!readKey) {
return undefined;
} else {
const decrytedChanges = decryptForTransaction(
tx.encryptedChanges,
readKey,
{
in: this.id,
tx: txID,
}
);
if (!decrytedChanges) {
console.error(
"Failed to decrypt transaction despite having key"
);
return undefined;
}
return {
txID,
madeAt: tx.madeAt,
changes: decrytedChanges,
};
}
}
})
.filter((x): x is Exclude<typeof x, undefined> => !!x);
allTransactions.sort(
(a, b) =>
a.madeAt - b.madeAt ||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
a.txID.txIndex - b.txID.txIndex
);
return allTransactions;
}
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
if (this.header.ruleset.type === "group") {
const content = expectGroupContent(this.getCurrentContent());
const currentKeyId = content.get("readKey");
if (!currentKeyId) {
throw new Error("No readKey set");
}
const secret = this.getReadKey(currentKeyId);
return {
secret: secret,
id: currentKeyId,
};
} else if (this.header.ruleset.type === "ownedByGroup") {
return this.node
.expectCoValueLoaded(this.header.ruleset.group)
.getCurrentReadKey();
} else {
throw new Error(
"Only groups or values owned by groups have read secrets"
);
}
}
getReadKey(keyID: KeyID): KeySecret | undefined {
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}`
);
if (readKeyEntry) {
const revealer = accountOrAgentIDfromSessionID(
readKeyEntry.txID.sessionID
);
const revealerAgent = this.node.resolveAccountAgent(
revealer,
"Expected to know revealer"
);
const secret = unseal(
readKeyEntry.value,
this.node.account.currentSealerSecret(),
getAgentSealerID(revealerAgent),
{
in: this.id,
tx: readKeyEntry.txID,
}
);
if (secret) {
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
for (const field of content.keys()) {
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
const encryptingKeyID = field.split("_for_")[1] as KeyID;
const encryptingKeySecret =
this.getReadKey(encryptingKeyID);
if (!encryptingKeySecret) {
continue;
}
const encryptedPreviousKey = content.get(field)!;
const secret = decryptKeySecret(
{
encryptedID: keyID,
encryptingID: encryptingKeyID,
encrypted: encryptedPreviousKey,
},
encryptingKeySecret
);
if (secret) {
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 === "ownedByGroup") {
return this.node
.expectCoValueLoaded(this.header.ruleset.group)
.getReadKey(keyID);
} else {
throw new Error(
"Only groups or values owned by groups have read secrets"
);
}
}
getGroup(): Group {
if (this.header.ruleset.type !== "ownedByGroup") {
throw new Error("Only values owned by groups have groups");
}
return new Group(
expectGroupContent(
this.node
.expectCoValueLoaded(this.header.ruleset.group)
.getCurrentContent()
),
this.node
);
}
getTx(txID: TransactionID): Transaction | undefined {
return this.sessions[txID.sessionID]?.transactions[txID.txIndex];
}
newContentSince(
knownState: CoValueKnownState | undefined
): NewContentMessage | undefined {
const newContent: NewContentMessage = {
action: "content",
id: this.id,
header: knownState?.header ? undefined : this.header,
new: Object.fromEntries(
Object.entries(this.sessions)
.map(([sessionID, log]) => {
const newTransactions = log.transactions.slice(
knownState?.sessions[sessionID as SessionID] || 0
);
if (
newTransactions.length === 0 ||
!log.lastHash ||
!log.lastSignature
) {
return undefined;
}
return [
sessionID,
{
after:
knownState?.sessions[
sessionID as SessionID
] || 0,
newTransactions,
lastSignature: log.lastSignature,
},
];
})
.filter((x): x is Exclude<typeof x, undefined> => !!x)
),
};
if (!newContent.header && Object.keys(newContent.new).length === 0) {
return undefined;
}
return newContent;
}
getDependedOnCoValues(): RawCoID[] {
return this.header.ruleset.type === "group"
? expectGroupContent(this.getCurrentContent())
.keys()
.filter((k): k is AccountID => k.startsWith("co_"))
: this.header.ruleset.type === "ownedByGroup"
? [this.header.ruleset.group]
: [];
}
}

View File

@@ -1,445 +0,0 @@
import { JsonObject, JsonValue } from "../jsonValue.js";
import { CoID, ReadableCoValue, WriteableCoValue } from "../coValue.js";
import { CoValueCore, accountOrAgentIDfromSessionID } from "../coValueCore.js";
import { SessionID, TransactionID } from "../ids.js";
import { Group } from "../group.js";
import { AccountID, isAccountID } from "../account.js";
type OpID = TransactionID & { changeIdx: number };
type InsertionOpPayload<T extends JsonValue> =
| {
op: "pre";
value: T;
before: OpID | "end";
}
| {
op: "app";
value: T;
after: OpID | "start";
};
type DeletionOpPayload = {
op: "del";
insertion: OpID;
};
export type ListOpPayload<T extends JsonValue> =
| InsertionOpPayload<T>
| DeletionOpPayload;
type InsertionEntry<T extends JsonValue> = {
madeAt: number;
predecessors: OpID[];
successors: OpID[];
} & InsertionOpPayload<T>;
type DeletionEntry = {
madeAt: number;
deletionID: OpID;
} & DeletionOpPayload;
export class CoList<T extends JsonValue, Meta extends JsonObject | null = null>
implements ReadableCoValue
{
id: CoID<CoList<T, Meta>>;
type = "colist" as const;
core: CoValueCore;
/** @internal */
afterStart: OpID[];
/** @internal */
beforeEnd: OpID[];
/** @internal */
insertions: {
[sessionID: SessionID]: {
[txIdx: number]: {
[changeIdx: number]: InsertionEntry<T>;
};
};
};
/** @internal */
deletionsByInsertion: {
[deletedSessionID: SessionID]: {
[deletedTxIdx: number]: {
[deletedChangeIdx: number]: DeletionEntry[];
};
};
};
/** @internal */
constructor(core: CoValueCore) {
this.id = core.id as CoID<CoList<T, Meta>>;
this.core = core;
this.afterStart = [];
this.beforeEnd = [];
this.insertions = {};
this.deletionsByInsertion = {};
this.fillOpsFromCoValue();
}
get meta(): Meta {
return this.core.header.meta as Meta;
}
get group(): Group {
return this.core.getGroup();
}
/** @internal */
protected fillOpsFromCoValue() {
this.insertions = {};
this.deletionsByInsertion = {};
this.afterStart = [];
this.beforeEnd = [];
for (const {
txID,
changes,
madeAt,
} of this.core.getValidSortedTransactions()) {
for (const [changeIdx, changeUntyped] of changes.entries()) {
const change = changeUntyped as ListOpPayload<T>;
if (change.op === "pre" || change.op === "app") {
let sessionEntry = this.insertions[txID.sessionID];
if (!sessionEntry) {
sessionEntry = {};
this.insertions[txID.sessionID] = sessionEntry;
}
let txEntry = sessionEntry[txID.txIndex];
if (!txEntry) {
txEntry = {};
sessionEntry[txID.txIndex] = txEntry;
}
txEntry[changeIdx] = {
madeAt,
predecessors: [],
successors: [],
...change,
};
if (change.op === "pre") {
if (change.before === "end") {
this.beforeEnd.push({
...txID,
changeIdx,
});
} else {
const beforeEntry =
this.insertions[change.before.sessionID]?.[
change.before.txIndex
]?.[change.before.changeIdx];
if (!beforeEntry) {
throw new Error(
"Not yet implemented: insertion before missing op " +
change.before
);
}
beforeEntry.predecessors.splice(0, 0, {
...txID,
changeIdx,
});
}
} else {
if (change.after === "start") {
this.afterStart.push({
...txID,
changeIdx,
});
} else {
const afterEntry =
this.insertions[change.after.sessionID]?.[
change.after.txIndex
]?.[change.after.changeIdx];
if (!afterEntry) {
throw new Error(
"Not yet implemented: insertion after missing op " +
change.after
);
}
afterEntry.successors.push({
...txID,
changeIdx,
});
}
}
} else if (change.op === "del") {
let sessionEntry =
this.deletionsByInsertion[change.insertion.sessionID];
if (!sessionEntry) {
sessionEntry = {};
this.deletionsByInsertion[change.insertion.sessionID] =
sessionEntry;
}
let txEntry = sessionEntry[change.insertion.txIndex];
if (!txEntry) {
txEntry = {};
sessionEntry[change.insertion.txIndex] = txEntry;
}
let changeEntry = txEntry[change.insertion.changeIdx];
if (!changeEntry) {
changeEntry = [];
txEntry[change.insertion.changeIdx] = changeEntry;
}
changeEntry.push({
madeAt,
deletionID: {
...txID,
changeIdx,
},
...change,
});
} else {
throw new Error(
"Unknown list operation " +
(change as { op: unknown }).op
);
}
}
}
}
/** Get the item currently at `idx`. */
get(idx: number): T | undefined {
const entry = this.entries()[idx];
if (!entry) {
return undefined;
}
return entry.value;
}
/** Returns the current items in the CoList as an array. */
asArray(): T[] {
return this.entries().map((entry) => entry.value);
}
entries(): { value: T; madeAt: number; opID: OpID }[] {
const arr: { value: T; madeAt: number; opID: OpID }[] = [];
for (const opID of this.afterStart) {
this.fillArrayFromOpID(opID, arr);
}
for (const opID of this.beforeEnd) {
this.fillArrayFromOpID(opID, arr);
}
return arr;
}
/** @internal */
private fillArrayFromOpID(
opID: OpID,
arr: { value: T; madeAt: number; opID: OpID }[]
) {
const entry =
this.insertions[opID.sessionID]?.[opID.txIndex]?.[opID.changeIdx];
if (!entry) {
throw new Error("Missing op " + opID);
}
for (const predecessor of entry.predecessors) {
this.fillArrayFromOpID(predecessor, arr);
}
const deleted =
(this.deletionsByInsertion[opID.sessionID]?.[opID.txIndex]?.[
opID.changeIdx
]?.length || 0) > 0;
if (!deleted) {
arr.push({
value: entry.value,
madeAt: entry.madeAt,
opID,
});
}
for (const successor of entry.successors) {
this.fillArrayFromOpID(successor, arr);
}
}
/** Returns the accountID of the account that inserted value at the given index. */
whoInserted(idx: number): AccountID | undefined {
const entry = this.entries()[idx];
if (!entry) {
return undefined;
}
const accountID = accountOrAgentIDfromSessionID(entry.opID.sessionID);
if (isAccountID(accountID)) {
return accountID;
} else {
return undefined;
}
}
/** Returns the current items in the CoList as an array. (alias of `asArray`) */
toJSON(): T[] {
return this.asArray();
}
map<U>(mapper: (value: T, idx: number) => U): U[] {
return this.entries().map((entry, idx) => mapper(entry.value, idx));
}
filter<U extends T>(predicate: (value: T, idx: number) => value is U): U[];
filter(predicate: (value: T, idx: number) => boolean): T[] {
return this.entries()
.filter((entry, idx) => predicate(entry.value, idx))
.map((entry) => entry.value);
}
reduce<U>(
reducer: (accumulator: U, value: T, idx: number) => U,
initialValue: U
): U {
return this.entries().reduce(
(accumulator, entry, idx) => reducer(accumulator, entry.value, idx),
initialValue
);
}
subscribe(listener: (coMap: CoList<T, Meta>) => void): () => void {
return this.core.subscribe((content) => {
listener(content as CoList<T, Meta>);
});
}
edit(
changer: (editable: WriteableCoList<T, Meta>) => void
): CoList<T, Meta> {
const editable = new WriteableCoList<T, Meta>(this.core);
changer(editable);
return new CoList(this.core);
}
}
export class WriteableCoList<
T extends JsonValue,
Meta extends JsonObject | null = null
>
extends CoList<T, Meta>
implements WriteableCoValue
{
/** @internal */
edit(
_changer: (editable: WriteableCoList<T, Meta>) => void
): CoList<T, Meta> {
throw new Error("Already editing.");
}
/** Appends a new item after index `after`.
*
* If `privacy` is `"private"` **(default)**, both `value` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
*
* If `privacy` is `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
append(
after: number,
value: T,
privacy: "private" | "trusting" = "private"
): void {
const entries = this.entries();
let opIDBefore;
if (entries.length > 0) {
const entryBefore = entries[after];
if (!entryBefore) {
throw new Error("Invalid index " + after);
}
opIDBefore = entryBefore.opID;
} else {
if (after !== 0) {
throw new Error("Invalid index " + after);
}
opIDBefore = "start";
}
this.core.makeTransaction(
[
{
op: "app",
value,
after: opIDBefore,
},
],
privacy
);
this.fillOpsFromCoValue();
}
/** Pushes a new item to the end of the list.
*
* If `privacy` is `"private"` **(default)**, both `value` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
*
* If `privacy` is `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
push(value: T, privacy: "private" | "trusting" = "private"): void {
// TODO: optimize
const entries = this.entries();
this.append(
entries.length > 0 ? entries.length - 1 : 0,
value,
privacy
);
}
/**
* Prepends a new item before index `before`.
*
* If `privacy` is `"private"` **(default)**, both `value` is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
*
* If `privacy` is `"trusting"`, both `value` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers.
*/
prepend(
before: number,
value: T,
privacy: "private" | "trusting" = "private"
): void {
const entries = this.entries();
let opIDAfter;
if (entries.length > 0) {
const entryAfter = entries[before];
if (entryAfter) {
opIDAfter = entryAfter.opID;
} else {
if (before !== entries.length) {
throw new Error("Invalid index " + before);
}
opIDAfter = "end";
}
} else {
if (before !== 0) {
throw new Error("Invalid index " + before);
}
opIDAfter = "end";
}
this.core.makeTransaction(
[
{
op: "pre",
value,
before: opIDAfter,
},
],
privacy
);
this.fillOpsFromCoValue();
}
/** Deletes the item at index `at` from the list.
*
* If `privacy` is `"private"` **(default)**, the fact of this deletion is encrypted in the transaction, only readable by other members of the group this `CoList` belongs to. Not even sync servers can see the content in plaintext.
*
* If `privacy` is `"trusting"`, the fact of this deletion is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
delete(at: number, privacy: "private" | "trusting" = "private"): void {
const entries = this.entries();
const entry = entries[at];
if (!entry) {
throw new Error("Invalid index " + at);
}
this.core.makeTransaction(
[
{
op: "del",
insertion: entry.opID,
},
],
privacy
);
this.fillOpsFromCoValue();
}
}

View File

@@ -1,249 +0,0 @@
import { JsonObject, JsonValue } from "../jsonValue.js";
import { CoID, ReadableCoValue, WriteableCoValue } from "../coValue.js";
import { CoValueCore } from "../coValueCore.js";
import { Group } from "../group.js";
import { SessionID } from "../ids.js";
import { base64url } from "@scure/base";
export type BinaryChunkInfo = {
mimeType: string;
fileName?: string;
totalSizeBytes?: number;
};
export type BinaryStreamStart = {
type: "start";
} & BinaryChunkInfo;
export type BinaryStreamChunk = {
type: "chunk";
chunk: `U${string}`;
};
export type BinaryStreamEnd = {
type: "end";
};
export type BinaryCoStreamMeta = JsonObject & { type: "binary" };
export type BinaryStreamItem =
| BinaryStreamStart
| BinaryStreamChunk
| BinaryStreamEnd;
export class CoStream<
T extends JsonValue,
Meta extends JsonObject | null = null
> implements ReadableCoValue
{
id: CoID<CoStream<T, Meta>>;
type = "costream" as const;
core: CoValueCore;
items: {
[key: SessionID]: T[];
};
constructor(core: CoValueCore) {
this.id = core.id as CoID<CoStream<T, Meta>>;
this.core = core;
this.items = {};
}
get meta(): Meta {
return this.core.header.meta as Meta;
}
get group(): Group {
return this.core.getGroup();
}
/** @internal */
protected fillFromCoValue() {
this.items = {};
for (const {
txID,
changes,
} of this.core.getValidSortedTransactions()) {
for (const changeUntyped of changes) {
const change = changeUntyped as T;
let entries = this.items[txID.sessionID];
if (!entries) {
entries = [];
this.items[txID.sessionID] = entries;
}
entries.push(change);
}
}
}
getSingleStream(): T[] | undefined {
if (Object.keys(this.items).length === 0) {
return undefined;
} else if (Object.keys(this.items).length !== 1) {
throw new Error(
"CoStream.getSingleStream() can only be called when there is exactly one stream"
);
}
return Object.values(this.items)[0];
}
toJSON(): {
[key: SessionID]: T[];
} {
return this.items;
}
subscribe(listener: (coMap: CoStream<T, Meta>) => void): () => void {
return this.core.subscribe((content) => {
listener(content as CoStream<T, Meta>);
});
}
edit(
changer: (editable: WriteableCoStream<T, Meta>) => void
): CoStream<T, Meta> {
const editable = new WriteableCoStream<T, Meta>(this.core);
changer(editable);
return new CoStream(this.core);
}
}
export class BinaryCoStream<
Meta extends BinaryCoStreamMeta = { type: "binary" }
>
extends CoStream<BinaryStreamItem, Meta>
implements ReadableCoValue
{
id!: CoID<BinaryCoStream<Meta>>;
getBinaryChunks():
| (BinaryChunkInfo & { chunks: Uint8Array[]; finished: boolean })
| undefined {
const items = this.getSingleStream();
if (!items) return;
const start = items[0];
if (start?.type !== "start") {
console.error("Invalid binary stream start", start);
return;
}
const chunks: Uint8Array[] = [];
for (const item of items.slice(1)) {
if (item.type === "end") {
return {
mimeType: start.mimeType,
fileName: start.fileName,
totalSizeBytes: start.totalSizeBytes,
chunks,
finished: true,
};
}
if (item.type !== "chunk") {
console.error("Invalid binary stream chunk", item);
return undefined;
}
chunks.push(base64url.decode(item.chunk.slice(1)));
}
return {
mimeType: start.mimeType,
fileName: start.fileName,
totalSizeBytes: start.totalSizeBytes,
chunks,
finished: false,
};
}
edit(
changer: (editable: WriteableBinaryCoStream<Meta>) => void
): BinaryCoStream<Meta> {
const editable = new WriteableBinaryCoStream<Meta>(this.core);
changer(editable);
return new BinaryCoStream(this.core);
}
}
export class WriteableCoStream<
T extends JsonValue,
Meta extends JsonObject | null = null
>
extends CoStream<T, Meta>
implements WriteableCoValue
{
/** @internal */
edit(
_changer: (editable: WriteableCoStream<T, Meta>) => void
): CoStream<T, Meta> {
throw new Error("Already editing.");
}
push(item: T, privacy: "private" | "trusting" = "private") {
this.core.makeTransaction([item], privacy);
this.fillFromCoValue();
}
}
export class WriteableBinaryCoStream<
Meta extends BinaryCoStreamMeta = { type: "binary" }
>
extends BinaryCoStream<Meta>
implements WriteableCoValue
{
/** @internal */
edit(
_changer: (editable: WriteableBinaryCoStream<Meta>) => void
): BinaryCoStream<Meta> {
throw new Error("Already editing.");
}
/** @internal */
push(
item: BinaryStreamItem,
privacy: "private" | "trusting" = "private"
) {
WriteableCoStream.prototype.push.call(this, item, privacy);
}
startBinaryStream(
settings: BinaryChunkInfo,
privacy: "private" | "trusting" = "private"
) {
this.push(
{
type: "start",
...settings,
} satisfies BinaryStreamStart,
privacy
);
}
pushBinaryStreamChunk(
chunk: Uint8Array,
privacy: "private" | "trusting" = "private"
) {
this.push(
{
type: "chunk",
chunk: `U${base64url.encode(chunk)}`,
} satisfies BinaryStreamChunk,
privacy
);
}
endBinaryStream(privacy: "private" | "trusting" = "private") {
this.push(
{
type: "end",
} satisfies BinaryStreamEnd,
privacy
);
}
}

View File

@@ -1,31 +0,0 @@
import { JsonObject } from '../jsonValue.js';
import { CoID, ReadableCoValue } from '../coValue.js';
import { CoValueCore } from '../coValueCore.js';
import { Group } from '../index.js';
export class Static<T extends JsonObject> implements ReadableCoValue{
id: CoID<Static<T>>;
type = "static" as const;
core: CoValueCore;
constructor(core: CoValueCore) {
this.id = core.id as CoID<Static<T>>;
this.core = core;
}
get meta(): T {
return this.core.header.meta as T;
}
get group(): Group {
return this.core.getGroup();
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
subscribe(_listener: (coMap: Static<T>) => void): () => void {
throw new Error("Method not implemented.");
}
}

View File

@@ -0,0 +1,175 @@
import { accountOrAgentIDfromSessionID } from "./coValue.js";
import { createdNowUnique } from "./crypto.js";
import { LocalNode } from "./node.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
test("Empty COJSON Map works", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
expect([...content.keys()]).toEqual([]);
expect(content.toJSON()).toEqual({});
});
test("Can insert and delete Map entries in edit()", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
editable.set("hello", "world", "trusting");
expect(editable.get("hello")).toEqual("world");
editable.set("foo", "bar", "trusting");
expect(editable.get("foo")).toEqual("bar");
expect([...editable.keys()]).toEqual(["hello", "foo"]);
editable.delete("foo", "trusting");
expect(editable.get("foo")).toEqual(undefined);
});
});
test("Can get map entry values at different points in time", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
const beforeA = Date.now();
while (Date.now() < beforeA + 10) {}
editable.set("hello", "A", "trusting");
const beforeB = Date.now();
while (Date.now() < beforeB + 10) {}
editable.set("hello", "B", "trusting");
const beforeC = Date.now();
while (Date.now() < beforeC + 10) {}
editable.set("hello", "C", "trusting");
expect(editable.get("hello")).toEqual("C");
expect(editable.getAtTime("hello", Date.now())).toEqual("C");
expect(editable.getAtTime("hello", beforeA)).toEqual(undefined);
expect(editable.getAtTime("hello", beforeB)).toEqual("A");
expect(editable.getAtTime("hello", beforeC)).toEqual("B");
});
});
test("Can get all historic values of key", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
editable.set("hello", "A", "trusting");
const txA = editable.getLastTxID("hello");
editable.set("hello", "B", "trusting");
const txB = editable.getLastTxID("hello");
editable.delete("hello", "trusting");
const txDel = editable.getLastTxID("hello");
editable.set("hello", "C", "trusting");
const txC = editable.getLastTxID("hello");
expect(editable.getHistory("hello")).toEqual([
{
txID: txA,
value: "A",
at: txA && coValue.getTx(txA)?.madeAt,
},
{
txID: txB,
value: "B",
at: txB && coValue.getTx(txB)?.madeAt,
},
{
txID: txDel,
value: undefined,
at: txDel && coValue.getTx(txDel)?.madeAt,
},
{
txID: txC,
value: "C",
at: txC && coValue.getTx(txC)?.madeAt,
},
]);
});
});
test("Can get last tx ID for a key", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const coValue = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...createdNowUnique(),
});
const content = coValue.getCurrentContent();
if (content.type !== "comap") {
throw new Error("Expected map");
}
expect(content.type).toEqual("comap");
content.edit((editable) => {
expect(editable.getLastTxID("hello")).toEqual(undefined);
editable.set("hello", "A", "trusting");
const sessionID = editable.getLastTxID("hello")?.sessionID;
expect(sessionID && accountOrAgentIDfromSessionID(sessionID)).toEqual(
node.account.id
);
expect(editable.getLastTxID("hello")?.txIndex).toEqual(0);
editable.set("hello", "B", "trusting");
expect(editable.getLastTxID("hello")?.txIndex).toEqual(1);
editable.set("hello", "C", "trusting");
expect(editable.getLastTxID("hello")?.txIndex).toEqual(2);
});
});

View File

@@ -0,0 +1,26 @@
import { JsonObject, JsonValue } from "./jsonValue.js";
import { RawCoID } from "./ids.js";
import { CoMap } from "./contentTypes/coMap.js";
import { CoStream } from "./contentTypes/coStream.js";
import { Static } from "./contentTypes/static.js";
import { CoList } from "./contentTypes/coList.js";
export type CoID<T extends ContentType> = RawCoID & {
readonly __type: T;
};
export type ContentType =
| CoMap<{ [key: string]: JsonValue }, JsonObject | null>
| CoList<JsonValue, JsonObject | null>
| CoStream<JsonValue, JsonObject | null>
| Static<JsonObject>;
export function expectMap(
content: ContentType
): CoMap<{ [key: string]: string }, JsonObject | null> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<{ [key: string]: string }, JsonObject | null>;
}

View File

@@ -0,0 +1,24 @@
import { JsonObject, JsonValue } from '../jsonValue.js';
import { CoID } from '../contentType.js';
import { CoValue } from '../coValue.js';
export class CoList<T extends JsonValue, Meta extends JsonObject | null = null> {
id: CoID<CoList<T, Meta>>;
type = "colist" as const;
coValue: CoValue;
constructor(coValue: CoValue) {
this.id = coValue.id as CoID<CoList<T, Meta>>;
this.coValue = coValue;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
subscribe(listener: (coMap: CoList<T, Meta>) => void): () => void {
return this.coValue.subscribe((content) => {
listener(content as CoList<T, Meta>);
});
}
}

View File

@@ -1,9 +1,8 @@
import { JsonObject, JsonValue } from '../jsonValue.js';
import { TransactionID } from '../ids.js';
import { CoID, ReadableCoValue, WriteableCoValue } from '../coValue.js';
import { CoValueCore, accountOrAgentIDfromSessionID } from '../coValueCore.js';
import { CoID } from '../contentType.js';
import { CoValue, accountOrAgentIDfromSessionID } from '../coValue.js';
import { AccountID, isAccountID } from '../account.js';
import { Group } from '../group.js';
type MapOp<K extends string, V extends JsonValue> = {
txID: TransactionID;
@@ -13,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: "set";
op: "insert";
key: K;
value: V;
} |
{
op: "del";
op: "delete";
key: K;
};
@@ -28,41 +27,29 @@ export type MapM<M extends { [key: string]: JsonValue; }> = {
[KK in MapK<M>]: M[KK];
}
/** A collaborative map with precise shape `M` and optional static metadata `Meta` */
export class CoMap<
M extends { [key: string]: JsonValue; },
Meta extends JsonObject | null = null,
> implements ReadableCoValue {
> {
id: CoID<CoMap<MapM<M>, Meta>>;
coValue: CoValue;
type = "comap" as const;
core: CoValueCore;
/** @internal */
ops: {
[KK in MapK<M>]?: MapOp<KK, M[KK]>[];
};
/** @internal */
constructor(core: CoValueCore) {
this.id = core.id as CoID<CoMap<MapM<M>, Meta>>;
this.core = core;
constructor(coValue: CoValue) {
this.id = coValue.id as CoID<CoMap<MapM<M>, Meta>>;
this.coValue = coValue;
this.ops = {};
this.fillOpsFromCoValue();
}
get meta(): Meta {
return this.core.header.meta as Meta;
}
get group(): Group {
return this.core.getGroup();
}
/** @internal */
protected fillOpsFromCoValue() {
this.ops = {};
for (const { txID, changes, madeAt } of this.core.getValidSortedTransactions()) {
for (const { txID, changes, madeAt } of this.coValue.getValidSortedTransactions()) {
for (const [changeIdx, changeUntyped] of (
changes
).entries()) {
@@ -86,7 +73,6 @@ export class CoMap<
return Object.keys(this.ops) as MapK<M>[];
}
/** Returns the current value for the given key. */
get<K extends MapK<M>>(key: K): M[K] | undefined {
const ops = this.ops[key];
if (!ops) {
@@ -95,7 +81,7 @@ export class CoMap<
const lastEntry = ops[ops.length - 1]!;
if (lastEntry.op === "del") {
if (lastEntry.op === "delete") {
return undefined;
} else {
return lastEntry.value;
@@ -114,15 +100,14 @@ export class CoMap<
return undefined;
}
if (lastOpBeforeOrAtTime.op === "del") {
if (lastOpBeforeOrAtTime.op === "delete") {
return undefined;
} else {
return lastOpBeforeOrAtTime.value;
}
}
/** Returns the accountID of the last account to modify the value for the given key. */
whoEdited<K extends MapK<M>>(key: K): AccountID | undefined {
getLastEditor<K extends MapK<M>>(key: K): AccountID | undefined {
const tx = this.getLastTxID(key);
if (!tx) {
return undefined;
@@ -154,7 +139,7 @@ export class CoMap<
const lastEntry = ops[ops.length - 1]!;
if (lastEntry.op === "del") {
if (lastEntry.op === "delete") {
return undefined;
} else {
return { at: lastEntry.madeAt, txID: lastEntry.txID, value: lastEntry.value };
@@ -170,7 +155,7 @@ export class CoMap<
const history: { at: number; txID: TransactionID; value: M[K] | undefined; }[] = [];
for (const op of ops) {
if (op.op === "del") {
if (op.op === "delete") {
history.push({ at: op.madeAt, txID: op.txID, value: undefined });
} else {
history.push({ at: op.madeAt, txID: op.txID, value: op.value });
@@ -193,37 +178,28 @@ export class CoMap<
return json;
}
subscribe(listener: (coMap: CoMap<M, Meta>) => void): () => void {
return this.core.subscribe((content) => {
listener(content as CoMap<M, Meta>);
});
edit(changer: (editable: WriteableCoMap<M, Meta>) => void): CoMap<M, Meta> {
const editable = new WriteableCoMap<M, Meta>(this.coValue);
changer(editable);
return new CoMap(this.coValue);
}
edit(changer: (editable: WriteableCoMap<M, Meta>) => void): CoMap<M, Meta> {
const editable = new WriteableCoMap<M, Meta>(this.core);
changer(editable);
return new CoMap(this.core);
subscribe(listener: (coMap: CoMap<M, Meta>) => void): () => void {
return this.coValue.subscribe((content) => {
listener(content as CoMap<M, Meta>);
});
}
}
export class WriteableCoMap<
M extends { [key: string]: JsonValue; },
Meta extends JsonObject | null = null,
> extends CoMap<M, Meta> implements WriteableCoValue {
/** @internal */
edit(_changer: (editable: WriteableCoMap<M, Meta>) => void): CoMap<M, Meta> {
throw new Error("Already editing.");
}
/** Sets a new value for the given key.
*
* If `privacy` is `"private"` **(default)**, both `key` and `value` are encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
*
* If `privacy` is `"trusting"`, both `key` and `value` are stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
> extends CoMap<M, Meta> {
set<K extends MapK<M>>(key: K, value: M[K], privacy: "private" | "trusting" = "private"): void {
this.core.makeTransaction([
this.coValue.makeTransaction([
{
op: "set",
op: "insert",
key,
value,
},
@@ -232,15 +208,10 @@ export class WriteableCoMap<
this.fillOpsFromCoValue();
}
/** Deletes the value for the given key (setting it to undefined).
*
* If `privacy` is `"private"` **(default)**, `key` is encrypted in the transaction, only readable by other members of the group this `CoMap` belongs to. Not even sync servers can see the content in plaintext.
*
* If `privacy` is `"trusting"`, `key` is stored in plaintext in the transaction, visible to everyone who gets a hold of it, including sync servers. */
delete(key: MapK<M>, privacy: "private" | "trusting" = "private"): void {
this.core.makeTransaction([
this.coValue.makeTransaction([
{
op: "del",
op: "delete",
key,
},
], privacy);

View File

@@ -0,0 +1,24 @@
import { JsonObject, JsonValue } from '../jsonValue.js';
import { CoID } from '../contentType.js';
import { CoValue } from '../coValue.js';
export class CoStream<T extends JsonValue, Meta extends JsonObject | null = null> {
id: CoID<CoStream<T, Meta>>;
type = "costream" as const;
coValue: CoValue;
constructor(coValue: CoValue) {
this.id = coValue.id as CoID<CoStream<T, Meta>>;
this.coValue = coValue;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
subscribe(listener: (coMap: CoStream<T, Meta>) => void): () => void {
return this.coValue.subscribe((content) => {
listener(content as CoStream<T, Meta>);
});
}
}

View File

@@ -0,0 +1,22 @@
import { JsonObject } from '../jsonValue.js';
import { CoID } from '../contentType.js';
import { CoValue } from '../coValue.js';
export class Static<T extends JsonObject> {
id: CoID<Static<T>>;
type = "static" as const;
coValue: CoValue;
constructor(coValue: CoValue) {
this.id = coValue.id as CoID<Static<T>>;
this.coValue = coValue;
}
toJSON(): JsonObject {
throw new Error("Method not implemented.");
}
subscribe(_listener: (coMap: Static<T>) => void): () => void {
throw new Error("Method not implemented.");
}
}

View File

@@ -20,7 +20,6 @@ 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" };
@@ -50,7 +49,7 @@ test("encrypting round-trips, but invalid receiver can't unseal", () => {
const nOnceMaterial = {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
} as const;
const sealed = seal(
@@ -102,22 +101,22 @@ test("Encryption for transactions round-trips", () => {
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
});
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
});
const decrypted1 = decryptForTransaction(encrypted1, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
});
const decrypted2 = decryptForTransaction(encrypted2, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
});
expect([decrypted1, decrypted2]).toEqual([{ a: "hello" }, { b: "world" }]);
@@ -129,22 +128,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" as SessionID, txIndex: 0 },
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
});
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
});
const decrypted1 = decryptForTransaction(encrypted1, secret2, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
});
const decrypted2 = decryptForTransaction(encrypted2, secret2, {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
tx: { sessionID: "co_zTEST_session_zTEST", 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,7 +21,9 @@ 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 {
@@ -93,16 +95,20 @@ 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 {
@@ -133,17 +139,24 @@ 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>(
@@ -156,7 +169,9 @@ 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));
@@ -206,11 +221,10 @@ 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, shortHashLength)
blake3(textEncoder.encode(stableStringify(value))).slice(0, 19)
)}`;
}
@@ -260,10 +274,7 @@ 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,
@@ -317,10 +328,7 @@ 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 {
@@ -336,35 +344,10 @@ 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

@@ -1,47 +0,0 @@
import { LocalNode, CoMap, CoList, CoStream, BinaryCoStream } from "./index";
import { randomAnonymousAccountAndSessionID } from "./testUtils";
test("Can create a CoMap in a group", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const group = node.createGroup();
const map = group.createMap();
expect(map.core.getCurrentContent().type).toEqual("comap");
expect(map instanceof CoMap).toEqual(true);
});
test("Can create a CoList in a group", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const group = node.createGroup();
const list = group.createList();
expect(list.core.getCurrentContent().type).toEqual("colist");
expect(list instanceof CoList).toEqual(true);
})
test("Can create a CoStream in a group", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const group = node.createGroup();
const stream = group.createStream();
expect(stream.core.getCurrentContent().type).toEqual("costream");
expect(stream instanceof CoStream).toEqual(true);
});
test("Can create a BinaryCoStream in a group", () => {
const node = new LocalNode(...randomAnonymousAccountAndSessionID());
const group = node.createGroup();
const stream = group.createBinaryStream();
expect(stream.core.getCurrentContent().type).toEqual("costream");
expect(stream.meta.type).toEqual("binary");
expect(stream instanceof BinaryCoStream).toEqual(true);
})

View File

@@ -1,335 +0,0 @@
import { CoID, CoValueImpl } from "./coValue.js";
import { CoMap } from "./coValues/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 { AgentID, SessionID, isAgentID } from "./ids.js";
import { AccountID, GeneralizedControlledAccount, Profile } from "./account.js";
import { Role } from "./permissions.js";
import { base58 } from "@scure/base";
import { CoList } from "./coValues/coList.js";
import { BinaryCoStream, BinaryCoStreamMeta, CoStream } from "./coValues/coStream.js";
export type GroupContent = {
profile: CoID<Profile> | null;
[key: AccountID | AgentID]: Role;
readKey: KeyID;
[revelationFor: `${KeyID}_for_${AccountID | AgentID}`]: Sealed<KeySecret>;
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
KeySecret,
{ encryptedID: KeyID; encryptingID: KeyID }
>;
};
export function expectGroupContent(
content: CoValueImpl
): CoMap<GroupContent, JsonObject | null> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<GroupContent, JsonObject | null>;
}
/** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
*
* A `Group` object exposes methods for permission management and allows you to create new CoValues owned by that group.
*
* (Internally, a `Group` is also just a `CoMap`, mapping member accounts to roles and containing some
* state management for making cryptographic keys available to current members)
*
* @example
* You typically get a group from a CoValue that you already have loaded:
*
* ```typescript
* const group = coMap.group;
* ```
*
* @example
* Or, you can create a new group with a `LocalNode`:
*
* ```typescript
* const localNode.createGroup();
* ```
* */
export class Group {
underlyingMap: CoMap<GroupContent, JsonObject | null>;
/** @internal */
node: LocalNode;
/** @internal */
constructor(
underlyingMap: CoMap<GroupContent, JsonObject | null>,
node: LocalNode
) {
this.underlyingMap = underlyingMap;
this.node = node;
}
/** Returns the `CoID` of the `Group`. */
get id(): CoID<CoMap<GroupContent, JsonObject | null>> {
return this.underlyingMap.id;
}
/** Returns the current role of a given account. */
roleOf(accountID: AccountID): Role | undefined {
return this.roleOfInternal(accountID);
}
/** @internal */
roleOfInternal(accountID: AccountID | AgentID): Role | undefined {
return this.underlyingMap.get(accountID);
}
/** Returns the role of the current account in the group. */
myRole(): Role | undefined {
return this.roleOfInternal(this.node.account.id);
}
/** Directly grants a new member a role in the group. The current account must be an
* admin to be able to do so. Throws otherwise. */
addMember(accountID: AccountID, role: Role) {
this.addMemberInternal(accountID, role);
}
/** @internal */
addMemberInternal(accountID: AccountID | AgentID, role: Role) {
this.underlyingMap = this.underlyingMap.edit((map) => {
const currentReadKey = this.underlyingMap.core.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.underlyingMap.core.node.account.currentSealerSecret(),
getAgentSealerID(agent),
{
in: this.underlyingMap.core.id,
tx: this.underlyingMap.core.nextTransactionID(),
}
),
"trusting"
);
});
}
/** @internal */
rotateReadKey() {
const currentlyPermittedReaders = this.underlyingMap
.keys()
.filter((key) => {
if (key.startsWith("co_") || isAgentID(key)) {
const role = this.underlyingMap.get(key);
return (
role === "admin" ||
role === "writer" ||
role === "reader"
);
} else {
return false;
}
}) as (AccountID | AgentID)[];
const maybeCurrentReadKey = this.underlyingMap.core.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.underlyingMap = this.underlyingMap.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.underlyingMap.core.node.account.currentSealerSecret(),
getAgentSealerID(reader),
{
in: this.underlyingMap.core.id,
tx: this.underlyingMap.core.nextTransactionID(),
}
),
"trusting"
);
}
map.set(
`${currentReadKey.id}_for_${newReadKey.id}`,
encryptKeySecret({
encrypting: newReadKey,
toEncrypt: currentReadKey,
}).encrypted,
"trusting"
);
map.set("readKey", newReadKey.id, "trusting");
});
}
/** Strips the specified member of all roles (preventing future writes in
* the group and owned values) and rotates the read encryption key for that group
* (preventing reads of new content in the group and owned values) */
removeMember(accountID: AccountID) {
this.removeMemberInternal(accountID);
}
/** @internal */
removeMemberInternal(accountID: AccountID | AgentID) {
this.underlyingMap = this.underlyingMap.edit((map) => {
map.set(accountID, "revoked", "trusting");
});
this.rotateReadKey();
}
/** Creates an invite for new members to indirectly join the group, allowing them to grant themselves the specified role with the InviteSecret (a string starting with "inviteSecret_") - use `LocalNode.acceptInvite()` for this purpose. */
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
const secretSeed = newRandomSecretSeed();
const inviteSecret = agentSecretFromSecretSeed(secretSeed);
const inviteID = getAgentID(inviteSecret);
this.addMemberInternal(inviteID, `${role}Invite` as Role);
return inviteSecretFromSecretSeed(secretSeed);
}
/** Creates a new `CoMap` within this group, with the specified specialized
* `CoMap` type `M` and optional static metadata. */
createMap<M extends CoMap<{ [key: string]: JsonValue }, JsonObject | null>>(
meta?: M["meta"]
): M {
return this.node
.createCoValue({
type: "comap",
ruleset: {
type: "ownedByGroup",
group: this.underlyingMap.id,
},
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as M;
}
/** Creates a new `CoList` within this group, with the specified specialized
* `CoList` type `L` and optional static metadata. */
createList<L extends CoList<JsonValue, JsonObject | null>>(
meta?: L["meta"]
): L {
return this.node
.createCoValue({
type: "colist",
ruleset: {
type: "ownedByGroup",
group: this.underlyingMap.id,
},
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as L;
}
createStream<C extends CoStream<JsonValue, JsonObject | null>>(
meta?: C["meta"]
): C {
return this.node
.createCoValue({
type: "costream",
ruleset: {
type: "ownedByGroup",
group: this.underlyingMap.id,
},
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as C;
}
createBinaryStream<
C extends BinaryCoStream<BinaryCoStreamMeta>
>(meta: C["meta"] = { type: "binary" }): C {
return this.node
.createCoValue({
type: "costream",
ruleset: {
type: "ownedByGroup",
group: this.underlyingMap.id,
},
meta: meta,
...createdNowUnique(),
})
.getCurrentContent() as C;
}
/** @internal */
testWithDifferentAccount(
account: GeneralizedControlledAccount,
sessionId: SessionID
): Group {
return new Group(
expectGroupContent(
this.underlyingMap.core
.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,20 +1,7 @@
import { AccountID } from './account.js';
import { base58 } from "@scure/base";
import { shortHashLength } from './crypto.js';
import { AccountIDOrAgentID } from './account.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}`;
@@ -23,4 +10,4 @@ export function isAgentID(id: string): id is AgentID {
return typeof id === "string" && id.startsWith("sealer_") && id.includes("/signer_");
}
export type SessionID = `${AccountID | AgentID}_session_z${string}`;
export type SessionID = `${AccountIDOrAgentID}_session_z${string}`;

View File

@@ -1,41 +1,23 @@
import { CoValueCore, newRandomSessionID } from "./coValueCore.js";
import { CoValue, newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import type { CoValue, ReadableCoValue } from "./coValue.js";
import { CoMap, WriteableCoMap } from "./coValues/coMap.js";
import { CoList, WriteableCoList } from "./coValues/coList.js";
import {
CoStream,
WriteableCoStream,
BinaryCoStream,
WriteableBinaryCoStream,
} from "./coValues/coStream.js";
import { CoMap } from "./contentTypes/coMap.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, AgentID } from "./ids.js";
import type { CoID, CoValueImpl } from "./coValue.js";
import type { BinaryChunkInfo, BinaryCoStreamMeta } from "./coValues/coStream.js";
import type { CoID, ContentType } from "./contentType.js";
import type { JsonValue } from "./jsonValue.js";
import type { SyncMessage, Peer } from "./sync.js";
import type { SyncMessage } from "./sync.js";
import type { AgentSecret } from "./crypto.js";
import type { AccountID, Profile } from "./account.js";
import type { InviteSecret } from "./group.js";
type Value = JsonValue | CoValueImpl;
type Value = JsonValue | ContentType;
/** @hidden */
export const cojsonInternals = {
agentSecretFromBytes,
agentSecretToBytes,
@@ -43,27 +25,13 @@ export const cojsonInternals = {
newRandomAgentSecret,
connectedPeers,
getAgentID,
rawCoIDtoBytes,
rawCoIDfromBytes,
newRandomSecretSeed,
agentSecretFromSecretSeed,
secretSeedLength,
shortHashLength,
expectGroupContent,
};
export {
LocalNode,
Group,
CoValue,
CoMap,
WriteableCoMap,
CoList,
WriteableCoList,
CoStream,
WriteableCoStream,
BinaryCoStream,
WriteableBinaryCoStream,
CoValueCore,
cojsonInternals as internals,
AnonymousControlledAccount,
ControlledAccount,
};
@@ -71,36 +39,22 @@ export {
export type {
Value,
JsonValue,
CoValue,
ReadableCoValue,
CoValueImpl,
ContentType,
CoID,
AccountID,
Profile,
SessionID,
Peer,
BinaryChunkInfo,
BinaryCoStreamMeta,
AgentID,
AgentSecret,
InviteSecret,
SessionID,
SyncMessage,
AgentID,
};
// 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("./coValueCore.js").CoValueHeader;
export type Transaction = import("./coValueCore.js").Transaction;
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 AccountContent = import("./account.js").AccountContent;
export type ProfileContent = import("./account.js").ProfileContent;
export type ProfileMeta = import("./account.js").ProfileMeta;
export type SealerSecret = import("./crypto.js").SealerSecret;
export type SignerSecret = import("./crypto.js").SignerSecret;
}

View File

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

View File

@@ -1,6 +1,5 @@
import {
AgentSecret,
agentSecretFromSecretSeed,
createdNowUnique,
getAgentID,
getAgentSealerID,
@@ -9,20 +8,15 @@ import {
newRandomKeySecret,
seal,
} from "./crypto.js";
import { CoValueCore, CoValueHeader, newRandomSessionID } from "./coValueCore.js";
import {
InviteSecret,
Group,
GroupContent,
expectGroupContent,
secretSeedFromInviteSecret,
} from "./group.js";
import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js";
import { Team, TeamContent, expectTeamContent } from "./permissions.js";
import { Peer, SyncManager } from "./sync.js";
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
import { CoID, CoValueImpl } from "./coValue.js";
import { CoID, ContentType } from "./contentType.js";
import {
Account,
AccountMeta,
AccountIDOrAgentID,
accountHeaderForInitialAgentSecret,
GeneralizedControlledAccount,
ControlledAccount,
@@ -30,41 +24,26 @@ import {
AccountID,
Profile,
AccountContent,
AccountMap,
ProfileContent,
ProfileMeta,
} from "./account.js";
import { CoMap } from "./coValues/coMap.js";
import { CoMap } from "./index.js";
/** A `LocalNode` represents a local view of a set of loaded `CoValue`s, from the perspective of a particular account (or primitive cryptographic agent).
A `LocalNode` can have peers that it syncs to, for example some form of local persistence, or a sync server, such as `sync.jazz.tools` (Jazz Global Mesh).
@example
You typically get hold of a `LocalNode` using `jazz-react`'s `useJazz()`:
```typescript
const { localNode } = useJazz();
```
*/
export class LocalNode {
/** @internal */
coValues: { [key: RawCoID]: CoValueState } = {};
/** @internal */
account: GeneralizedControlledAccount;
currentSessionID: SessionID;
ownSessionID: SessionID;
sync = new SyncManager(this);
constructor(
account: GeneralizedControlledAccount,
currentSessionID: SessionID
ownSessionID: SessionID
) {
this.account = account;
this.currentSessionID = currentSessionID;
this.ownSessionID = ownSessionID;
}
static withNewlyCreatedAccount(
name: string,
initialAgentSecret = newRandomAgentSecret()
): {
static withNewlyCreatedAccount(name: string): {
node: LocalNode;
accountID: AccountID;
accountSecret: AgentSecret;
@@ -76,7 +55,7 @@ export class LocalNode {
newRandomSessionID(getAgentID(throwawayAgent))
);
const account = setupNode.createAccount(name, initialAgentSecret);
const account = setupNode.createAccount(name);
const nodeWithAccount = account.node.testWithDifferentAccount(
account,
@@ -87,20 +66,12 @@ export class LocalNode {
node: nodeWithAccount,
accountID: account.id,
accountSecret: account.agentSecret,
sessionID: nodeWithAccount.currentSessionID,
sessionID: nodeWithAccount.ownSessionID,
};
}
static async withLoadedAccount(
accountID: AccountID,
accountSecret: AgentSecret,
sessionID: SessionID,
peersToLoadFrom: Peer[]
): Promise<LocalNode> {
const loadingNode = new LocalNode(
new AnonymousControlledAccount(accountSecret),
newRandomSessionID(accountID)
);
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);
@@ -111,19 +82,15 @@ 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;
return node;
}
/** @internal */
createCoValue(header: CoValueHeader): CoValueCore {
const coValue = new CoValueCore(header, this);
createCoValue(header: CoValueHeader): CoValue {
const coValue = new CoValue(header, this);
this.coValues[coValue.id] = { state: "loaded", coValue: coValue };
void this.sync.syncCoValue(coValue);
@@ -131,8 +98,7 @@ export class LocalNode {
return coValue;
}
/** @internal */
loadCoValue(id: RawCoID): Promise<CoValueCore> {
loadCoValue(id: RawCoID): Promise<CoValue> {
let entry = this.coValues[id];
if (!entry) {
entry = newLoadingState();
@@ -147,111 +113,21 @@ export class LocalNode {
return entry.done;
}
/**
* Loads a CoValue's content, syncing from peers as necessary and resolving the returned
* promise once a first version has been loaded. See `coValue.subscribe()` and `node.useTelepathicData()`
* for listening to subsequent updates to the CoValue.
*/
async load<T extends CoValueImpl>(id: CoID<T>): Promise<T> {
async load<T extends ContentType>(id: CoID<T>): Promise<T> {
return (await this.loadCoValue(id)).getCurrentContent() as T;
}
/**
* Loads a profile associated with an account. `Profile` is at least a `CoMap<{string: name}>`,
* but might contain other, app-specific properties.
*/
async loadProfile(id: AccountID): Promise<Profile> {
const account = await this.load<AccountMap>(id);
const account = await this.load<CoMap<AccountContent>>(id);
const profileID = account.get("profile");
if (!profileID) {
throw new Error(`Account ${id} has no profile`);
}
return (
await this.loadCoValue(profileID)
).getCurrentContent() as Profile;
return (await this.loadCoValue(profileID)).getCurrentContent() as Profile;
}
async acceptInvite<T extends CoValueImpl>(
groupOrOwnedValueID: CoID<T>,
inviteSecret: InviteSecret
): Promise<void> {
const groupOrOwnedValue = await this.load(groupOrOwnedValueID);
if (groupOrOwnedValue.core.header.ruleset.type === "ownedByGroup") {
return this.acceptInvite(
groupOrOwnedValue.core.header.ruleset.group as CoID<
CoMap<GroupContent>
>,
inviteSecret
);
} else if (groupOrOwnedValue.core.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.underlyingMap.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.underlyingMap.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.addMemberInternal(
this.account.id,
inviteRole === "adminInvite"
? "admin"
: inviteRole === "writerInvite"
? "writer"
: "reader"
);
group.underlyingMap.core._sessions = groupAsInvite.underlyingMap.core.sessions;
group.underlyingMap.core._cachedContent = undefined;
for (const groupListener of group.underlyingMap.core.listeners) {
groupListener(group.underlyingMap.core.getCurrentContent());
}
}
/** @internal */
expectCoValueLoaded(id: RawCoID, expectation?: string): CoValueCore {
expectCoValueLoaded(id: RawCoID, expectation?: string): CoValue {
const entry = this.coValues[id];
if (!entry) {
throw new Error(
@@ -268,12 +144,9 @@ export class LocalNode {
return entry.coValue;
}
/** @internal */
expectProfileLoaded(id: AccountID, expectation?: string): Profile {
const account = this.expectCoValueLoaded(id, expectation);
const profileID = expectGroupContent(account.getCurrentContent()).get(
"profile"
);
const profileID = expectTeamContent(account.getCurrentContent()).get("profile");
if (!profileID) {
throw new Error(
`${
@@ -281,17 +154,12 @@ export class LocalNode {
}Account ${id} has no profile`
);
}
return this.expectCoValueLoaded(
profileID,
expectation
).getCurrentContent() as Profile;
return this.expectCoValueLoaded(profileID, expectation).getCurrentContent() as Profile;
}
/** @internal */
createAccount(
name: string,
agentSecret = newRandomAgentSecret()
): ControlledAccount {
createAccount(name: string): ControlledAccount {
const agentSecret = newRandomAgentSecret();
const account = this.createCoValue(
accountHeaderForInitialAgentSecret(agentSecret)
).testWithDifferentAccount(
@@ -299,12 +167,9 @@ export class LocalNode {
newRandomSessionID(getAgentID(agentSecret))
);
const accountAsGroup = new Group(
expectGroupContent(account.getCurrentContent()),
account.node
);
const accountAsTeam = new Team(expectTeamContent(account.getCurrentContent()), account.node);
accountAsGroup.underlyingMap.edit((editable) => {
accountAsTeam.teamMap.edit((editable) => {
editable.set(getAgentID(agentSecret), "admin", "trusting");
const readKey = newRandomKeySecret();
@@ -332,28 +197,20 @@ export class LocalNode {
account.node
);
const profile = accountAsGroup.createMap<Profile>({
type: "profile",
});
const profile = accountAsTeam.createMap<ProfileContent, ProfileMeta>({ type: "profile" });
profile.edit((editable) => {
editable.set("name", name, "trusting");
});
accountAsGroup.underlyingMap.edit((editable) => {
accountAsTeam.teamMap.edit((editable) => {
editable.set("profile", profile.id, "trusting");
});
const accountOnThisNode = this.expectCoValueLoaded(account.id);
accountOnThisNode._sessions = {...accountAsGroup.underlyingMap.core.sessions};
accountOnThisNode._cachedContent = undefined;
return controlledAccount;
}
/** @internal */
resolveAccountAgent(id: AccountID | AgentID, expectation?: string): AgentID {
resolveAccountAgent(id: AccountIDOrAgentID, expectation?: string): AgentID {
if (isAgentID(id)) {
return id;
}
@@ -362,7 +219,7 @@ export class LocalNode {
if (
coValue.header.type !== "comap" ||
coValue.header.ruleset.type !== "group" ||
coValue.header.ruleset.type !== "team" ||
!coValue.header.meta ||
!("type" in coValue.header.meta) ||
coValue.header.meta.type !== "account"
@@ -375,23 +232,22 @@ export class LocalNode {
}
return new Account(
coValue.getCurrentContent() as CoMap<GroupContent, AccountMeta>,
coValue.getCurrentContent() as CoMap<TeamContent, AccountMeta>,
this
).getCurrentAgentID();
}
/** Creates a new group (with the current account as the group's first admin). */
createGroup(): Group {
const groupCoValue = this.createCoValue({
createTeam(): Team {
const teamCoValue = this.createCoValue({
type: "comap",
ruleset: { type: "group", initialAdmin: this.account.id },
ruleset: { type: "team", initialAdmin: this.account.id },
meta: null,
...createdNowUnique(),
});
let groupContent = expectGroupContent(groupCoValue.getCurrentContent());
let teamContent = expectTeamContent(teamCoValue.getCurrentContent());
groupContent = groupContent.edit((editable) => {
teamContent = teamContent.edit((editable) => {
editable.set(this.account.id, "admin", "trusting");
const readKey = newRandomKeySecret();
@@ -403,8 +259,8 @@ export class LocalNode {
this.account.currentSealerSecret(),
this.account.currentSealerID(),
{
in: groupCoValue.id,
tx: groupCoValue.nextTransactionID(),
in: teamCoValue.id,
tx: teamCoValue.nextTransactionID(),
}
),
"trusting"
@@ -413,65 +269,50 @@ export class LocalNode {
editable.set("readKey", readKey.id, "trusting");
});
return new Group(groupContent, this);
return new Team(teamContent, this);
}
/** @internal */
testWithDifferentAccount(
account: GeneralizedControlledAccount,
currentSessionID: SessionID
ownSessionID: SessionID
): LocalNode {
const newNode = new LocalNode(account, currentSessionID);
const newNode = new LocalNode(account, ownSessionID);
const coValuesToCopy = Object.entries(this.coValues);
newNode.coValues = Object.fromEntries(
Object.entries(this.coValues)
.map(([id, entry]) => {
if (entry.state === "loading") {
return undefined;
}
while (coValuesToCopy.length > 0) {
const [coValueID, entry] =
coValuesToCopy[coValuesToCopy.length - 1]!;
const newCoValue = new CoValue(
entry.coValue.header,
newNode
);
if (entry.state === "loading") {
coValuesToCopy.pop();
continue;
} else {
const allDepsCopied = entry.coValue
.getDependedOnCoValues()
.every((dep) => newNode.coValues[dep]?.state === "loaded");
newCoValue.sessions = entry.coValue.sessions;
if (!allDepsCopied) {
// move to end of queue
coValuesToCopy.unshift(coValuesToCopy.pop()!);
continue;
}
const newCoValue = new CoValueCore(entry.coValue.header, newNode, {...entry.coValue.sessions});
newNode.coValues[coValueID as RawCoID] = {
state: "loaded",
coValue: newCoValue,
};
coValuesToCopy.pop();
}
}
return [id, { state: "loaded", coValue: newCoValue }];
})
.filter((x): x is Exclude<typeof x, undefined> => !!x)
);
return newNode;
}
}
/** @internal */
type CoValueState =
| {
state: "loading";
done: Promise<CoValueCore>;
resolve: (coValue: CoValueCore) => void;
done: Promise<CoValue>;
resolve: (coValue: CoValue) => void;
}
| { state: "loaded"; coValue: CoValueCore };
| { state: "loaded"; coValue: CoValue };
/** @internal */
export function newLoadingState(): CoValueState {
let resolve: (coValue: CoValueCore) => void;
let resolve: (coValue: CoValue) => void;
const promise = new Promise<CoValueCore>((r) => {
const promise = new Promise<CoValue>((r) => {
resolve = r;
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,38 @@
import { CoID } from "./coValue.js";
import { MapOpPayload } from "./coValues/coMap.js";
import { JsonValue } from "./jsonValue.js";
import { CoID, ContentType } from "./contentType.js";
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import {
Encrypted,
KeyID,
KeySecret,
createdNowUnique,
newRandomKeySecret,
seal,
encryptKeySecret,
getAgentSealerID,
Sealed,
} from "./crypto.js";
import {
CoValueCore,
CoValue,
Transaction,
TrustingTransaction,
accountOrAgentIDfromSessionID,
} from "./coValueCore.js";
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
import {
AccountID,
Profile,
} from "./account.js";
} from "./coValue.js";
import { LocalNode } from "./node.js";
import { RawCoID, SessionID, TransactionID, isAgentID } from "./ids.js";
import { AccountIDOrAgentID, GeneralizedControlledAccount, Profile } from "./account.js";
export type PermissionsDef =
| { type: "group"; initialAdmin: AccountID | AgentID }
| { type: "ownedByGroup"; group: RawCoID }
| { type: "team"; initialAdmin: AccountIDOrAgentID }
| { type: "ownedByTeam"; team: RawCoID }
| { type: "unsafeAllowAll" };
export type Role =
| "reader"
| "writer"
| "admin"
| "revoked"
| "adminInvite"
| "writerInvite"
| "readerInvite";
export type Role = "reader" | "writer" | "admin" | "revoked";
export function determineValidTransactions(
coValue: CoValueCore
coValue: CoValue
): { txID: TransactionID; tx: Transaction }[] {
if (coValue.header.ruleset.type === "group") {
if (coValue.header.ruleset.type === "team") {
const allTrustingTransactionsSorted = Object.entries(
coValue.sessions
).flatMap(([sessionID, sessionLog]) => {
@@ -43,7 +42,7 @@ export function determineValidTransactions(
if (tx.privacy === "trusting") {
return true;
} else {
console.warn("Unexpected private transaction in Group");
console.warn("Unexpected private transaction in Team");
return false;
}
}) as {
@@ -60,10 +59,10 @@ export function determineValidTransactions(
const initialAdmin = coValue.header.ruleset.initialAdmin;
if (!initialAdmin) {
throw new Error("Group must have initialAdmin");
throw new Error("Team must have initialAdmin");
}
const memberState: { [agent: AccountID | AgentID]: Role } = {};
const memberState: { [agent: AccountIDOrAgentID]: Role } = {};
const validTransactions: { txID: TransactionID; tx: Transaction }[] =
[];
@@ -77,16 +76,16 @@ export function determineValidTransactions(
const transactor = accountOrAgentIDfromSessionID(sessionID);
const change = tx.changes[0] as
| MapOpPayload<AccountID | AgentID, Role>
| MapOpPayload<AccountIDOrAgentID, Role>
| MapOpPayload<"readKey", JsonValue>
| MapOpPayload<"profile", CoID<Profile>>;
if (tx.changes.length !== 1) {
console.warn("Group transaction must have exactly one change");
console.warn("Team transaction must have exactly one change");
continue;
}
if (change.op !== "set") {
console.warn("Group transaction must set a role or readKey");
if (change.op !== "insert") {
console.warn("Team transaction must set a role or readKey");
continue;
}
@@ -98,7 +97,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;
@@ -106,16 +105,8 @@ export function determineValidTransactions(
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (
isKeyForKeyField(change.key) ||
isKeyForAccountField(change.key)
) {
if (
memberState[transactor] !== "admin" &&
memberState[transactor] !== "adminInvite" &&
memberState[transactor] !== "writerInvite" &&
memberState[transactor] !== "readerInvite"
) {
} else if (isKeyForKeyField(change.key) || isKeyForAccountField(change.key)) {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can reveal keys");
continue;
}
@@ -133,53 +124,35 @@ export function determineValidTransactions(
change.value !== "admin" &&
change.value !== "writer" &&
change.value !== "reader" &&
change.value !== "revoked" &&
change.value !== "adminInvite" &&
change.value !== "writerInvite" &&
change.value !== "readerInvite"
change.value !== "revoked"
) {
console.warn("Group transaction must set a valid role");
console.warn("Team transaction must set a valid role");
continue;
}
const isFirstSelfAppointment =
!memberState[transactor] &&
transactor === initialAdmin &&
change.op === "set" &&
change.op === "insert" &&
change.key === transactor &&
change.value === "admin";
if (!isFirstSelfAppointment) {
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 {
if (memberState[transactor] !== "admin") {
console.warn(
"Group transaction must be made by current admin or invite"
"Team transaction must be made by current admin"
);
continue;
}
if (
memberState[affectedMember] === "admin" &&
affectedMember !== transactor &&
assignedRole !== "admin"
) {
console.warn("Admins can only demote themselves.");
continue;
}
}
memberState[affectedMember] = change.value;
@@ -189,16 +162,16 @@ export function determineValidTransactions(
}
return validTransactions;
} else if (coValue.header.ruleset.type === "ownedByGroup") {
const groupContent = coValue.node
} else if (coValue.header.ruleset.type === "ownedByTeam") {
const teamContent = coValue.node
.expectCoValueLoaded(
coValue.header.ruleset.group,
"Determining valid transaction in owned object but its group wasn't loaded"
coValue.header.ruleset.team,
"Determining valid transaction in owned object but its team wasn't loaded"
)
.getCurrentContent();
if (groupContent.type !== "comap") {
throw new Error("Group must be a map");
if (teamContent.type !== "comap") {
throw new Error("Team must be a map");
}
return Object.entries(coValue.sessions).flatMap(
@@ -208,7 +181,7 @@ export function determineValidTransactions(
);
return sessionLog.transactions
.filter((tx) => {
const transactorRoleAtTxTime = groupContent.getAtTime(
const transactorRoleAtTxTime = teamContent.getAtTime(
transactor,
tx.madeAt
);
@@ -240,17 +213,180 @@ export function determineValidTransactions(
}
}
export function isKeyForKeyField(
field: string
): field is `${KeyID}_for_${KeyID}` {
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 = null>(
meta?: Meta
): CoMap<M, Meta> {
return this.node
.createCoValue({
type: "comap",
ruleset: {
type: "ownedByTeam",
team: this.teamMap.id,
},
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as CoMap<M, Meta>;
}
testWithDifferentAccount(
account: GeneralizedControlledAccount,
sessionId: SessionID
): Team {
return new Team(
expectTeamContent(
this.teamMap.coValue
.testWithDifferentAccount(account, sessionId)
.getCurrentContent()
),
this.node
);
}
}
export function isKeyForKeyField(field: string): field is `${KeyID}_for_${KeyID}` {
return field.startsWith("key_") && field.includes("_for_key");
}
export function isKeyForAccountField(
field: string
): field is `${KeyID}_for_${AccountID | AgentID}` {
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

@@ -1,17 +1,12 @@
import {
ReadableStream,
TransformStream,
WritableStream,
} from "isomorphic-streams";
import { ReadableStream, TransformStream, WritableStream } from "isomorphic-streams";
import { Peer, PeerID, SyncMessage } from "./sync.js";
export function connectedPeers(
peer1id: PeerID,
peer2id: PeerID,
{
trace = false,
peer1role = "peer",
peer2role = "peer",
trace = false, peer1role = "peer", peer2role = "peer",
}: {
trace?: boolean;
peer1role?: Peer["role"];
@@ -29,13 +24,9 @@ export function connectedPeers(
new TransformStream({
transform(
chunk: SyncMessage,
controller: { enqueue: (msg: SyncMessage) => void }
controller: { enqueue: (msg: SyncMessage) => void; }
) {
trace &&
console.debug(
`${peer2id} -> ${peer1id}`,
JSON.stringify(chunk, null, 2)
);
trace && console.debug(`${peer2id} -> ${peer1id}`, JSON.stringify(chunk, null, 2));
controller.enqueue(chunk);
},
})
@@ -47,13 +38,9 @@ export function connectedPeers(
new TransformStream({
transform(
chunk: SyncMessage,
controller: { enqueue: (msg: SyncMessage) => void }
controller: { enqueue: (msg: SyncMessage) => void; }
) {
trace &&
console.debug(
`${peer1id} -> ${peer2id}`,
JSON.stringify(chunk, null, 2)
);
trace && console.debug(`${peer1id} -> ${peer2id}`, JSON.stringify(chunk, null, 2));
controller.enqueue(chunk);
},
})
@@ -78,22 +65,39 @@ export function connectedPeers(
}
export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
const queue: T[] = [];
let resolveNextItemReady: () => void = () => { };
let nextItemReady: Promise<void> = new Promise((resolve) => {
resolveNextItemReady = resolve;
});
let writerClosed = false;
let readerClosed = false;
let resolveEnqueue: (enqueue: (item: T) => void) => void;
const enqueuePromise = new Promise<(item: T) => void>((resolve) => {
resolveEnqueue = resolve;
});
let resolveClose: (close: () => void) => void;
const closePromise = new Promise<() => void>((resolve) => {
resolveClose = resolve;
});
const readable = new ReadableStream<T>({
async start(controller) {
resolveEnqueue(controller.enqueue.bind(controller));
resolveClose(controller.close.bind(controller));
async pull(controller) {
let retriesLeft = 3;
while (retriesLeft > 0) {
if (writerClosed) {
controller.close();
return;
}
retriesLeft--;
if (queue.length > 0) {
controller.enqueue(queue.shift()!);
if (queue.length === 0) {
nextItemReady = new Promise((resolve) => {
resolveNextItemReady = resolve;
});
}
return;
} else {
await nextItemReady;
}
}
throw new Error(
"Should only use one retry to get next item in queue."
);
},
cancel(_reason) {
@@ -103,21 +107,22 @@ export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
});
const writable = new WritableStream<T>({
async write(chunk) {
const enqueue = await enqueuePromise;
write(chunk) {
if (readerClosed) {
throw new Error("Reader closed");
} else {
// make sure write resolves before corresponding read
setTimeout(() => {
enqueue(chunk);
})
console.log("Reader closed, not writing chunk", chunk);
throw new Error("Reader closed, not writing chunk");
}
queue.push(chunk);
if (queue.length === 1) {
// make sure that await write resolves before corresponding read
setTimeout(() => resolveNextItemReady());
}
},
async abort(reason) {
console.debug("Manually closing writer", reason);
const close = await closePromise;
close();
abort(_reason) {
console.log("Manually closing writer");
writerClosed = true;
resolveNextItemReady();
return Promise.resolve();
},
});

View File

@@ -1,9 +1,9 @@
import { newRandomSessionID } from "./coValueCore.js";
import { newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { Peer, PeerID, SyncMessage } from "./sync.js";
import { expectMap } from "./coValue.js";
import { MapOpPayload } from "./coValues/coMap.js";
import { Group } from "./group.js";
import { expectMap } from "./contentType.js";
import { MapOpPayload } from "./contentTypes/coMap.js";
import { Team } from "./permissions.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 group = node.createGroup();
const team = node.createTeam();
const map = group.createMap();
const map = team.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
@@ -45,7 +45,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
await writer.write({
action: "load",
id: map.core.id,
id: map.coValue.id,
header: false,
sessions: {},
});
@@ -53,40 +53,40 @@ 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(groupStateEx(group));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
action: "known",
...map.core.knownState(),
...map.coValue.knownState(),
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const newContentMsg = await reader.read();
expect(newContentMsg.value).toEqual({
action: "content",
id: map.core.id,
id: map.coValue.id,
header: {
type: "comap",
ruleset: { type: "ownedByGroup", group: group.id },
ruleset: { type: "ownedByTeam", team: team.id },
meta: null,
createdAt: map.core.header.createdAt,
uniqueness: map.core.header.uniqueness,
createdAt: map.coValue.header.createdAt,
uniqueness: map.coValue.header.uniqueness,
},
new: {
[node.currentSessionID]: {
[node.ownSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.core.sessions[node.currentSessionID]!
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[0]!.madeAt,
changes: [
{
op: "set",
op: "insert",
key: "hello",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -94,7 +94,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
},
],
lastSignature:
map.core.sessions[node.currentSessionID]!.lastSignature!,
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -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 group = node.createGroup();
const team = node.createTeam();
const map = group.createMap();
const map = team.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
@@ -127,44 +127,44 @@ test("Node replies with only new tx to subscribe with some known state", async (
await writer.write({
action: "load",
id: map.core.id,
id: map.coValue.id,
header: true,
sessions: {
[node.currentSessionID]: 1,
[node.ownSessionID]: 1,
},
});
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
action: "known",
...map.core.knownState(),
...map.coValue.knownState(),
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapNewContentMsg = await reader.read();
expect(mapNewContentMsg.value).toEqual({
action: "content",
id: map.core.id,
id: map.coValue.id,
header: undefined,
new: {
[node.currentSessionID]: {
[node.ownSessionID]: {
after: 1,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.core.sessions[node.currentSessionID]!
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[1]!.madeAt,
changes: [
{
op: "set",
op: "insert",
key: "goodbye",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -172,7 +172,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
},
],
lastSignature:
map.core.sessions[node.currentSessionID]!.lastSignature!,
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -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 group = node.createGroup();
const team = node.createTeam();
const map = group.createMap();
const map = team.createMap();
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -204,33 +204,33 @@ test("After subscribing, node sends own known state and new txs to peer", async
await writer.write({
action: "load",
id: map.core.id,
id: map.coValue.id,
header: false,
sessions: {
[node.currentSessionID]: 0,
[node.ownSessionID]: 0,
},
});
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
action: "known",
...map.core.knownState(),
...map.coValue.knownState(),
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapNewContentHeaderOnlyMsg = await reader.read();
expect(mapNewContentHeaderOnlyMsg.value).toEqual({
action: "content",
id: map.core.id,
header: map.core.header,
id: map.coValue.id,
header: map.coValue.header,
new: {},
} satisfies SyncMessage);
@@ -242,18 +242,18 @@ test("After subscribing, node sends own known state and new txs to peer", async
expect(mapEditMsg1.value).toEqual({
action: "content",
id: map.core.id,
id: map.coValue.id,
new: {
[node.currentSessionID]: {
[node.ownSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.core.sessions[node.currentSessionID]!
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[0]!.madeAt,
changes: [
{
op: "set",
op: "insert",
key: "hello",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -261,7 +261,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
},
],
lastSignature:
map.core.sessions[node.currentSessionID]!.lastSignature!,
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -274,18 +274,18 @@ test("After subscribing, node sends own known state and new txs to peer", async
expect(mapEditMsg2.value).toEqual({
action: "content",
id: map.core.id,
id: map.coValue.id,
new: {
[node.currentSessionID]: {
[node.ownSessionID]: {
after: 1,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.core.sessions[node.currentSessionID]!
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[1]!.madeAt,
changes: [
{
op: "set",
op: "insert",
key: "goodbye",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -293,7 +293,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
},
],
lastSignature:
map.core.sessions[node.currentSessionID]!.lastSignature!,
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -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 group = node.createGroup();
const team = node.createTeam();
const map = group.createMap();
const map = team.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
@@ -323,48 +323,48 @@ test("Client replies with known new content to tellKnownState from server", asyn
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(groupStateEx(group));
// expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const writer = inTx.getWriter();
await writer.write({
action: "known",
id: map.core.id,
id: map.coValue.id,
header: false,
sessions: {
[node.currentSessionID]: 0,
[node.ownSessionID]: 0,
},
});
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
action: "known",
...map.core.knownState(),
...map.coValue.knownState(),
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapNewContentMsg = await reader.read();
expect(mapNewContentMsg.value).toEqual({
action: "content",
id: map.core.id,
header: map.core.header,
id: map.coValue.id,
header: map.coValue.header,
new: {
[node.currentSessionID]: {
[node.ownSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.core.sessions[node.currentSessionID]!
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[0]!.madeAt,
changes: [
{
op: "set",
op: "insert",
key: "hello",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -372,7 +372,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
},
],
lastSignature:
map.core.sessions[node.currentSessionID]!.lastSignature!,
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -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 group = node.createGroup();
const team = node.createTeam();
const map = group.createMap();
const map = team.createMap();
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -400,33 +400,33 @@ test("No matter the optimistic known state, node respects invalid known state me
await writer.write({
action: "load",
id: map.core.id,
id: map.coValue.id,
header: false,
sessions: {
[node.currentSessionID]: 0,
[node.ownSessionID]: 0,
},
});
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownStateMsg = await reader.read();
expect(mapTellKnownStateMsg.value).toEqual({
action: "known",
...map.core.knownState(),
...map.coValue.knownState(),
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapNewContentHeaderOnlyMsg = await reader.read();
expect(mapNewContentHeaderOnlyMsg.value).toEqual({
action: "content",
id: map.core.id,
header: map.core.header,
id: map.coValue.id,
header: map.coValue.header,
new: {},
} satisfies SyncMessage);
@@ -444,10 +444,10 @@ test("No matter the optimistic known state, node respects invalid known state me
await writer.write({
action: "known",
isCorrection: true,
id: map.core.id,
id: map.coValue.id,
header: true,
sessions: {
[node.currentSessionID]: 1,
[node.ownSessionID]: 1,
},
} satisfies SyncMessage);
@@ -455,19 +455,19 @@ test("No matter the optimistic known state, node respects invalid known state me
expect(newContentAfterWrongAssumedState.value).toEqual({
action: "content",
id: map.core.id,
id: map.coValue.id,
header: undefined,
new: {
[node.currentSessionID]: {
[node.ownSessionID]: {
after: 1,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.core.sessions[node.currentSessionID]!
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[1]!.madeAt,
changes: [
{
op: "set",
op: "insert",
key: "goodbye",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -475,7 +475,7 @@ test("No matter the optimistic known state, node respects invalid known state me
},
],
lastSignature:
map.core.sessions[node.currentSessionID]!.lastSignature!,
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -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 group = node.createGroup();
const team = node.createTeam();
const map = group.createMap();
const map = team.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 group = node.createGroup();
const team = node.createTeam();
const map = group.createMap();
const map = team.createMap();
const [inRx, _inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -535,14 +535,14 @@ 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: group.underlyingMap.core.id,
id: team.teamMap.coValue.id,
});
const mapSubscribeMsg = await reader.read();
expect(mapSubscribeMsg.value).toEqual({
action: "load",
id: map.core.id,
id: map.coValue.id,
header: true,
sessions: {},
} satisfies SyncMessage);
@@ -552,25 +552,25 @@ 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(groupContentEx(group));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapNewContentMsg = await reader.read();
expect(mapNewContentMsg.value).toEqual({
action: "content",
id: map.core.id,
header: map.core.header,
id: map.coValue.id,
header: map.coValue.header,
new: {
[node.currentSessionID]: {
[node.ownSessionID]: {
after: 0,
newTransactions: [
{
privacy: "trusting" as const,
madeAt: map.core.sessions[node.currentSessionID]!
madeAt: map.coValue.sessions[node.ownSessionID]!
.transactions[0]!.madeAt,
changes: [
{
op: "set",
op: "insert",
key: "hello",
value: "world",
} satisfies MapOpPayload<string, string>,
@@ -578,7 +578,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
},
],
lastSignature:
map.core.sessions[node.currentSessionID]!.lastSignature!,
map.coValue.sessions[node.ownSessionID]!.lastSignature!,
},
},
} satisfies SyncMessage);
@@ -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 group = node.createGroup();
const team = node.createTeam();
const [inRx, _inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -607,27 +607,27 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
// });
expect((await reader.read()).value).toMatchObject({
action: "load",
id: group.underlyingMap.core.id,
id: team.teamMap.coValue.id,
});
const map = group.createMap();
const map = team.createMap();
const mapSubscribeMsg = await reader.read();
expect(mapSubscribeMsg.value).toEqual({
action: "load",
...map.core.knownState(),
...map.coValue.knownState(),
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(adminID));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapContentMsg = await reader.read();
expect(mapContentMsg.value).toEqual({
action: "content",
id: map.core.id,
header: map.core.header,
id: map.coValue.id,
header: map.coValue.header,
new: {},
} satisfies SyncMessage);
});
@@ -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 group = node.createGroup();
const team = node.createTeam();
const map = group.createMap();
const map = team.createMap();
const [inRx, _inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -657,18 +657,18 @@ 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 groupSubscribeMessage = await reader.read();
const teamSubscribeMessage = await reader.read();
expect(groupSubscribeMessage.value).toEqual({
expect(teamSubscribeMessage.value).toEqual({
action: "load",
...group.underlyingMap.core.knownState(),
...team.teamMap.coValue.knownState(),
} satisfies SyncMessage);
const secondMessage = await reader.read();
expect(secondMessage.value).toEqual({
action: "load",
...map.core.knownState(),
...map.coValue.knownState(),
} satisfies SyncMessage);
});
@@ -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 group = node.createGroup();
const team = node.createTeam();
const map = group.createMap();
const map = team.createMap();
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -694,22 +694,22 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
await writer.write({
action: "load",
id: map.core.id,
id: map.coValue.id,
header: true,
sessions: {
[node.currentSessionID]: 1,
[node.ownSessionID]: 1,
},
});
const reader = outRx.getReader();
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
const mapTellKnownState = await reader.read();
expect(mapTellKnownState.value).toEqual({
action: "known",
...map.core.knownState(),
...map.coValue.knownState(),
} satisfies SyncMessage);
});
@@ -719,7 +719,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
const node1 = new LocalNode(admin, session);
const group = node1.createGroup();
const team = node1.createTeam();
const [inRx1, inTx1] = newStreamPair<SyncMessage>();
const [outRx1, outTx1] = newStreamPair<SyncMessage>();
@@ -754,52 +754,52 @@ test.skip("When replaying creation and transactions of a coValue as new content,
action: "load",
id: admin.id,
});
const groupSubscribeMsg = await from1.read();
expect(groupSubscribeMsg.value).toMatchObject({
const teamSubscribeMsg = await from1.read();
expect(teamSubscribeMsg.value).toMatchObject({
action: "load",
id: group.underlyingMap.core.id,
id: team.teamMap.coValue.id,
});
await to2.write(adminSubscribeMessage.value!);
await to2.write(groupSubscribeMsg.value!);
await to2.write(teamSubscribeMsg.value!);
// const adminTellKnownStateMsg = await from2.read();
// expect(adminTellKnownStateMsg.value).toMatchObject(admStateEx(admin.id));
const groupTellKnownStateMsg = await from2.read();
expect(groupTellKnownStateMsg.value).toMatchObject(groupStateEx(group));
const teamTellKnownStateMsg = await from2.read();
expect(teamTellKnownStateMsg.value).toMatchObject(teamStateEx(team));
expect(
node2.sync.peers["test1"]!.optimisticKnownStates[
group.underlyingMap.core.id
team.teamMap.coValue.id
]
).toBeDefined();
// await to1.write(adminTellKnownStateMsg.value!);
await to1.write(groupTellKnownStateMsg.value!);
await to1.write(teamTellKnownStateMsg.value!);
// const adminContentMsg = await from1.read();
// expect(adminContentMsg.value).toMatchObject(admContEx(admin.id));
const groupContentMsg = await from1.read();
expect(groupContentMsg.value).toMatchObject(groupContentEx(group));
const teamContentMsg = await from1.read();
expect(teamContentMsg.value).toMatchObject(teamContentEx(team));
// await to2.write(adminContentMsg.value!);
await to2.write(groupContentMsg.value!);
await to2.write(teamContentMsg.value!);
const map = group.createMap();
const map = team.createMap();
const mapSubscriptionMsg = await from1.read();
expect(mapSubscriptionMsg.value).toMatchObject({
action: "load",
id: map.core.id,
id: map.coValue.id,
});
const mapNewContentMsg = await from1.read();
expect(mapNewContentMsg.value).toEqual({
action: "content",
id: map.core.id,
header: map.core.header,
id: map.coValue.id,
header: map.coValue.header,
new: {},
} satisfies SyncMessage);
@@ -808,12 +808,12 @@ test.skip("When replaying creation and transactions of a coValue as new content,
const mapTellKnownStateMsg = await from2.read();
expect(mapTellKnownStateMsg.value).toEqual({
action: "known",
id: map.core.id,
id: map.coValue.id,
header: false,
sessions: {},
} satisfies SyncMessage);
expect(node2.coValues[map.core.id]?.state).toEqual("loading");
expect(node2.coValues[map.coValue.id]?.state).toEqual("loading");
await to2.write(mapNewContentMsg.value!);
@@ -829,7 +829,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
expect(
expectMap(
node2.expectCoValueLoaded(map.core.id).getCurrentContent()
node2.expectCoValueLoaded(map.coValue.id).getCurrentContent()
).get("hello")
).toEqual("world");
});
@@ -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 group = node1.createGroup();
const team = node1.createTeam();
const map = group.createMap();
const map = team.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
});
@@ -854,11 +854,11 @@ test.skip("When loading a coValue on one node, the server node it is requested f
node1.sync.addPeer(node2asPeer);
node2.sync.addPeer(node1asPeer);
await node2.loadCoValue(map.core.id);
await node2.loadCoValue(map.coValue.id);
expect(
expectMap(
node2.expectCoValueLoaded(map.core.id).getCurrentContent()
node2.expectCoValueLoaded(map.coValue.id).getCurrentContent()
).get("hello")
).toEqual("world");
});
@@ -868,9 +868,9 @@ test("Can sync a coValue through a server to another client", async () => {
const client1 = new LocalNode(admin, session);
const group = client1.createGroup();
const team = client1.createTeam();
const map = group.createMap();
const map = team.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
});
@@ -898,7 +898,7 @@ test("Can sync a coValue through a server to another client", async () => {
client2.sync.addPeer(serverAsOtherPeer);
server.sync.addPeer(client2AsPeer);
const mapOnClient2 = await client2.loadCoValue(map.core.id);
const mapOnClient2 = await client2.loadCoValue(map.coValue.id);
expect(expectMap(mapOnClient2.getCurrentContent()).get("hello")).toEqual(
"world"
@@ -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 group = client1.createGroup();
const team = client1.createTeam();
const map = group.createMap();
const map = team.createMap();
map.edit((editable) => {
editable.set("hello", "world", "private");
});
@@ -941,7 +941,7 @@ test("Can sync a coValue with private transactions through a server to another c
client2.sync.addPeer(serverAsOtherPeer);
server.sync.addPeer(client2AsPeer);
const mapOnClient2 = await client2.loadCoValue(map.core.id);
const mapOnClient2 = await client2.loadCoValue(map.coValue.id);
expect(expectMap(mapOnClient2.getCurrentContent()).get("hello")).toEqual(
"world"
@@ -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 group = node.createGroup();
const team = node.createTeam();
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -971,27 +971,27 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
// });
expect((await reader.read()).value).toMatchObject({
action: "load",
id: group.underlyingMap.core.id,
id: team.teamMap.coValue.id,
});
const map = group.createMap();
const map = team.createMap();
const mapSubscribeMsg = await reader.read();
expect(mapSubscribeMsg.value).toEqual({
action: "load",
...map.core.knownState(),
...map.coValue.knownState(),
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapContentMsg = await reader.read();
expect(mapContentMsg.value).toEqual({
action: "content",
id: map.core.id,
header: map.core.header,
id: map.coValue.id,
header: map.coValue.header,
new: {},
} satisfies SyncMessage);
@@ -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 group = node.createGroup();
const team = node.createTeam();
const [inRx, inTx] = newStreamPair<SyncMessage>();
const [outRx, outTx] = newStreamPair<SyncMessage>();
@@ -1025,27 +1025,27 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
// });
expect((await reader.read()).value).toMatchObject({
action: "load",
id: group.underlyingMap.core.id,
id: team.teamMap.coValue.id,
});
const map = group.createMap();
const map = team.createMap();
const mapSubscribeMsg = await reader.read();
expect(mapSubscribeMsg.value).toEqual({
action: "load",
...map.core.knownState(),
...map.coValue.knownState(),
} satisfies SyncMessage);
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
const mapContentMsg = await reader.read();
expect(mapContentMsg.value).toEqual({
action: "content",
id: map.core.id,
header: map.core.header,
id: map.coValue.id,
header: map.coValue.header,
new: {},
} satisfies SyncMessage);
@@ -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 group = node1.createGroup();
const team = node1.createTeam();
const map = group.createMap();
const map = team.createMap();
map.edit((editable) => {
editable.set("hello", "world", "trusting");
});
@@ -1083,9 +1083,9 @@ test("If we start loading a coValue before connecting to a peer that has it, it
node1.sync.addPeer(node2asPeer);
const mapOnNode2Promise = node2.loadCoValue(map.core.id);
const mapOnNode2Promise = node2.loadCoValue(map.coValue.id);
expect(node2.coValues[map.core.id]?.state).toEqual("loading");
expect(node2.coValues[map.coValue.id]?.state).toEqual("loading");
node2.sync.addPeer(node1asPeer);
@@ -1096,10 +1096,10 @@ test("If we start loading a coValue before connecting to a peer that has it, it
);
});
function groupContentEx(group: Group) {
function teamContentEx(team: Team) {
return {
action: "content",
id: group.underlyingMap.core.id,
id: team.teamMap.coValue.id,
};
}
@@ -1110,10 +1110,10 @@ function admContEx(adminID: AccountID) {
};
}
function groupStateEx(group: Group) {
function teamStateEx(team: Team) {
return {
action: "known",
id: group.underlyingMap.core.id,
id: team.teamMap.coValue.id,
};
}

View File

@@ -1,6 +1,6 @@
import { Signature } from "./crypto.js";
import { CoValueHeader, Transaction } from "./coValueCore.js";
import { CoValueCore } from "./coValueCore.js";
import { CoValueHeader, Transaction } from "./coValue.js";
import { CoValue } from "./coValue.js";
import { LocalNode } from "./node.js";
import { newLoadingState } from "./node.js";
import {
@@ -149,11 +149,16 @@ export class SyncManager {
}
}
async subscribeToIncludingDependencies(id: RawCoID, peer: PeerState) {
async subscribeToIncludingDependencies(
id: RawCoID,
peer: PeerState
) {
const entry = this.local.coValues[id];
if (!entry) {
throw new Error("Expected coValue entry on subscribe");
throw new Error(
"Expected coValue entry on subscribe"
);
}
if (entry.state === "loading") {
@@ -207,7 +212,10 @@ export class SyncManager {
}
}
async sendNewContentIncludingDependencies(id: RawCoID, peer: PeerState) {
async sendNewContentIncludingDependencies(
id: RawCoID,
peer: PeerState
) {
const coValue = this.local.expectCoValueLoaded(id);
for (const id of coValue.getDependedOnCoValues()) {
@@ -221,7 +229,8 @@ export class SyncManager {
if (newContent) {
await this.trySendToPeer(peer, newContent);
peer.optimisticKnownStates[id] = combinedKnownStates(
peer.optimisticKnownStates[id] || emptyKnownState(id),
peer.optimisticKnownStates[id] ||
emptyKnownState(id),
coValue.knownState()
);
}
@@ -256,23 +265,17 @@ export class SyncManager {
}
const readIncoming = async () => {
try {
for await (const msg of peerState.incoming) {
try {
await this.handleSyncMessage(msg, peerState);
} catch (e) {
console.error(
`Error reading from peer ${peer.id}, handling msg`,
JSON.stringify(msg),
e
);
}
for await (const msg of peerState.incoming) {
try {
await this.handleSyncMessage(msg, peerState);
} catch (e) {
console.error(
`Error reading from peer ${peer.id}`,
JSON.stringify(msg),
e
);
}
console.log("DONE!!!");
} catch (e) {
console.error(`Error reading from peer ${peer.id}`, e);
}
console.log("Peer disconnected:", peer.id);
delete this.peers[peer.id];
};
@@ -281,32 +284,9 @@ export class SyncManager {
}
trySendToPeer(peer: PeerState, msg: SyncMessage) {
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
console.error(
new Error(
`Writing to peer ${peer.id} took >1s - this should never happen as write should resolve quickly or error`
)
);
resolve();
}, 1000);
peer.outgoing
.write(msg)
.then(() => {
clearTimeout(timeout);
resolve();
})
.catch((e) => {
console.error(
new Error(
`Error writing to peer ${peer.id}, disconnecting`,
{
cause: e,
}
)
);
delete this.peers[peer.id];
});
return peer.outgoing.write(msg).catch((e) => {
console.error(new Error(`Error writing to peer ${peer.id}, disconnecting`, {cause: e}));
delete this.peers[peer.id];
});
}
@@ -315,19 +295,7 @@ export class SyncManager {
if (!entry || entry.state === "loading") {
if (!entry) {
await new Promise<void>((resolve) => {
this.local
.loadCoValue(msg.id)
.then(() => resolve())
.catch((e) => {
console.error(
"Error loading coValue in handleLoad",
e
);
resolve();
});
setTimeout(resolve, 1000);
});
this.local.coValues[msg.id] = newLoadingState();
}
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
@@ -345,7 +313,10 @@ export class SyncManager {
peer.optimisticKnownStates[msg.id] = knownStateIn(msg);
await this.tellUntoldKnownStateIncludingDependencies(msg.id, peer);
await this.tellUntoldKnownStateIncludingDependencies(
msg.id,
peer
);
await this.sendNewContentIncludingDependencies(msg.id, peer);
}
@@ -354,7 +325,8 @@ export class SyncManager {
let entry = this.local.coValues[msg.id];
peer.optimisticKnownStates[msg.id] = combinedKnownStates(
peer.optimisticKnownStates[msg.id] || emptyKnownState(msg.id),
peer.optimisticKnownStates[msg.id] ||
emptyKnownState(msg.id),
knownStateIn(msg)
);
@@ -380,7 +352,10 @@ export class SyncManager {
return [];
}
await this.tellUntoldKnownStateIncludingDependencies(msg.id, peer);
await this.tellUntoldKnownStateIncludingDependencies(
msg.id,
peer
);
await this.sendNewContentIncludingDependencies(msg.id, peer);
}
@@ -393,9 +368,10 @@ export class SyncManager {
);
}
let resolveAfterDone: ((coValue: CoValueCore) => void) | undefined;
let resolveAfterDone: ((coValue: CoValue) => void) | undefined;
const peerOptimisticKnownState = peer.optimisticKnownStates[msg.id];
const peerOptimisticKnownState =
peer.optimisticKnownStates[msg.id];
if (!peerOptimisticKnownState) {
throw new Error(
@@ -410,7 +386,7 @@ export class SyncManager {
peerOptimisticKnownState.header = true;
const coValue = new CoValueCore(msg.header, this.local);
const coValue = new CoValue(msg.header, this.local);
resolveAfterDone = entry.resolve;
@@ -477,7 +453,10 @@ export class SyncManager {
}
}
async handleCorrection(msg: KnownStateMessage, peer: PeerState) {
async handleCorrection(
msg: KnownStateMessage,
peer: PeerState
) {
const coValue = this.local.expectCoValueLoaded(msg.id);
peer.optimisticKnownStates[msg.id] = combinedKnownStates(
@@ -496,7 +475,7 @@ export class SyncManager {
throw new Error("Method not implemented.");
}
async syncCoValue(coValue: CoValueCore) {
async syncCoValue(coValue: CoValue) {
for (const peer of Object.values(this.peers)) {
const optimisticKnownState = peer.optimisticKnownStates[coValue.id];
@@ -520,7 +499,11 @@ export class SyncManager {
}
}
function knownStateIn(msg: LoadMessage | KnownStateMessage) {
function knownStateIn(
msg:
| LoadMessage
| KnownStateMessage
) {
return {
id: msg.id,
header: msg.header,

View File

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

View File

@@ -9,8 +9,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true
"esModuleInterop": true,
},
"include": ["./src/**/*"],
"exclude": ["./src/**/*.test.*"],
}

View File

@@ -1,21 +0,0 @@
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

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

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

View File

@@ -1,17 +0,0 @@
{
"name": "jazz-browser-auth-local",
"version": "0.1.10",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"jazz-browser": "^0.1.10",
"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"
},
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
}

View File

@@ -1,212 +0,0 @@
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.currentSessionID = 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

@@ -1,16 +0,0 @@
{
"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

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

@@ -1,17 +0,0 @@
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",
},
};

View File

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

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

View File

@@ -1,18 +0,0 @@
{
"name": "jazz-browser",
"version": "0.1.10",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",
"dependencies": {
"cojson": "^0.1.10",
"jazz-storage-indexeddb": "^0.1.10",
"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"
},
"gitHead": "33c27053293b4801b968c61d5c4c989f93a67d13"
}

Some files were not shown because too many files have changed in this diff Show More