Compare commits
26 Commits
jazz-react
...
jazz-react
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6557532743 | ||
|
|
ea6835664b | ||
|
|
a00332f4af | ||
|
|
6a6fb2eb3c | ||
|
|
0437223d50 | ||
|
|
30c7e1bf6d | ||
|
|
aaa9d876d5 | ||
|
|
264009a1a9 | ||
|
|
f2cb5d1b59 | ||
|
|
8610db2d8e | ||
|
|
c672a03338 | ||
|
|
cb60088d2a | ||
|
|
a59d5d3b70 | ||
|
|
dcdf829a05 | ||
|
|
a907321d02 | ||
|
|
a2b9be9dd0 | ||
|
|
432d114438 | ||
|
|
17a2e2337a | ||
|
|
442e6d58cb | ||
|
|
2b26771bc0 | ||
|
|
0851d21674 | ||
|
|
2c79dbb335 | ||
|
|
5966170c38 | ||
|
|
798a88d1fc | ||
|
|
60a9961ff2 | ||
|
|
7964e5d382 |
81
.github/workflows/build-and-deploy.yaml
vendored
Normal file
81
.github/workflows/build-and-deploy.yaml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: yarn.lock
|
||||
- name: Nuke Workspace
|
||||
run: |
|
||||
rm package.json yarn.lock;
|
||||
- name: Yarn Build
|
||||
run: |
|
||||
yarn install --frozen-lockfile;
|
||||
yarn build;
|
||||
working-directory: ./examples/todo
|
||||
|
||||
- uses: satackey/action-docker-layer-caching@v0.0.11
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: docker-layer-caching-${{ github.workflow }}-{hash}
|
||||
restore-keys: |
|
||||
docker-layer-caching-${{ github.workflow }}-
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: gardencmp
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Build & Push
|
||||
run: |
|
||||
export DOCKER_TAG=ghcr.io/gardencmp/jazz-example-todo:${{github.head_ref || github.ref_name}}-${{github.sha}}-$(date +%s) ;
|
||||
docker build . --file Dockerfile --tag $DOCKER_TAG;
|
||||
docker push $DOCKER_TAG;
|
||||
echo "DOCKER_TAG=$DOCKER_TAG" >> $GITHUB_ENV
|
||||
working-directory: ./examples/todo
|
||||
|
||||
- uses: gacts/install-nomad@v1
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v1
|
||||
with:
|
||||
authkey: ${{ secrets.TAILSCALE_AUTHKEY }}
|
||||
|
||||
- name: Deploy on Nomad
|
||||
run: |
|
||||
if [ "${{github.ref_name}}" == "main" ]; then
|
||||
export BRANCH_SUFFIX="";
|
||||
export BRANCH_SUBDOMAIN="";
|
||||
else
|
||||
export BRANCH_SUFFIX=-${{github.head_ref || github.ref_name}};
|
||||
export BRANCH_SUBDOMAIN=${{github.head_ref || github.ref_name}}.;
|
||||
fi
|
||||
|
||||
export DOCKER_USER=gardencmp;
|
||||
export DOCKER_PASSWORD=${{ secrets.DOCKER_PULL_PAT }};
|
||||
export DOCKER_TAG=${{ env.DOCKER_TAG }};
|
||||
|
||||
for region in ${{ vars.DEPLOY_REGIONS }}
|
||||
do
|
||||
export REGION=$region;
|
||||
envsubst '${DOCKER_USER} ${DOCKER_PASSWORD} ${DOCKER_TAG} ${BRANCH_SUFFIX} ${BRANCH_SUBDOMAIN} ${REGION}' < job-template.nomad > job-instance.nomad;
|
||||
cat job-instance.nomad;
|
||||
NOMAD_ADDR='${{ secrets.NOMAD_ADDR }}' nomad job run job-instance.nomad;
|
||||
done
|
||||
working-directory: ./examples/todo
|
||||
4
examples/todo/Dockerfile
Normal file
4
examples/todo/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM caddy:2.7.3-alpine
|
||||
LABEL org.opencontainers.image.source="https://github.com/gardencmp/jazz"
|
||||
|
||||
COPY ./dist /usr/share/caddy/
|
||||
@@ -1,76 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
|
||||
--primary: 24 9.8% 10%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--primary: 60 9.1% 97.8%;
|
||||
--primary-foreground: 24 9.8% 10%;
|
||||
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 24 5.7% 82.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
54
examples/todo/job-template.nomad
Normal file
54
examples/todo/job-template.nomad
Normal file
@@ -0,0 +1,54 @@
|
||||
job "example-todo$BRANCH_SUFFIX" {
|
||||
region = "$REGION"
|
||||
datacenters = ["$REGION"]
|
||||
|
||||
group "static" {
|
||||
// count = 3
|
||||
|
||||
network {
|
||||
port "http" {
|
||||
to = 80
|
||||
}
|
||||
}
|
||||
|
||||
constraint {
|
||||
attribute = "${node.class}"
|
||||
operator = "="
|
||||
value = "edge"
|
||||
}
|
||||
|
||||
// spread {
|
||||
// attribute = "${node.datacenter}"
|
||||
// weight = 100
|
||||
// }
|
||||
|
||||
task "server" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "$DOCKER_TAG"
|
||||
ports = ["http"]
|
||||
|
||||
auth = {
|
||||
username = "$DOCKER_USER"
|
||||
password = "$DOCKER_PASSWORD"
|
||||
}
|
||||
}
|
||||
|
||||
service {
|
||||
tags = ["public"]
|
||||
meta {
|
||||
public_name = "${BRANCH_SUBDOMAIN}example-todo"
|
||||
}
|
||||
port = "http"
|
||||
provider = "consul"
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 50 # MHz
|
||||
memory = 50 # MB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# deploy bump 4
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.10",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,10 +12,11 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.0.8",
|
||||
"jazz-react-auth-local": "^0.0.5",
|
||||
"jazz-react": "^0.0.16",
|
||||
"jazz-react-auth-local": "^0.0.13",
|
||||
"lucide-react": "^0.265.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -6,50 +6,91 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CoMap, CoID } from "cojson";
|
||||
import { useJazz, useTelepathicState } from "jazz-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CoMap, CoID, AccountID } from "cojson";
|
||||
import {
|
||||
consumeInviteLinkFromWindowLocation,
|
||||
useJazz,
|
||||
useProfile,
|
||||
useTelepathicState,
|
||||
} from "jazz-react";
|
||||
import { SubmittableInput } from "./components/SubmittableInput";
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import { useToast } from "./components/ui/use-toast";
|
||||
import { Skeleton } from "./components/ui/skeleton";
|
||||
|
||||
type TaskContent = { done: boolean; text: string };
|
||||
type Task = CoMap<TaskContent>;
|
||||
|
||||
type TodoListContent = { title: string; [taskId: CoID<Task>]: true };
|
||||
type TodoListContent = {
|
||||
title: string;
|
||||
// other keys form a set of task IDs
|
||||
[taskId: CoID<Task>]: true;
|
||||
};
|
||||
type TodoList = CoMap<TodoListContent>;
|
||||
|
||||
function App() {
|
||||
const [listId, setListId] = useState<CoID<TodoList>>(window.location.hash.slice(1) as CoID<TodoList>);
|
||||
const [listId, setListId] = useState<CoID<TodoList>>();
|
||||
|
||||
const { localNode } = useJazz();
|
||||
const { localNode, logOut } = useJazz();
|
||||
|
||||
const createList = () => {
|
||||
const listTeam = localNode.createTeam();
|
||||
const list = listTeam.createMap<TodoListContent, null>();
|
||||
const createList = useCallback(
|
||||
(title: string) => {
|
||||
const listTeam = localNode.createTeam();
|
||||
const list = listTeam.createMap<TodoListContent>();
|
||||
|
||||
list.edit((list) => {
|
||||
list.set("title", "My Todo List");
|
||||
});
|
||||
list.edit((list) => {
|
||||
list.set("title", title);
|
||||
});
|
||||
|
||||
window.location.hash = list.id;
|
||||
};
|
||||
window.location.hash = list.id;
|
||||
},
|
||||
[localNode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
const listener = async () => {
|
||||
const acceptedInvitation =
|
||||
await consumeInviteLinkFromWindowLocation(localNode);
|
||||
|
||||
if (acceptedInvitation) {
|
||||
setListId(acceptedInvitation.valueID as CoID<TodoList>);
|
||||
window.location.hash = acceptedInvitation.valueID;
|
||||
return;
|
||||
}
|
||||
|
||||
setListId(window.location.hash.slice(1) as CoID<TodoList>);
|
||||
}
|
||||
};
|
||||
window.addEventListener("hashchange", listener);
|
||||
listener();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", listener);
|
||||
}
|
||||
}, [])
|
||||
};
|
||||
}, [localNode]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center justify-center gap-10">
|
||||
{listId && <TodoList listId={listId} />}
|
||||
<Button onClick={createList}>Create New List</Button>
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 md:pt-[30vh] pb-10 px-5">
|
||||
{listId ? (
|
||||
<TodoList listId={listId} />
|
||||
) : (
|
||||
<SubmittableInput
|
||||
onSubmit={createList}
|
||||
label="Create New List"
|
||||
placeholder="New list title"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.hash = "";
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,9 +98,9 @@ function App() {
|
||||
export function TodoList({ listId }: { listId: CoID<TodoList> }) {
|
||||
const list = useTelepathicState(listId);
|
||||
|
||||
const createTodo = (text: string) => {
|
||||
const createTask = (text: string) => {
|
||||
if (!list) return;
|
||||
let task = list.coValue.getTeam().createMap<TaskContent, {}>();
|
||||
let task = list.coValue.getTeam().createMap<TaskContent>();
|
||||
|
||||
task = task.edit((task) => {
|
||||
task.set("text", text);
|
||||
@@ -75,11 +116,41 @@ export function TodoList({ listId }: { listId: CoID<TodoList> }) {
|
||||
console.log("Updated list", listAfter.toJSON());
|
||||
};
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<h1>
|
||||
{list?.get("title")} ({list?.id})
|
||||
</h1>
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{list?.get("title") ? (
|
||||
<>
|
||||
{list.get("title")}{" "}
|
||||
<span className="text-sm">({list.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</h1>
|
||||
{list && list.coValue.getTeam().myRole() === "admin" && <Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (list) {
|
||||
const inviteLink = createInviteLink(list, "writer");
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
description:
|
||||
"Copied invite link to clipboard!",
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</Button>}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -95,35 +166,19 @@ export function TodoList({ listId }: { listId: CoID<TodoList> }) {
|
||||
key.startsWith("co_")
|
||||
)
|
||||
.map((taskId) => (
|
||||
<TodoRow key={taskId} taskId={taskId} />
|
||||
<TaskRow key={taskId} taskId={taskId} />
|
||||
))}
|
||||
<TableRow key="new">
|
||||
<TableCell>
|
||||
<Checkbox className="mt-1" disabled />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<form
|
||||
className="flex flex-row items-center gap-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const textEl =
|
||||
e.currentTarget.elements.namedItem(
|
||||
"text"
|
||||
) as HTMLInputElement;
|
||||
createTodo(textEl.value);
|
||||
textEl.value = "";
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="-ml-3 -my-2"
|
||||
name="text"
|
||||
placeholder="Add todo"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button asChild type="submit">
|
||||
<Input type="submit" value="Add" />
|
||||
</Button>
|
||||
</form>
|
||||
<SubmittableInput
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={!list}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
@@ -132,7 +187,7 @@ export function TodoList({ listId }: { listId: CoID<TodoList> }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TodoRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
function TaskRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
const task = useTelepathicState(taskId);
|
||||
|
||||
return (
|
||||
@@ -148,11 +203,26 @@ function TodoRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text")}
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-between">
|
||||
<span className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text")}
|
||||
</span>
|
||||
<NameBadge accountID={task?.getLastEditor("text")} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function NameBadge({ accountID }: { accountID?: AccountID }) {
|
||||
const profile = useProfile({ accountID });
|
||||
|
||||
return (
|
||||
<span className="rounded-full bg-neutral-200 dark:bg-neutral-600 py-0.5 px-2 text-xs text-neutral-500 dark:text-neutral-300">
|
||||
{profile?.get("name") || "..."}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from "react";
|
||||
import { Input } from "./components/ui/input.tsx";
|
||||
import { Button } from "./components/ui/button.tsx";
|
||||
import { AuthComponent } from "jazz-react";
|
||||
import { useLocalAuth } from "jazz-react-auth-local";
|
||||
|
||||
export const LocalAuth: AuthComponent = ({ onCredential }) => {
|
||||
const { displayName, setDisplayName, signIn, signUp } = useLocalAuth(onCredential);
|
||||
|
||||
return (<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-72 flex flex-col gap-4">
|
||||
<form
|
||||
className="w-72 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
signUp();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
autoComplete="webauthn" />
|
||||
<Button asChild>
|
||||
<Input type="submit" value="Sign Up as new account" />
|
||||
</Button>
|
||||
</form>
|
||||
<Button onClick={signIn}>Log In with existing account</Button>
|
||||
</div>
|
||||
</div>);
|
||||
};
|
||||
39
examples/todo/src/components/SubmittableInput.tsx
Normal file
39
examples/todo/src/components/SubmittableInput.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SubmittableInput({
|
||||
onSubmit,
|
||||
label,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: {
|
||||
onSubmit: (text: string) => void;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
className="flex flex-row items-center gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const textEl = e.currentTarget.elements.namedItem(
|
||||
"text"
|
||||
) as HTMLInputElement;
|
||||
onSubmit(textEl.value);
|
||||
textEl.value = "";
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="-ml-3 -my-2 flex-grow flex-3 text-base"
|
||||
name="text"
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button asChild type="submit" className="flex-shrink flex-1 cursor-pointer">
|
||||
<Input type="submit" value={label} disabled={disabled} />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
47
examples/todo/src/components/prettyAuth.tsx
Normal file
47
examples/todo/src/components/prettyAuth.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { LocalAuthComponent } from "jazz-react-auth-local";
|
||||
import { useState } from "react";
|
||||
import { Input } from "./ui/input";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export const PrettyAuthComponent: LocalAuthComponent = ({
|
||||
loading,
|
||||
logIn,
|
||||
signUp,
|
||||
}) => {
|
||||
const [username, setUsername] = useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-5">
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<div className="w-72 flex flex-col gap-4">
|
||||
<form
|
||||
className="w-72 flex flex-col gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
signUp(username);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="webauthn"
|
||||
className="text-base"
|
||||
/>
|
||||
<Button asChild>
|
||||
<Input
|
||||
type="submit"
|
||||
value="Sign Up as new account"
|
||||
/>
|
||||
</Button>
|
||||
</form>
|
||||
<Button onClick={logIn}>
|
||||
Log In with existing account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
72
examples/todo/src/components/themeProvider.tsx
Normal file
72
examples/todo/src/components/themeProvider.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: string;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: string;
|
||||
setTheme: (theme: string) => void;
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState(
|
||||
() => localStorage.getItem(storageKey) || defaultTheme
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)"
|
||||
).matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: string) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
15
examples/todo/src/components/ui/skeleton.tsx
Normal file
15
examples/todo/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
127
examples/todo/src/components/ui/toast.tsx
Normal file
127
examples/todo/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
33
examples/todo/src/components/ui/toaster.tsx
Normal file
33
examples/todo/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
192
examples/todo/src/components/ui/use-toast.ts
Normal file
192
examples/todo/src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
@@ -3,12 +3,27 @@ import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import { WithJazz } from "jazz-react";
|
||||
import { LocalAuth } from "./LocalAuth.tsx";
|
||||
import { LocalAuth } from "jazz-react-auth-local";
|
||||
import { PrettyAuthComponent } from "./components/prettyAuth.tsx";
|
||||
import { ThemeProvider } from "./components/themeProvider.tsx";
|
||||
import { Toaster } from "./components/ui/toaster.tsx";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<WithJazz auth={LocalAuth}>
|
||||
<App />
|
||||
</WithJazz>
|
||||
<ThemeProvider>
|
||||
<WithJazz
|
||||
auth={LocalAuth({
|
||||
appName: "Todo List Example",
|
||||
Component: PrettyAuthComponent,
|
||||
})}
|
||||
syncAddress={
|
||||
new URLSearchParams(window.location.search).get("sync") ||
|
||||
undefined
|
||||
}
|
||||
>
|
||||
<App />
|
||||
<Toaster />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
||||
@@ -9,5 +9,8 @@ export default defineConfig({
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: false
|
||||
}
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
6
jest.config.js
Normal file
6
jest.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('jest').Config} */
|
||||
const config = {
|
||||
projects: ['<rootDir>/packages/cojson'],
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -2,10 +2,10 @@
|
||||
"name": "cojson",
|
||||
"module": "dist/index.js",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.23",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "./crypto.js";
|
||||
import { AgentID } from "./ids.js";
|
||||
import { CoMap, LocalNode } from "./index.js";
|
||||
import { Team, TeamContent } from "./permissions.js";
|
||||
import { Team, TeamContent } from "./team.js";
|
||||
|
||||
export function accountHeaderForInitialAgentSecret(
|
||||
agentSecret: AgentSecret
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Transaction } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { AccountID } from "./index.js";
|
||||
import { Role } from "./permissions.js";
|
||||
|
||||
test("Can create coValue with new agent credentials and add transaction to it", () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
@@ -121,3 +124,58 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("New transactions in a team correctly update owned values, including subscriptions", async () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
|
||||
const team = node.createTeam();
|
||||
|
||||
const timeBeforeEdit = Date.now();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
let map = team.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 } = team.teamMap.coValue.expectedNewHashAfter(sessionID, [
|
||||
resignationThatWeJustLearnedAbout,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
node.account.currentSignerSecret(),
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
expect(map.coValue.getValidSortedTransactions().length).toBe(1);
|
||||
|
||||
const manuallyAdddedTxSuccess = team.teamMap.coValue.tryAddTransactions(node.ownSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
|
||||
|
||||
expect(manuallyAdddedTxSuccess).toBe(true);
|
||||
|
||||
expect(listener.mock.calls.length).toBe(2);
|
||||
expect(listener.mock.calls[1][0].get("hello")).toBe(undefined);
|
||||
|
||||
expect(map.coValue.getValidSortedTransactions().length).toBe(0);
|
||||
});
|
||||
|
||||
@@ -24,11 +24,10 @@ import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import {
|
||||
PermissionsDef as RulesetDef,
|
||||
Team,
|
||||
determineValidTransactions,
|
||||
expectTeamContent,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { Team, expectTeamContent } from "./team.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
@@ -97,15 +96,31 @@ export class CoValue {
|
||||
id: RawCoID;
|
||||
node: LocalNode;
|
||||
header: CoValueHeader;
|
||||
sessions: { [key: SessionID]: SessionLog };
|
||||
content?: ContentType;
|
||||
_sessions: { [key: SessionID]: SessionLog };
|
||||
_cachedContent?: ContentType;
|
||||
listeners: Set<(content?: ContentType) => void> = new Set();
|
||||
|
||||
constructor(header: CoValueHeader, node: LocalNode) {
|
||||
constructor(header: CoValueHeader, node: LocalNode, internalInitSessions: { [key: SessionID]: SessionLog } = {}) {
|
||||
this.id = idforHeader(header);
|
||||
this.header = header;
|
||||
this.sessions = {};
|
||||
this._sessions = internalInitSessions;
|
||||
this.node = node;
|
||||
|
||||
if (header.ruleset.type == "ownedByTeam") {
|
||||
this.node
|
||||
.expectCoValueLoaded(header.ruleset.team)
|
||||
.subscribe((_teamUpdate) => {
|
||||
this._cachedContent = undefined;
|
||||
const newContent = this.getCurrentContent();
|
||||
for (const listener of this.listeners) {
|
||||
listener(newContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get sessions(): Readonly<{ [key: SessionID]: SessionLog }> {
|
||||
return this._sessions;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
@@ -172,7 +187,10 @@ export class CoValue {
|
||||
);
|
||||
|
||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
||||
console.warn("Invalid hash", { expectedNewHash, givenExpectedNewHash });
|
||||
console.warn("Invalid hash", {
|
||||
expectedNewHash,
|
||||
givenExpectedNewHash,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -190,14 +208,14 @@ export class CoValue {
|
||||
|
||||
transactions.push(...newTransactions);
|
||||
|
||||
this.sessions[sessionID] = {
|
||||
this._sessions[sessionID] = {
|
||||
transactions,
|
||||
lastHash: expectedNewHash,
|
||||
streamingHash: newStreamingHash,
|
||||
lastSignature: newSignature,
|
||||
};
|
||||
|
||||
this.content = undefined;
|
||||
this._cachedContent = undefined;
|
||||
|
||||
const content = this.getCurrentContent();
|
||||
|
||||
@@ -296,23 +314,23 @@ export class CoValue {
|
||||
}
|
||||
|
||||
getCurrentContent(): ContentType {
|
||||
if (this.content) {
|
||||
return this.content;
|
||||
if (this._cachedContent) {
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
if (this.header.type === "comap") {
|
||||
this.content = new CoMap(this);
|
||||
this._cachedContent = new CoMap(this);
|
||||
} else if (this.header.type === "colist") {
|
||||
this.content = new CoList(this);
|
||||
this._cachedContent = new CoList(this);
|
||||
} else if (this.header.type === "costream") {
|
||||
this.content = new CoStream(this);
|
||||
this._cachedContent = new CoStream(this);
|
||||
} else if (this.header.type === "static") {
|
||||
this.content = new Static(this);
|
||||
this._cachedContent = new Static(this);
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||
}
|
||||
|
||||
return this.content;
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
getValidSortedTransactions(): DecryptedTransaction[] {
|
||||
@@ -399,7 +417,9 @@ export class CoValue {
|
||||
|
||||
// Try to find key revelation for us
|
||||
|
||||
const readKeyEntry = content.getLastEntry(`${keyID}_for_${this.node.account.id}`);
|
||||
const readKeyEntry = content.getLastEntry(
|
||||
`${keyID}_for_${this.node.account.id}`
|
||||
);
|
||||
|
||||
if (readKeyEntry) {
|
||||
const revealer = accountOrAgentIDfromSessionID(
|
||||
@@ -428,7 +448,8 @@ export class CoValue {
|
||||
for (const field of content.keys()) {
|
||||
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
|
||||
const encryptingKeyID = field.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
|
||||
const encryptingKeySecret =
|
||||
this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
@@ -453,7 +474,6 @@ export class CoValue {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -525,10 +545,7 @@ export class CoValue {
|
||||
),
|
||||
};
|
||||
|
||||
if (
|
||||
!newContent.header &&
|
||||
Object.keys(newContent.new).length === 0
|
||||
) {
|
||||
if (!newContent.header && Object.keys(newContent.new).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -544,4 +561,4 @@ export class CoValue {
|
||||
? [this.header.ruleset.team]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ type MapOp<K extends string, V extends JsonValue> = {
|
||||
// TODO: add after TransactionID[] for conflicts/ordering
|
||||
|
||||
export type MapOpPayload<K extends string, V extends JsonValue> = {
|
||||
op: "insert";
|
||||
op: "set";
|
||||
key: K;
|
||||
value: V;
|
||||
} |
|
||||
{
|
||||
op: "delete";
|
||||
op: "del";
|
||||
key: K;
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ export class CoMap<
|
||||
|
||||
const lastEntry = ops[ops.length - 1]!;
|
||||
|
||||
if (lastEntry.op === "delete") {
|
||||
if (lastEntry.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return lastEntry.value;
|
||||
@@ -100,7 +100,7 @@ export class CoMap<
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastOpBeforeOrAtTime.op === "delete") {
|
||||
if (lastOpBeforeOrAtTime.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return lastOpBeforeOrAtTime.value;
|
||||
@@ -139,7 +139,7 @@ export class CoMap<
|
||||
|
||||
const lastEntry = ops[ops.length - 1]!;
|
||||
|
||||
if (lastEntry.op === "delete") {
|
||||
if (lastEntry.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return { at: lastEntry.madeAt, txID: lastEntry.txID, value: lastEntry.value };
|
||||
@@ -155,7 +155,7 @@ export class CoMap<
|
||||
const history: { at: number; txID: TransactionID; value: M[K] | undefined; }[] = [];
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.op === "delete") {
|
||||
if (op.op === "del") {
|
||||
history.push({ at: op.madeAt, txID: op.txID, value: undefined });
|
||||
} else {
|
||||
history.push({ at: op.madeAt, txID: op.txID, value: op.value });
|
||||
@@ -199,7 +199,7 @@ export class WriteableCoMap<
|
||||
set<K extends MapK<M>>(key: K, value: M[K], privacy: "private" | "trusting" = "private"): void {
|
||||
this.coValue.makeTransaction([
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key,
|
||||
value,
|
||||
},
|
||||
@@ -211,7 +211,7 @@ export class WriteableCoMap<
|
||||
delete(key: MapK<M>, privacy: "private" | "trusting" = "private"): void {
|
||||
this.coValue.makeTransaction([
|
||||
{
|
||||
op: "delete",
|
||||
op: "del",
|
||||
key,
|
||||
},
|
||||
], privacy);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { x25519 } from "@noble/curves/ed25519";
|
||||
import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import stableStringify from "fast-json-stable-stringify";
|
||||
import { SessionID } from './ids.js';
|
||||
|
||||
test("Signatures round-trip and use stable stringify", () => {
|
||||
const data = { b: "world", a: "hello" };
|
||||
@@ -49,7 +50,7 @@ test("encrypting round-trips, but invalid receiver can't unseal", () => {
|
||||
|
||||
const nOnceMaterial = {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
} as const;
|
||||
|
||||
const sealed = seal(
|
||||
@@ -101,22 +102,22 @@ test("Encryption for transactions round-trips", () => {
|
||||
|
||||
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
const decrypted1 = decryptForTransaction(encrypted1, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
expect([decrypted1, decrypted2]).toEqual([{ a: "hello" }, { b: "world" }]);
|
||||
@@ -128,22 +129,22 @@ test("Encryption for transactions doesn't decrypt with a wrong key", () => {
|
||||
|
||||
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
const decrypted1 = decryptForTransaction(encrypted1, secret2, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret2, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
expect([decrypted1, decrypted2]).toEqual([undefined, undefined]);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ed25519, x25519 } from "@noble/curves/ed25519";
|
||||
import { xsalsa20_poly1305, xsalsa20 } from "@noble/ciphers/salsa";
|
||||
import { JsonValue } from './jsonValue.js';
|
||||
import { JsonValue } from "./jsonValue.js";
|
||||
import { base58, base64url } from "@scure/base";
|
||||
import stableStringify from "fast-json-stable-stringify";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
|
||||
import { AgentID, RawCoID, TransactionID } from './ids.js';
|
||||
import { AgentID, RawCoID, TransactionID } from "./ids.js";
|
||||
|
||||
export type SignerSecret = `signerSecret_z${string}`;
|
||||
export type SignerID = `signer_z${string}`;
|
||||
@@ -21,9 +21,7 @@ const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export function newRandomSigner(): SignerSecret {
|
||||
return `signerSecret_z${base58.encode(
|
||||
ed25519.utils.randomPrivateKey()
|
||||
)}`;
|
||||
return `signerSecret_z${base58.encode(ed25519.utils.randomPrivateKey())}`;
|
||||
}
|
||||
|
||||
export function signerSecretToBytes(secret: SignerSecret): Uint8Array {
|
||||
@@ -95,20 +93,16 @@ export function agentSecretToBytes(secret: AgentSecret): Uint8Array {
|
||||
}
|
||||
|
||||
export function agentSecretFromBytes(bytes: Uint8Array): AgentSecret {
|
||||
const sealerSecret = sealerSecretFromBytes(
|
||||
bytes.slice(0, 32)
|
||||
);
|
||||
const signerSecret = signerSecretFromBytes(
|
||||
bytes.slice(32)
|
||||
);
|
||||
const sealerSecret = sealerSecretFromBytes(bytes.slice(0, 32));
|
||||
const signerSecret = signerSecretFromBytes(bytes.slice(32));
|
||||
return `${sealerSecret}/${signerSecret}`;
|
||||
}
|
||||
|
||||
export function getAgentID(secret: AgentSecret): AgentID {
|
||||
const [sealerSecret, signerSecret] = secret.split("/");
|
||||
return `${getSealerID(
|
||||
sealerSecret as SealerSecret
|
||||
)}/${getSignerID(signerSecret as SignerSecret)}`;
|
||||
return `${getSealerID(sealerSecret as SealerSecret)}/${getSignerID(
|
||||
signerSecret as SignerSecret
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function getAgentSignerID(agentId: AgentID): SignerID {
|
||||
@@ -139,24 +133,17 @@ export function seal<T extends JsonValue>(
|
||||
|
||||
const sealerPub = base58.decode(to.substring("sealer_z".length));
|
||||
|
||||
const senderPriv = base58.decode(
|
||||
from.substring("sealerSecret_z".length)
|
||||
);
|
||||
const senderPriv = base58.decode(from.substring("sealerSecret_z".length));
|
||||
|
||||
const plaintext = textEncoder.encode(stableStringify(message));
|
||||
|
||||
const sharedSecret = x25519.getSharedSecret(
|
||||
senderPriv,
|
||||
sealerPub
|
||||
);
|
||||
const sharedSecret = x25519.getSharedSecret(senderPriv, sealerPub);
|
||||
|
||||
const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt(
|
||||
plaintext
|
||||
);
|
||||
|
||||
return `sealed_U${base64url.encode(
|
||||
sealedBytes
|
||||
)}` as Sealed<T>
|
||||
return `sealed_U${base64url.encode(sealedBytes)}` as Sealed<T>;
|
||||
}
|
||||
|
||||
export function unseal<T extends JsonValue>(
|
||||
@@ -169,9 +156,7 @@ export function unseal<T extends JsonValue>(
|
||||
textEncoder.encode(stableStringify(nOnceMaterial))
|
||||
).slice(0, 24);
|
||||
|
||||
const sealerPriv = base58.decode(
|
||||
sealer.substring("sealerSecret_z".length)
|
||||
);
|
||||
const sealerPriv = base58.decode(sealer.substring("sealerSecret_z".length));
|
||||
|
||||
const senderPub = base58.decode(from.substring("sealer_z".length));
|
||||
|
||||
@@ -221,10 +206,11 @@ export class StreamingHash {
|
||||
}
|
||||
|
||||
export type ShortHash = `shortHash_z${string}`;
|
||||
export const shortHashLength = 19;
|
||||
|
||||
export function shortHash(value: JsonValue): ShortHash {
|
||||
return `shortHash_z${base58.encode(
|
||||
blake3(textEncoder.encode(stableStringify(value))).slice(0, 19)
|
||||
blake3(textEncoder.encode(stableStringify(value))).slice(0, shortHashLength)
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -274,7 +260,10 @@ export function encryptKeySecret(keys: {
|
||||
}): {
|
||||
encryptedID: KeyID;
|
||||
encryptingID: KeyID;
|
||||
encrypted: Encrypted<KeySecret, { encryptedID: KeyID; encryptingID: KeyID }>;
|
||||
encrypted: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
} {
|
||||
const nOnceMaterial = {
|
||||
encryptedID: keys.toEncrypt.id,
|
||||
@@ -328,7 +317,10 @@ export function decryptKeySecret(
|
||||
encryptedInfo: {
|
||||
encryptedID: KeyID;
|
||||
encryptingID: KeyID;
|
||||
encrypted: Encrypted<KeySecret, { encryptedID: KeyID; encryptingID: KeyID }>;
|
||||
encrypted: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
},
|
||||
sealingSecret: KeySecret
|
||||
): KeySecret | undefined {
|
||||
@@ -344,10 +336,35 @@ export function uniquenessForHeader(): `z${string}` {
|
||||
return `z${base58.encode(randomBytes(12))}`;
|
||||
}
|
||||
|
||||
export function createdNowUnique(): {createdAt: `2${string}`, uniqueness: `z${string}`} {
|
||||
const createdAt = (new Date()).toISOString() as `2${string}`;
|
||||
export function createdNowUnique(): {
|
||||
createdAt: `2${string}`;
|
||||
uniqueness: `z${string}`;
|
||||
} {
|
||||
const createdAt = new Date().toISOString() as `2${string}`;
|
||||
return {
|
||||
createdAt,
|
||||
uniqueness: uniquenessForHeader(),
|
||||
};
|
||||
}
|
||||
|
||||
export const secretSeedLength = 32;
|
||||
|
||||
export function newRandomSecretSeed(): Uint8Array {
|
||||
return randomBytes(secretSeedLength);
|
||||
}
|
||||
|
||||
export function agentSecretFromSecretSeed(secretSeed: Uint8Array): AgentSecret {
|
||||
if (secretSeed.length !== secretSeedLength) {
|
||||
throw new Error(`Secret seed needs to be ${secretSeedLength} bytes long`);
|
||||
}
|
||||
}
|
||||
|
||||
return `sealerSecret_z${base58.encode(
|
||||
blake3(secretSeed, {
|
||||
context: textEncoder.encode("seal"),
|
||||
})
|
||||
)}/signerSecret_z${base58.encode(
|
||||
blake3(secretSeed, {
|
||||
context: textEncoder.encode("sign"),
|
||||
})
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { AccountIDOrAgentID } from './account.js';
|
||||
import { base58 } from "@scure/base";
|
||||
import { shortHashLength } from './crypto.js';
|
||||
|
||||
|
||||
export type RawCoID = `co_z${string}`;
|
||||
|
||||
export function rawCoIDtoBytes(id: RawCoID): Uint8Array {
|
||||
return base58.decode(
|
||||
id.substring("co_z".length)
|
||||
)
|
||||
}
|
||||
|
||||
export function rawCoIDfromBytes(bytes: Uint8Array): RawCoID {
|
||||
return `co_z${base58.encode(bytes.slice(0, shortHashLength))}` as RawCoID;
|
||||
}
|
||||
|
||||
export type TransactionID = { sessionID: SessionID; txIndex: number };
|
||||
|
||||
export type AgentID = `sealer_z${string}/signer_z${string}`;
|
||||
|
||||
@@ -1,25 +1,60 @@
|
||||
import { CoValue, newRandomSessionID } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { CoMap } from "./contentTypes/coMap.js";
|
||||
import { agentSecretFromBytes, agentSecretToBytes } from "./crypto.js";
|
||||
import {
|
||||
agentSecretFromBytes,
|
||||
agentSecretToBytes,
|
||||
getAgentID,
|
||||
newRandomAgentSecret,
|
||||
newRandomSecretSeed,
|
||||
agentSecretFromSecretSeed,
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
} from "./crypto.js";
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
|
||||
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
|
||||
import { Team, expectTeamContent } from "./team.js"
|
||||
|
||||
import type { SessionID } from "./ids.js";
|
||||
import type { SessionID, AgentID } from "./ids.js";
|
||||
import type { CoID, ContentType } from "./contentType.js";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import type { SyncMessage } from "./sync.js";
|
||||
import type { SyncMessage, Peer } from "./sync.js";
|
||||
import type { AgentSecret } from "./crypto.js";
|
||||
import type {
|
||||
AccountID,
|
||||
AccountContent,
|
||||
ProfileContent,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
import type { InviteSecret } from "./team.js";
|
||||
|
||||
type Value = JsonValue | ContentType;
|
||||
|
||||
const internals = {
|
||||
export const cojsonInternals = {
|
||||
agentSecretFromBytes,
|
||||
agentSecretToBytes,
|
||||
newRandomSessionID,
|
||||
connectedPeers
|
||||
newRandomAgentSecret,
|
||||
connectedPeers,
|
||||
getAgentID,
|
||||
rawCoIDtoBytes,
|
||||
rawCoIDfromBytes,
|
||||
newRandomSecretSeed,
|
||||
agentSecretFromSecretSeed,
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
expectTeamContent
|
||||
};
|
||||
|
||||
export { LocalNode, CoValue, CoMap, internals };
|
||||
export {
|
||||
LocalNode,
|
||||
CoValue,
|
||||
CoMap,
|
||||
AnonymousControlledAccount,
|
||||
ControlledAccount,
|
||||
Team
|
||||
};
|
||||
|
||||
export type {
|
||||
Value,
|
||||
@@ -29,4 +64,25 @@ export type {
|
||||
AgentSecret,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
AgentID,
|
||||
AccountID,
|
||||
Peer,
|
||||
AccountContent,
|
||||
Profile,
|
||||
ProfileContent,
|
||||
InviteSecret
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace CojsonInternalTypes {
|
||||
export type CoValueKnownState = import("./sync.js").CoValueKnownState;
|
||||
export type DoneMessage = import("./sync.js").DoneMessage;
|
||||
export type KnownStateMessage = import("./sync.js").KnownStateMessage;
|
||||
export type LoadMessage = import("./sync.js").LoadMessage;
|
||||
export type NewContentMessage = import("./sync.js").NewContentMessage;
|
||||
export type CoValueHeader = import("./coValue.js").CoValueHeader;
|
||||
export type Transaction = import("./coValue.js").Transaction;
|
||||
export type Signature = import("./crypto.js").Signature;
|
||||
export type RawCoID = import("./ids.js").RawCoID;
|
||||
export type AccountIDOrAgentID = import("./account.js").AccountIDOrAgentID;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CoID, ContentType } from './contentType.js';
|
||||
import { RawCoID } from './ids.js';
|
||||
|
||||
export type JsonAtom = string | number | boolean | null;
|
||||
export type JsonValue = JsonAtom | JsonArray | JsonObject | CoID<ContentType>;
|
||||
export type JsonValue = JsonAtom | JsonArray | JsonObject | RawCoID;
|
||||
export type JsonArray = JsonValue[];
|
||||
export type JsonObject = { [key: string]: JsonValue; };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AgentSecret,
|
||||
agentSecretFromSecretSeed,
|
||||
createdNowUnique,
|
||||
getAgentID,
|
||||
getAgentSealerID,
|
||||
@@ -9,7 +10,13 @@ import {
|
||||
seal,
|
||||
} from "./crypto.js";
|
||||
import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js";
|
||||
import { Team, TeamContent, expectTeamContent } from "./permissions.js";
|
||||
import {
|
||||
InviteSecret,
|
||||
Team,
|
||||
TeamContent,
|
||||
expectTeamContent,
|
||||
secretSeedFromInviteSecret,
|
||||
} from "./team.js";
|
||||
import { Peer, SyncManager } from "./sync.js";
|
||||
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
@@ -43,7 +50,10 @@ export class LocalNode {
|
||||
this.ownSessionID = ownSessionID;
|
||||
}
|
||||
|
||||
static withNewlyCreatedAccount(name: string): {
|
||||
static withNewlyCreatedAccount(
|
||||
name: string,
|
||||
initialAgentSecret = newRandomAgentSecret()
|
||||
): {
|
||||
node: LocalNode;
|
||||
accountID: AccountID;
|
||||
accountSecret: AgentSecret;
|
||||
@@ -55,7 +65,7 @@ export class LocalNode {
|
||||
newRandomSessionID(getAgentID(throwawayAgent))
|
||||
);
|
||||
|
||||
const account = setupNode.createAccount(name);
|
||||
const account = setupNode.createAccount(name, initialAgentSecret);
|
||||
|
||||
const nodeWithAccount = account.node.testWithDifferentAccount(
|
||||
account,
|
||||
@@ -70,8 +80,16 @@ export class LocalNode {
|
||||
};
|
||||
}
|
||||
|
||||
static async withLoadedAccount(accountID: AccountID, accountSecret: AgentSecret, sessionID: SessionID, peersToLoadFrom: Peer[]): Promise<LocalNode> {
|
||||
const loadingNode = new LocalNode(new AnonymousControlledAccount(accountSecret), newRandomSessionID(accountID));
|
||||
static async withLoadedAccount(
|
||||
accountID: AccountID,
|
||||
accountSecret: AgentSecret,
|
||||
sessionID: SessionID,
|
||||
peersToLoadFrom: Peer[]
|
||||
): Promise<LocalNode> {
|
||||
const loadingNode = new LocalNode(
|
||||
new AnonymousControlledAccount(accountSecret),
|
||||
newRandomSessionID(accountID)
|
||||
);
|
||||
|
||||
const accountPromise = loadingNode.load(accountID);
|
||||
|
||||
@@ -82,7 +100,10 @@ export class LocalNode {
|
||||
const account = await accountPromise;
|
||||
|
||||
// since this is all synchronous, we can just swap out nodes for the SyncManager
|
||||
const node = loadingNode.testWithDifferentAccount(new ControlledAccount(accountSecret, account, loadingNode), sessionID);
|
||||
const node = loadingNode.testWithDifferentAccount(
|
||||
new ControlledAccount(accountSecret, account, loadingNode),
|
||||
sessionID
|
||||
);
|
||||
node.sync = loadingNode.sync;
|
||||
node.sync.local = node;
|
||||
|
||||
@@ -124,7 +145,81 @@ export class LocalNode {
|
||||
if (!profileID) {
|
||||
throw new Error(`Account ${id} has no profile`);
|
||||
}
|
||||
return (await this.loadCoValue(profileID)).getCurrentContent() as Profile;
|
||||
return (
|
||||
await this.loadCoValue(profileID)
|
||||
).getCurrentContent() as Profile;
|
||||
}
|
||||
|
||||
async acceptInvite<T extends ContentType>(
|
||||
teamOrOwnedValueID: CoID<T>,
|
||||
inviteSecret: InviteSecret
|
||||
): Promise<void> {
|
||||
const teamOrOwnedValue = await this.load(teamOrOwnedValueID);
|
||||
|
||||
if (teamOrOwnedValue.coValue.header.ruleset.type === "ownedByTeam") {
|
||||
return this.acceptInvite(
|
||||
teamOrOwnedValue.coValue.header.ruleset.team as CoID<
|
||||
CoMap<TeamContent>
|
||||
>,
|
||||
inviteSecret
|
||||
);
|
||||
} else if (teamOrOwnedValue.coValue.header.ruleset.type !== "team") {
|
||||
throw new Error("Can only accept invites to teams");
|
||||
}
|
||||
|
||||
const team = new Team(expectTeamContent(teamOrOwnedValue), this);
|
||||
|
||||
const inviteAgentSecret = agentSecretFromSecretSeed(
|
||||
secretSeedFromInviteSecret(inviteSecret)
|
||||
);
|
||||
const inviteAgentID = getAgentID(inviteAgentSecret);
|
||||
|
||||
const invitationRole = await new Promise((resolve, reject) => {
|
||||
team.teamMap.subscribe((teamMap) => {
|
||||
const role = teamMap.get(inviteAgentID);
|
||||
if (role) {
|
||||
resolve(role);
|
||||
}
|
||||
});
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error("Couldn't find invitation before timeout")
|
||||
),
|
||||
1000
|
||||
);
|
||||
});
|
||||
|
||||
if (!invitationRole) {
|
||||
throw new Error("No invitation found");
|
||||
}
|
||||
|
||||
const existingRole = team.teamMap.get(this.account.id);
|
||||
|
||||
if (
|
||||
existingRole === "admin" ||
|
||||
(existingRole === "writer" && invitationRole === "reader")
|
||||
) {
|
||||
console.debug("Not accepting invite that would downgrade role");
|
||||
return;
|
||||
}
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteAgentSecret),
|
||||
newRandomSessionID(inviteAgentID)
|
||||
);
|
||||
|
||||
teamAsInvite.addMember(
|
||||
this.account.id,
|
||||
invitationRole === "adminInvite"
|
||||
? "admin"
|
||||
: invitationRole === "writerInvite"
|
||||
? "writer"
|
||||
: "reader"
|
||||
);
|
||||
|
||||
team.teamMap.coValue._sessions = teamAsInvite.teamMap.coValue.sessions;
|
||||
team.teamMap.coValue._cachedContent = undefined;
|
||||
}
|
||||
|
||||
expectCoValueLoaded(id: RawCoID, expectation?: string): CoValue {
|
||||
@@ -146,7 +241,9 @@ export class LocalNode {
|
||||
|
||||
expectProfileLoaded(id: AccountID, expectation?: string): Profile {
|
||||
const account = this.expectCoValueLoaded(id, expectation);
|
||||
const profileID = expectTeamContent(account.getCurrentContent()).get("profile");
|
||||
const profileID = expectTeamContent(account.getCurrentContent()).get(
|
||||
"profile"
|
||||
);
|
||||
if (!profileID) {
|
||||
throw new Error(
|
||||
`${
|
||||
@@ -154,12 +251,16 @@ export class LocalNode {
|
||||
}Account ${id} has no profile`
|
||||
);
|
||||
}
|
||||
return this.expectCoValueLoaded(profileID, expectation).getCurrentContent() as Profile;
|
||||
return this.expectCoValueLoaded(
|
||||
profileID,
|
||||
expectation
|
||||
).getCurrentContent() as Profile;
|
||||
}
|
||||
|
||||
createAccount(name: string): ControlledAccount {
|
||||
const agentSecret = newRandomAgentSecret();
|
||||
|
||||
createAccount(
|
||||
name: string,
|
||||
agentSecret = newRandomAgentSecret()
|
||||
): ControlledAccount {
|
||||
const account = this.createCoValue(
|
||||
accountHeaderForInitialAgentSecret(agentSecret)
|
||||
).testWithDifferentAccount(
|
||||
@@ -167,7 +268,10 @@ export class LocalNode {
|
||||
newRandomSessionID(getAgentID(agentSecret))
|
||||
);
|
||||
|
||||
const accountAsTeam = new Team(expectTeamContent(account.getCurrentContent()), account.node);
|
||||
const accountAsTeam = new Team(
|
||||
expectTeamContent(account.getCurrentContent()),
|
||||
account.node
|
||||
);
|
||||
|
||||
accountAsTeam.teamMap.edit((editable) => {
|
||||
editable.set(getAgentID(agentSecret), "admin", "trusting");
|
||||
@@ -197,7 +301,9 @@ export class LocalNode {
|
||||
account.node
|
||||
);
|
||||
|
||||
const profile = accountAsTeam.createMap<ProfileContent, ProfileMeta>({ type: "profile" });
|
||||
const profile = accountAsTeam.createMap<ProfileContent, ProfileMeta>({
|
||||
type: "profile",
|
||||
});
|
||||
|
||||
profile.edit((editable) => {
|
||||
editable.set("name", name, "trusting");
|
||||
@@ -207,6 +313,11 @@ export class LocalNode {
|
||||
editable.set("profile", profile.id, "trusting");
|
||||
});
|
||||
|
||||
const accountOnThisNode = this.expectCoValueLoaded(account.id);
|
||||
|
||||
accountOnThisNode._sessions = {...accountAsTeam.teamMap.coValue.sessions};
|
||||
accountOnThisNode._cachedContent = undefined;
|
||||
|
||||
return controlledAccount;
|
||||
}
|
||||
|
||||
@@ -278,24 +389,36 @@ export class LocalNode {
|
||||
): LocalNode {
|
||||
const newNode = new LocalNode(account, ownSessionID);
|
||||
|
||||
newNode.coValues = Object.fromEntries(
|
||||
Object.entries(this.coValues)
|
||||
.map(([id, entry]) => {
|
||||
if (entry.state === "loading") {
|
||||
return undefined;
|
||||
}
|
||||
const coValuesToCopy = Object.entries(this.coValues);
|
||||
|
||||
const newCoValue = new CoValue(
|
||||
entry.coValue.header,
|
||||
newNode
|
||||
);
|
||||
while (coValuesToCopy.length > 0) {
|
||||
const [coValueID, entry] =
|
||||
coValuesToCopy[coValuesToCopy.length - 1]!;
|
||||
|
||||
newCoValue.sessions = entry.coValue.sessions;
|
||||
if (entry.state === "loading") {
|
||||
coValuesToCopy.pop();
|
||||
continue;
|
||||
} else {
|
||||
const allDepsCopied = entry.coValue
|
||||
.getDependedOnCoValues()
|
||||
.every((dep) => newNode.coValues[dep]?.state === "loaded");
|
||||
|
||||
return [id, { state: "loaded", coValue: newCoValue }];
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x)
|
||||
);
|
||||
if (!allDepsCopied) {
|
||||
// move to end of queue
|
||||
coValuesToCopy.unshift(coValuesToCopy.pop()!);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newCoValue = new CoValue(entry.coValue.header, newNode, {...entry.coValue.sessions});
|
||||
|
||||
newNode.coValues[coValueID as RawCoID] = {
|
||||
state: "loaded",
|
||||
coValue: newCoValue,
|
||||
};
|
||||
|
||||
coValuesToCopy.pop();
|
||||
}
|
||||
}
|
||||
|
||||
return newNode;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { newRandomSessionID } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { expectMap } from "./contentType.js";
|
||||
import { expectTeamContent } from "./permissions.js";
|
||||
import { Team, expectTeamContent } from "./team.js";
|
||||
import {
|
||||
createdNowUnique,
|
||||
getSealerID,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
encryptKeySecret,
|
||||
newRandomAgentSecret,
|
||||
getAgentID,
|
||||
getAgentSealerSecret,
|
||||
getAgentSealerID,
|
||||
} from "./crypto.js";
|
||||
import {
|
||||
newTeam,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
teamWithTwoAdmins,
|
||||
teamWithTwoAdminsHighLevel,
|
||||
} from "./testUtils.js";
|
||||
import { AnonymousControlledAccount } from "./index.js";
|
||||
|
||||
test("Initial admin can add another admin to a team", () => {
|
||||
teamWithTwoAdmins();
|
||||
@@ -1265,3 +1268,500 @@ test("Can create two owned objects in the same team and they will have different
|
||||
|
||||
expect(childObject1.id).not.toEqual(childObject2.id);
|
||||
});
|
||||
|
||||
test("Admins can create an adminInvite, which can add an admin", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "adminInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("adminInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedAdminSecret = newRandomAgentSecret();
|
||||
const invitedAdminID = getAgentID(invitedAdminSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedAdminID, "admin", "trusting");
|
||||
|
||||
expect(editable.get(invitedAdminID)).toEqual("admin");
|
||||
|
||||
const readKey = teamAsInvite.getCurrentReadKey();
|
||||
|
||||
expect(readKey.secret).toBeDefined();
|
||||
|
||||
const revelation = seal(
|
||||
readKey.secret!,
|
||||
getAgentSealerSecret(invitedAdminSecret),
|
||||
getAgentSealerID(invitedAdminID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${invitedAdminID}`,
|
||||
revelation,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
expect(editable.get(`${readKey.id}_for_${invitedAdminID}`)).toEqual(
|
||||
revelation
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("Admins can create an adminInvite, which can add an admin (high-level)", async () => {
|
||||
const { node, team, admin } = newTeamHighLevel();
|
||||
|
||||
const inviteSecret = team.createInvite("admin");
|
||||
|
||||
const invitedAdminSecret = newRandomAgentSecret();
|
||||
const invitedAdminID = getAgentID(invitedAdminSecret);
|
||||
|
||||
const nodeAsInvitedAdmin = node.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(invitedAdminSecret),
|
||||
newRandomSessionID(invitedAdminID)
|
||||
);
|
||||
|
||||
await nodeAsInvitedAdmin.acceptInvite(team.id, inviteSecret);
|
||||
|
||||
const thirdAdmin = newRandomAgentSecret();
|
||||
const thirdAdminID = getAgentID(thirdAdmin);
|
||||
|
||||
const teamAsInvitedAdmin = new Team(
|
||||
await nodeAsInvitedAdmin.load(team.id),
|
||||
nodeAsInvitedAdmin
|
||||
);
|
||||
|
||||
expect(teamAsInvitedAdmin.teamMap.get(invitedAdminID)).toEqual("admin");
|
||||
expect(
|
||||
teamAsInvitedAdmin.teamMap.coValue.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
|
||||
teamAsInvitedAdmin.addMember(thirdAdminID, "admin");
|
||||
|
||||
expect(teamAsInvitedAdmin.teamMap.get(thirdAdminID)).toEqual("admin");
|
||||
});
|
||||
|
||||
test("Admins can create a writerInvite, which can add a writer", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "writerInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("writerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedWriterSecret = newRandomAgentSecret();
|
||||
const invitedWriterID = getAgentID(invitedWriterSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedWriterID, "writer", "trusting");
|
||||
|
||||
expect(editable.get(invitedWriterID)).toEqual("writer");
|
||||
|
||||
const readKey = teamAsInvite.getCurrentReadKey();
|
||||
|
||||
expect(readKey.secret).toBeDefined();
|
||||
|
||||
const revelation = seal(
|
||||
readKey.secret!,
|
||||
getAgentSealerSecret(invitedWriterSecret),
|
||||
getAgentSealerID(invitedWriterID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${invitedWriterID}`,
|
||||
revelation,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
expect(editable.get(`${readKey.id}_for_${invitedWriterID}`)).toEqual(
|
||||
revelation
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("Admins can create a writerInvite, which can add a writer (high-level)", async () => {
|
||||
const { node, team, admin } = newTeamHighLevel();
|
||||
|
||||
const inviteSecret = team.createInvite("writer");
|
||||
|
||||
const invitedWriterSecret = newRandomAgentSecret();
|
||||
const invitedWriterID = getAgentID(invitedWriterSecret);
|
||||
|
||||
const nodeAsInvitedWriter = node.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(invitedWriterSecret),
|
||||
newRandomSessionID(invitedWriterID)
|
||||
);
|
||||
|
||||
await nodeAsInvitedWriter.acceptInvite(team.id, inviteSecret);
|
||||
|
||||
const teamAsInvitedWriter = new Team(
|
||||
await nodeAsInvitedWriter.load(team.id),
|
||||
nodeAsInvitedWriter
|
||||
);
|
||||
|
||||
expect(teamAsInvitedWriter.teamMap.get(invitedWriterID)).toEqual("writer");
|
||||
expect(
|
||||
teamAsInvitedWriter.teamMap.coValue.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
test("Admins can create a readerInvite, which can add a reader", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "readerInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("readerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedReaderSecret = newRandomAgentSecret();
|
||||
const invitedReaderID = getAgentID(invitedReaderSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedReaderID, "reader", "trusting");
|
||||
|
||||
expect(editable.get(invitedReaderID)).toEqual("reader");
|
||||
|
||||
const readKey = teamAsInvite.getCurrentReadKey();
|
||||
|
||||
expect(readKey.secret).toBeDefined();
|
||||
|
||||
const revelation = seal(
|
||||
readKey.secret!,
|
||||
getAgentSealerSecret(invitedReaderSecret),
|
||||
getAgentSealerID(invitedReaderID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${invitedReaderID}`,
|
||||
revelation,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
expect(editable.get(`${readKey.id}_for_${invitedReaderID}`)).toEqual(
|
||||
revelation
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("Admins can create a readerInvite, which can add a reader (high-level)", async () => {
|
||||
const { node, team, admin } = newTeamHighLevel();
|
||||
|
||||
const inviteSecret = team.createInvite("reader");
|
||||
|
||||
const invitedReaderSecret = newRandomAgentSecret();
|
||||
const invitedReaderID = getAgentID(invitedReaderSecret);
|
||||
|
||||
const nodeAsInvitedReader = node.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(invitedReaderSecret),
|
||||
newRandomSessionID(invitedReaderID)
|
||||
);
|
||||
|
||||
await nodeAsInvitedReader.acceptInvite(team.id, inviteSecret);
|
||||
|
||||
const teamAsInvitedReader = new Team(
|
||||
await nodeAsInvitedReader.load(team.id),
|
||||
nodeAsInvitedReader
|
||||
);
|
||||
|
||||
expect(teamAsInvitedReader.teamMap.get(invitedReaderID)).toEqual("reader");
|
||||
expect(
|
||||
teamAsInvitedReader.teamMap.coValue.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("WriterInvites can not invite admins", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "writerInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("writerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedAdminSecret = newRandomAgentSecret();
|
||||
const invitedAdminID = getAgentID(invitedAdminSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedAdminID, "admin", "trusting");
|
||||
expect(editable.get(invitedAdminID)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("ReaderInvites can not invite admins", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "readerInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("readerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedAdminSecret = newRandomAgentSecret();
|
||||
const invitedAdminID = getAgentID(invitedAdminSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedAdminID, "admin", "trusting");
|
||||
expect(editable.get(invitedAdminID)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("ReaderInvites can not invite writers", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "readerInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("readerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedWriterSecret = newRandomAgentSecret();
|
||||
const invitedWriterID = getAgentID(invitedWriterSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedWriterID, "writer", "trusting");
|
||||
expect(editable.get(invitedWriterID)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { CoID } from "./contentType.js";
|
||||
import { MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
Encrypted,
|
||||
KeyID,
|
||||
KeySecret,
|
||||
createdNowUnique,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
encryptKeySecret,
|
||||
getAgentSealerID,
|
||||
Sealed,
|
||||
} from "./crypto.js";
|
||||
import {
|
||||
CoValue,
|
||||
@@ -18,16 +10,25 @@ import {
|
||||
TrustingTransaction,
|
||||
accountOrAgentIDfromSessionID,
|
||||
} from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { RawCoID, SessionID, TransactionID, isAgentID } from "./ids.js";
|
||||
import { AccountIDOrAgentID, GeneralizedControlledAccount, Profile } from "./account.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import {
|
||||
AccountIDOrAgentID,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
|
||||
export type PermissionsDef =
|
||||
| { type: "team"; initialAdmin: AccountIDOrAgentID }
|
||||
| { type: "ownedByTeam"; team: RawCoID }
|
||||
| { type: "unsafeAllowAll" };
|
||||
|
||||
export type Role = "reader" | "writer" | "admin" | "revoked";
|
||||
export type Role =
|
||||
| "reader"
|
||||
| "writer"
|
||||
| "admin"
|
||||
| "revoked"
|
||||
| "adminInvite"
|
||||
| "writerInvite"
|
||||
| "readerInvite";
|
||||
|
||||
export function determineValidTransactions(
|
||||
coValue: CoValue
|
||||
@@ -84,7 +85,7 @@ export function determineValidTransactions(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.op !== "insert") {
|
||||
if (change.op !== "set") {
|
||||
console.warn("Team transaction must set a role or readKey");
|
||||
continue;
|
||||
}
|
||||
@@ -97,7 +98,7 @@ export function determineValidTransactions(
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (change.key === 'profile') {
|
||||
} else if (change.key === "profile") {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
console.warn("Only admins can set profile");
|
||||
continue;
|
||||
@@ -105,8 +106,16 @@ export function determineValidTransactions(
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (isKeyForKeyField(change.key) || isKeyForAccountField(change.key)) {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
} else if (
|
||||
isKeyForKeyField(change.key) ||
|
||||
isKeyForAccountField(change.key)
|
||||
) {
|
||||
if (
|
||||
memberState[transactor] !== "admin" &&
|
||||
memberState[transactor] !== "adminInvite" &&
|
||||
memberState[transactor] !== "writerInvite" &&
|
||||
memberState[transactor] !== "readerInvite"
|
||||
) {
|
||||
console.warn("Only admins can reveal keys");
|
||||
continue;
|
||||
}
|
||||
@@ -124,7 +133,10 @@ export function determineValidTransactions(
|
||||
change.value !== "admin" &&
|
||||
change.value !== "writer" &&
|
||||
change.value !== "reader" &&
|
||||
change.value !== "revoked"
|
||||
change.value !== "revoked" &&
|
||||
change.value !== "adminInvite" &&
|
||||
change.value !== "writerInvite" &&
|
||||
change.value !== "readerInvite"
|
||||
) {
|
||||
console.warn("Team transaction must set a valid role");
|
||||
continue;
|
||||
@@ -133,26 +145,41 @@ export function determineValidTransactions(
|
||||
const isFirstSelfAppointment =
|
||||
!memberState[transactor] &&
|
||||
transactor === initialAdmin &&
|
||||
change.op === "insert" &&
|
||||
change.op === "set" &&
|
||||
change.key === transactor &&
|
||||
change.value === "admin";
|
||||
|
||||
if (!isFirstSelfAppointment) {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
if (memberState[transactor] === "admin") {
|
||||
if (
|
||||
memberState[affectedMember] === "admin" &&
|
||||
affectedMember !== transactor &&
|
||||
assignedRole !== "admin"
|
||||
) {
|
||||
console.warn("Admins can only demote themselves.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "adminInvite") {
|
||||
if (change.value !== "admin") {
|
||||
console.warn("AdminInvites can only create admins.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "writerInvite") {
|
||||
if (change.value !== "writer") {
|
||||
console.warn("WriterInvites can only create writers.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "readerInvite") {
|
||||
if (change.value !== "reader") {
|
||||
console.warn("ReaderInvites can only create reader.");
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Team transaction must be made by current admin"
|
||||
"Team transaction must be made by current admin or invite"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
memberState[affectedMember] === "admin" &&
|
||||
affectedMember !== transactor &&
|
||||
assignedRole !== "admin"
|
||||
) {
|
||||
console.warn("Admins can only demote themselves.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
memberState[affectedMember] = change.value;
|
||||
@@ -213,180 +240,17 @@ export function determineValidTransactions(
|
||||
}
|
||||
}
|
||||
|
||||
export type TeamContent = {
|
||||
profile: CoID<Profile> | null;
|
||||
[key: AccountIDOrAgentID]: Role;
|
||||
readKey: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
};
|
||||
|
||||
export function expectTeamContent(
|
||||
content: ContentType
|
||||
): CoMap<TeamContent, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<TeamContent, JsonObject | null>;
|
||||
}
|
||||
|
||||
export class Team {
|
||||
teamMap: CoMap<TeamContent, JsonObject | null>;
|
||||
node: LocalNode;
|
||||
|
||||
constructor(teamMap: CoMap<TeamContent, JsonObject | null>, node: LocalNode) {
|
||||
this.teamMap = teamMap;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
get id(): CoID<CoMap<TeamContent, JsonObject | null>> {
|
||||
return this.teamMap.id;
|
||||
}
|
||||
|
||||
addMember(accountID: AccountIDOrAgentID, role: Role) {
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
const agent = this.node.resolveAccountAgent(
|
||||
accountID,
|
||||
"Expected to know agent to add them to team"
|
||||
);
|
||||
|
||||
map.set(accountID, role, "trusting");
|
||||
|
||||
if (map.get(accountID) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal(
|
||||
currentReadKey.secret,
|
||||
this.teamMap.coValue.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(agent),
|
||||
{
|
||||
in: this.teamMap.coValue.id,
|
||||
tx: this.teamMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
rotateReadKey() {
|
||||
const currentlyPermittedReaders = this.teamMap.keys().filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.teamMap.get(key);
|
||||
return (
|
||||
role === "admin" || role === "writer" || role === "reader"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as AccountIDOrAgentID[];
|
||||
|
||||
const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error(
|
||||
"Can't rotate read key secret we don't have access to"
|
||||
);
|
||||
}
|
||||
|
||||
const currentReadKey = {
|
||||
id: maybeCurrentReadKey.id,
|
||||
secret: maybeCurrentReadKey.secret,
|
||||
};
|
||||
|
||||
const newReadKey = newRandomKeySecret();
|
||||
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.node.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader"
|
||||
);
|
||||
|
||||
map.set(
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
seal(
|
||||
newReadKey.secret,
|
||||
this.teamMap.coValue.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(reader),
|
||||
{
|
||||
in: this.teamMap.coValue.id,
|
||||
tx: this.teamMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${newReadKey.id}`,
|
||||
encryptKeySecret({
|
||||
encrypting: newReadKey,
|
||||
toEncrypt: currentReadKey,
|
||||
}).encrypted,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
map.set("readKey", newReadKey.id, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
removeMember(accountID: AccountIDOrAgentID) {
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
map.set(accountID, "revoked", "trusting");
|
||||
});
|
||||
|
||||
this.rotateReadKey();
|
||||
}
|
||||
|
||||
createMap<M extends { [key: string]: JsonValue }, Meta extends JsonObject | null>(
|
||||
meta?: Meta
|
||||
): CoMap<M, Meta> {
|
||||
return this.node
|
||||
.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "ownedByTeam",
|
||||
team: this.teamMap.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as CoMap<M, Meta>;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
sessionId: SessionID
|
||||
): Team {
|
||||
return new Team(
|
||||
expectTeamContent(
|
||||
this.teamMap.coValue
|
||||
.testWithDifferentAccount(account, sessionId)
|
||||
.getCurrentContent()
|
||||
),
|
||||
this.node
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isKeyForKeyField(field: string): field is `${KeyID}_for_${KeyID}` {
|
||||
export function isKeyForKeyField(
|
||||
field: string
|
||||
): field is `${KeyID}_for_${KeyID}` {
|
||||
return field.startsWith("key_") && field.includes("_for_key");
|
||||
}
|
||||
|
||||
export function isKeyForAccountField(field: string): field is `${KeyID}_for_${AccountIDOrAgentID}` {
|
||||
return field.startsWith("key_") && (field.includes("_for_sealer") || field.includes("_for_co"));
|
||||
}
|
||||
export function isKeyForAccountField(
|
||||
field: string
|
||||
): field is `${KeyID}_for_${AccountIDOrAgentID}` {
|
||||
return (
|
||||
field.startsWith("key_") &&
|
||||
(field.includes("_for_sealer") || field.includes("_for_co"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function connectedPeers(
|
||||
chunk: SyncMessage,
|
||||
controller: { enqueue: (msg: SyncMessage) => void; }
|
||||
) {
|
||||
trace && console.log(`${peer2id} -> ${peer1id}`, JSON.stringify(chunk, null, 2));
|
||||
trace && console.debug(`${peer2id} -> ${peer1id}`, JSON.stringify(chunk, null, 2));
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
})
|
||||
@@ -40,7 +40,7 @@ export function connectedPeers(
|
||||
chunk: SyncMessage,
|
||||
controller: { enqueue: (msg: SyncMessage) => void; }
|
||||
) {
|
||||
trace && console.log(`${peer1id} -> ${peer2id}`, JSON.stringify(chunk, null, 2));
|
||||
trace && console.debug(`${peer1id} -> ${peer2id}`, JSON.stringify(chunk, null, 2));
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
})
|
||||
@@ -100,14 +100,14 @@ export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
|
||||
);
|
||||
},
|
||||
|
||||
cancel(reason) {
|
||||
cancel(_reason) {
|
||||
console.log("Manually closing reader");
|
||||
readerClosed = true;
|
||||
},
|
||||
});
|
||||
|
||||
const writable = new WritableStream<T>({
|
||||
write(chunk, controller) {
|
||||
write(chunk) {
|
||||
if (readerClosed) {
|
||||
console.log("Reader closed, not writing chunk", chunk);
|
||||
throw new Error("Reader closed, not writing chunk");
|
||||
@@ -118,7 +118,7 @@ export function newStreamPair<T>(): [ReadableStream<T>, WritableStream<T>] {
|
||||
setTimeout(() => resolveNextItemReady());
|
||||
}
|
||||
},
|
||||
abort(reason) {
|
||||
abort(_reason) {
|
||||
console.log("Manually closing writer");
|
||||
writerClosed = true;
|
||||
resolveNextItemReady();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LocalNode } from "./node.js";
|
||||
import { Peer, PeerID, SyncMessage } from "./sync.js";
|
||||
import { expectMap } from "./contentType.js";
|
||||
import { MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { Team } from "./permissions.js";
|
||||
import { Team } from "./team.js";
|
||||
import {
|
||||
ReadableStream,
|
||||
WritableStream,
|
||||
@@ -86,7 +86,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -164,7 +164,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -253,7 +253,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -285,7 +285,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -364,7 +364,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -467,7 +467,7 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -570,7 +570,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Hash, Signature } from "./crypto.js";
|
||||
import { Signature } from "./crypto.js";
|
||||
import { CoValueHeader, Transaction } from "./coValue.js";
|
||||
import { CoValue } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
@@ -285,7 +285,7 @@ export class SyncManager {
|
||||
|
||||
trySendToPeer(peer: PeerState, msg: SyncMessage) {
|
||||
return peer.outgoing.write(msg).catch((e) => {
|
||||
console.error(new Error("Error writing to peer, disconnecting", {cause: e}));
|
||||
console.error(new Error(`Error writing to peer ${peer.id}, disconnecting`, {cause: e}));
|
||||
delete this.peers[peer.id];
|
||||
});
|
||||
}
|
||||
|
||||
233
packages/cojson/src/team.ts
Normal file
233
packages/cojson/src/team.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
import { CoMap } from "./contentTypes/coMap.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
Encrypted,
|
||||
KeyID,
|
||||
KeySecret,
|
||||
createdNowUnique,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
encryptKeySecret,
|
||||
getAgentSealerID,
|
||||
Sealed,
|
||||
newRandomSecretSeed,
|
||||
agentSecretFromSecretSeed,
|
||||
getAgentID,
|
||||
} from "./crypto.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { SessionID, isAgentID } from "./ids.js";
|
||||
import {
|
||||
AccountIDOrAgentID,
|
||||
GeneralizedControlledAccount,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
import { Role } from "./permissions.js";
|
||||
import { base58 } from "@scure/base";
|
||||
|
||||
export type 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;
|
||||
}
|
||||
|
||||
roleOf(accountID: AccountIDOrAgentID): Role | undefined {
|
||||
return this.teamMap.get(accountID);
|
||||
}
|
||||
|
||||
myRole(): Role | undefined {
|
||||
return this.roleOf(this.node.account.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"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
|
||||
const secretSeed = newRandomSecretSeed();
|
||||
|
||||
const inviteSecret = agentSecretFromSecretSeed(secretSeed);
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
this.addMember(inviteID, `${role}Invite` as Role);
|
||||
|
||||
return inviteSecretFromSecretSeed(secretSeed);
|
||||
}
|
||||
|
||||
rotateReadKey() {
|
||||
const currentlyPermittedReaders = this.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 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));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "./crypto.js";
|
||||
import { newRandomSessionID } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { expectTeamContent } from "./permissions.js";
|
||||
import { expectTeamContent } from "./team.js";
|
||||
import { AnonymousControlledAccount } from "./account.js";
|
||||
import { SessionID } from "./ids.js";
|
||||
|
||||
|
||||
@@ -12,4 +12,5 @@
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["./src/**/*.test.*"],
|
||||
}
|
||||
|
||||
21
packages/jazz-browser-auth-local/.eslintrc.cjs
Normal file
21
packages/jazz-browser-auth-local/.eslintrc.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:require-extensions/recommended",
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint", "require-extensions"],
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
};
|
||||
171
packages/jazz-browser-auth-local/.gitignore
vendored
Normal file
171
packages/jazz-browser-auth-local/.gitignore
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
.DS_Store
|
||||
2
packages/jazz-browser-auth-local/.npmignore
Normal file
2
packages/jazz-browser-auth-local/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
coverage
|
||||
node_modules
|
||||
16
packages/jazz-browser-auth-local/package.json
Normal file
16
packages/jazz-browser-auth-local/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "jazz-browser-auth-local",
|
||||
"version": "0.0.6",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser": "^0.0.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
212
packages/jazz-browser-auth-local/src/index.ts
Normal file
212
packages/jazz-browser-auth-local/src/index.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
AccountID,
|
||||
AgentSecret,
|
||||
cojsonInternals,
|
||||
LocalNode,
|
||||
Peer,
|
||||
} from "cojson";
|
||||
import { agentSecretFromSecretSeed } from "cojson/src/crypto";
|
||||
import { AuthProvider, SessionProvider } from "jazz-browser";
|
||||
|
||||
type SessionStorageData = {
|
||||
accountID: AccountID;
|
||||
accountSecret: AgentSecret;
|
||||
};
|
||||
|
||||
const sessionStorageKey = "jazz-logged-in-secret";
|
||||
|
||||
export interface BrowserLocalAuthDriver {
|
||||
onReady: (next: {
|
||||
signUp: (username: string) => Promise<void>;
|
||||
logIn: () => Promise<void>;
|
||||
}) => void;
|
||||
onSignedIn: (next: { logOut: () => void }) => void;
|
||||
}
|
||||
|
||||
export class BrowserLocalAuth implements AuthProvider {
|
||||
driver: BrowserLocalAuthDriver;
|
||||
appName: string;
|
||||
appHostname: string;
|
||||
|
||||
constructor(
|
||||
driver: BrowserLocalAuthDriver,
|
||||
appName: string,
|
||||
// TODO: is this a safe default?
|
||||
appHostname: string = window.location.hostname
|
||||
) {
|
||||
this.driver = driver;
|
||||
this.appName = appName;
|
||||
this.appHostname = appHostname;
|
||||
}
|
||||
|
||||
async createNode(
|
||||
getSessionFor: SessionProvider,
|
||||
initialPeers: Peer[]
|
||||
): Promise<LocalNode> {
|
||||
if (sessionStorage[sessionStorageKey]) {
|
||||
const sessionStorageData = JSON.parse(
|
||||
sessionStorage[sessionStorageKey]
|
||||
) as SessionStorageData;
|
||||
|
||||
const sessionID = await getSessionFor(sessionStorageData.accountID);
|
||||
|
||||
const node = await LocalNode.withLoadedAccount(
|
||||
sessionStorageData.accountID,
|
||||
sessionStorageData.accountSecret,
|
||||
sessionID,
|
||||
initialPeers
|
||||
);
|
||||
|
||||
this.driver.onSignedIn({ logOut });
|
||||
|
||||
return Promise.resolve(node);
|
||||
} else {
|
||||
const node = await new Promise<LocalNode>(
|
||||
(doneSigningUpOrLoggingIn) => {
|
||||
this.driver.onReady({
|
||||
signUp: async (username) => {
|
||||
const node = await signUp(
|
||||
username,
|
||||
getSessionFor,
|
||||
this.appName,
|
||||
this.appHostname
|
||||
);
|
||||
for (const peer of initialPeers) {
|
||||
node.sync.addPeer(peer);
|
||||
}
|
||||
doneSigningUpOrLoggingIn(node);
|
||||
this.driver.onSignedIn({ logOut });
|
||||
},
|
||||
logIn: async () => {
|
||||
const node = await logIn(
|
||||
getSessionFor,
|
||||
this.appHostname,
|
||||
initialPeers
|
||||
);
|
||||
doneSigningUpOrLoggingIn(node);
|
||||
this.driver.onSignedIn({ logOut });
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function signUp(
|
||||
username: string,
|
||||
getSessionFor: SessionProvider,
|
||||
appName: string,
|
||||
appHostname: string
|
||||
): Promise<LocalNode> {
|
||||
const secretSeed = cojsonInternals.newRandomSecretSeed();
|
||||
|
||||
const { node, accountID, accountSecret } =
|
||||
LocalNode.withNewlyCreatedAccount(
|
||||
username,
|
||||
agentSecretFromSecretSeed(secretSeed)
|
||||
);
|
||||
|
||||
const webAuthNCredentialPayload = new Uint8Array(
|
||||
cojsonInternals.secretSeedLength + cojsonInternals.shortHashLength
|
||||
);
|
||||
|
||||
webAuthNCredentialPayload.set(secretSeed);
|
||||
webAuthNCredentialPayload.set(
|
||||
cojsonInternals.rawCoIDtoBytes(accountID),
|
||||
cojsonInternals.secretSeedLength
|
||||
);
|
||||
|
||||
const webAuthNCredential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
challenge: Uint8Array.from([0, 1, 2]),
|
||||
rp: {
|
||||
name: appName,
|
||||
id: appHostname,
|
||||
},
|
||||
user: {
|
||||
id: webAuthNCredentialPayload,
|
||||
name: username + `(${new Date().toLocaleString()})`,
|
||||
displayName: username,
|
||||
},
|
||||
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: "platform",
|
||||
},
|
||||
timeout: 60000,
|
||||
attestation: "direct",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(webAuthNCredential, accountID);
|
||||
|
||||
sessionStorage[sessionStorageKey] = JSON.stringify({
|
||||
accountID,
|
||||
accountSecret,
|
||||
} satisfies SessionStorageData);
|
||||
|
||||
node.ownSessionID = await getSessionFor(accountID);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
async function logIn(
|
||||
getSessionFor: SessionProvider,
|
||||
appHostname: string,
|
||||
initialPeers: Peer[]
|
||||
): Promise<LocalNode> {
|
||||
const webAuthNCredential = (await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: Uint8Array.from([0, 1, 2]),
|
||||
rpId: appHostname,
|
||||
allowCredentials: [],
|
||||
timeout: 60000,
|
||||
},
|
||||
})) as unknown as {
|
||||
response: { userHandle: ArrayBuffer };
|
||||
};
|
||||
if (!webAuthNCredential) {
|
||||
throw new Error("Couldn't log in");
|
||||
}
|
||||
|
||||
const webAuthNCredentialPayload = new Uint8Array(
|
||||
webAuthNCredential.response.userHandle
|
||||
);
|
||||
const accountSecretSeed = webAuthNCredentialPayload.slice(
|
||||
0,
|
||||
cojsonInternals.secretSeedLength
|
||||
);
|
||||
|
||||
const accountID = cojsonInternals.rawCoIDfromBytes(
|
||||
webAuthNCredentialPayload.slice(
|
||||
cojsonInternals.secretSeedLength,
|
||||
cojsonInternals.secretSeedLength + cojsonInternals.shortHashLength
|
||||
)
|
||||
) as AccountID;
|
||||
|
||||
const accountSecret = agentSecretFromSecretSeed(accountSecretSeed);
|
||||
|
||||
if (!accountSecret) {
|
||||
throw new Error("Invalid credential");
|
||||
}
|
||||
|
||||
sessionStorage[sessionStorageKey] = JSON.stringify({
|
||||
accountID,
|
||||
accountSecret,
|
||||
} satisfies SessionStorageData);
|
||||
|
||||
const node = await LocalNode.withLoadedAccount(
|
||||
accountID,
|
||||
accountSecret,
|
||||
await getSessionFor(accountID),
|
||||
initialPeers
|
||||
);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function logOut() {
|
||||
delete sessionStorage[sessionStorageKey];
|
||||
}
|
||||
16
packages/jazz-browser-auth-local/tsconfig.json
Normal file
16
packages/jazz-browser-auth-local/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"jsx": "react",
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
}
|
||||
83
packages/jazz-browser-auth-local/yarn.lock
Normal file
83
packages/jazz-browser-auth-local/yarn.lock
Normal file
@@ -0,0 +1,83 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@noble/ciphers@^0.1.3":
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.1.4.tgz#96327dca147829ed9eee0d96cfdf7c57915765f0"
|
||||
integrity sha512-d3ZR8vGSpy3v/nllS+bD/OMN5UZqusWiQqkyj7AwzTnhXFH72pF5oB4Ach6DQ50g5kXxC28LdaYBEpsyv9KOUQ==
|
||||
|
||||
"@noble/curves@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d"
|
||||
integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==
|
||||
dependencies:
|
||||
"@noble/hashes" "1.3.1"
|
||||
|
||||
"@noble/hashes@1.3.1", "@noble/hashes@^1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9"
|
||||
integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==
|
||||
|
||||
"@scure/base@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"
|
||||
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==
|
||||
|
||||
"@types/prop-types@*":
|
||||
version "15.7.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
|
||||
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
|
||||
|
||||
"@types/react@^18.2.19":
|
||||
version "18.2.19"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.19.tgz#f77cb2c8307368e624d464a25b9675fa35f95a8b"
|
||||
integrity sha512-e2S8wmY1ePfM517PqCG80CcE48Xs5k0pwJzuDZsfE8IZRRBfOMCF+XqnFxu6mWtyivum1MQm4aco+WIt6Coimw==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
"@types/scheduler" "*"
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/scheduler@*":
|
||||
version "0.16.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"
|
||||
integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==
|
||||
|
||||
cojson@^0.0.14:
|
||||
version "0.0.14"
|
||||
resolved "https://registry.yarnpkg.com/cojson/-/cojson-0.0.14.tgz#e7b190ade1efc20d6f0fa12411d7208cdc0f19f7"
|
||||
integrity sha512-TFenIGswEEhnZlCmq+B1NZPztjovZ72AjK1YkkZca54ZFbB1lAHdPt2hqqu/QBO24C9+6DtuoS2ixm6gbSBWCg==
|
||||
dependencies:
|
||||
"@noble/ciphers" "^0.1.3"
|
||||
"@noble/curves" "^1.1.0"
|
||||
"@noble/hashes" "^1.3.1"
|
||||
"@scure/base" "^1.1.1"
|
||||
fast-json-stable-stringify "^2.1.0"
|
||||
isomorphic-streams "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
||||
|
||||
csstype@^3.0.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
|
||||
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
|
||||
|
||||
fast-json-stable-stringify@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
|
||||
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
||||
|
||||
"isomorphic-streams@git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae":
|
||||
version "1.0.3"
|
||||
resolved "git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
||||
|
||||
jazz-react@^0.0.6:
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/jazz-react/-/jazz-react-0.0.6.tgz#53b0245720b10ec31ac8deba45fd8a052b313b06"
|
||||
integrity sha512-JlYTKUVPpuK3T7cLfk2YwHh3yH+2BPVSuWIQui35U52/gce+HmTMGolqFYGghWMVQtwclaZ0IoEbtuycPiADOQ==
|
||||
dependencies:
|
||||
cojson "^0.0.14"
|
||||
typescript "^5.1.6"
|
||||
|
||||
typescript@^5.1.6:
|
||||
version "5.1.6"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274"
|
||||
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==
|
||||
17
packages/jazz-browser/.eslintrc.cjs
Normal file
17
packages/jazz-browser/.eslintrc.cjs
Normal file
@@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
};
|
||||
171
packages/jazz-browser/.gitignore
vendored
Normal file
171
packages/jazz-browser/.gitignore
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
.DS_Store
|
||||
2
packages/jazz-browser/.npmignore
Normal file
2
packages/jazz-browser/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
coverage
|
||||
node_modules
|
||||
17
packages/jazz-browser/package.json
Normal file
17
packages/jazz-browser/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "jazz-browser",
|
||||
"version": "0.0.6",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.0.23",
|
||||
"jazz-storage-indexeddb": "^0.0.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"
|
||||
}
|
||||
}
|
||||
314
packages/jazz-browser/src/index.ts
Normal file
314
packages/jazz-browser/src/index.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { InviteSecret } from "cojson";
|
||||
import {
|
||||
LocalNode,
|
||||
cojsonInternals,
|
||||
CojsonInternalTypes,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
Peer,
|
||||
ContentType,
|
||||
Team,
|
||||
CoID,
|
||||
} from "cojson";
|
||||
import { ReadableStream, WritableStream } from "isomorphic-streams";
|
||||
import { IDBStorage } from "jazz-storage-indexeddb";
|
||||
|
||||
export type BrowserNodeHandle = {
|
||||
node: LocalNode;
|
||||
// TODO: Symbol.dispose?
|
||||
done: () => void;
|
||||
};
|
||||
|
||||
export async function createBrowserNode({
|
||||
auth,
|
||||
syncAddress = "wss://sync.jazz.tools",
|
||||
reconnectionTimeout = 300,
|
||||
}: {
|
||||
auth: AuthProvider;
|
||||
syncAddress?: string;
|
||||
reconnectionTimeout?: number;
|
||||
}): Promise<BrowserNodeHandle> {
|
||||
let sessionDone: () => void;
|
||||
|
||||
const firstWsPeer = createWebSocketPeer(syncAddress);
|
||||
let shouldTryToReconnect = true;
|
||||
|
||||
const node = await auth.createNode(
|
||||
(accountID) => {
|
||||
const sessionHandle = getSessionHandleFor(accountID);
|
||||
sessionDone = sessionHandle.done;
|
||||
return sessionHandle.session;
|
||||
},
|
||||
[await IDBStorage.asPeer({ trace: true }), firstWsPeer]
|
||||
);
|
||||
|
||||
void async function websocketReconnectLoop() {
|
||||
while (shouldTryToReconnect) {
|
||||
if (
|
||||
Object.keys(node.sync.peers).some((peerId) =>
|
||||
peerId.includes(syncAddress)
|
||||
)
|
||||
) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, reconnectionTimeout)
|
||||
);
|
||||
} else {
|
||||
console.log("Websocket disconnected, trying to reconnect");
|
||||
node.sync.addPeer(createWebSocketPeer(syncAddress));
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, reconnectionTimeout)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
node,
|
||||
done: () => {
|
||||
shouldTryToReconnect = false;
|
||||
sessionDone?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthProvider {
|
||||
createNode(
|
||||
getSessionFor: SessionProvider,
|
||||
initialPeers: Peer[]
|
||||
): Promise<LocalNode>;
|
||||
}
|
||||
|
||||
export type SessionProvider = (
|
||||
accountID: CojsonInternalTypes.AccountIDOrAgentID
|
||||
) => Promise<SessionID>;
|
||||
|
||||
export type SessionHandle = {
|
||||
session: Promise<SessionID>;
|
||||
done: () => void;
|
||||
};
|
||||
|
||||
function getSessionHandleFor(
|
||||
accountID: CojsonInternalTypes.AccountIDOrAgentID
|
||||
): SessionHandle {
|
||||
let done!: () => void;
|
||||
const donePromise = new Promise<void>((resolve) => {
|
||||
done = resolve;
|
||||
});
|
||||
|
||||
let resolveSession: (sessionID: SessionID) => void;
|
||||
const sessionPromise = new Promise<SessionID>((resolve) => {
|
||||
resolveSession = resolve;
|
||||
});
|
||||
|
||||
void (async function () {
|
||||
for (let idx = 0; idx < 100; idx++) {
|
||||
// To work better around StrictMode
|
||||
for (let retry = 0; retry < 2; retry++) {
|
||||
console.debug("Trying to get lock", accountID + "_" + idx);
|
||||
const sessionFinishedOrNoLock = await navigator.locks.request(
|
||||
accountID + "_" + idx,
|
||||
{ ifAvailable: true },
|
||||
async (lock) => {
|
||||
if (!lock) return "noLock";
|
||||
|
||||
const sessionID =
|
||||
localStorage[accountID + "_" + idx] ||
|
||||
cojsonInternals.newRandomSessionID(accountID);
|
||||
localStorage[accountID + "_" + idx] = sessionID;
|
||||
|
||||
console.debug(
|
||||
"Got lock",
|
||||
accountID + "_" + idx,
|
||||
sessionID
|
||||
);
|
||||
|
||||
resolveSession(sessionID);
|
||||
|
||||
await donePromise;
|
||||
console.log(
|
||||
"Done with lock",
|
||||
accountID + "_" + idx,
|
||||
sessionID
|
||||
);
|
||||
return "sessionFinished";
|
||||
}
|
||||
);
|
||||
|
||||
if (sessionFinishedOrNoLock === "sessionFinished") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Couldn't get lock on session after 100x2 tries");
|
||||
})();
|
||||
|
||||
return {
|
||||
session: sessionPromise,
|
||||
done,
|
||||
};
|
||||
}
|
||||
|
||||
function websocketReadableStream<T>(ws: WebSocket) {
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
return new ReadableStream<T>({
|
||||
start(controller) {
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "ping") {
|
||||
console.debug(
|
||||
"Got ping from",
|
||||
msg.dc,
|
||||
"latency",
|
||||
Date.now() - msg.time,
|
||||
"ms"
|
||||
);
|
||||
return;
|
||||
}
|
||||
controller.enqueue(msg);
|
||||
};
|
||||
ws.onclose = () => controller.close();
|
||||
ws.onerror = () =>
|
||||
controller.error(new Error("The WebSocket errored!"));
|
||||
},
|
||||
|
||||
cancel() {
|
||||
ws.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createWebSocketPeer(syncAddress: string): Peer {
|
||||
const ws = new WebSocket(syncAddress);
|
||||
|
||||
const incoming = websocketReadableStream<SyncMessage>(ws);
|
||||
const outgoing = websocketWritableStream<SyncMessage>(ws);
|
||||
|
||||
return {
|
||||
id: syncAddress + "@" + new Date().toISOString(),
|
||||
incoming,
|
||||
outgoing,
|
||||
role: "server",
|
||||
};
|
||||
}
|
||||
|
||||
function websocketWritableStream<T>(ws: WebSocket) {
|
||||
return new WritableStream<T>({
|
||||
start(controller) {
|
||||
ws.onerror = () => {
|
||||
controller.error(new Error("The WebSocket errored!"));
|
||||
ws.onclose = null;
|
||||
};
|
||||
ws.onclose = () =>
|
||||
controller.error(
|
||||
new Error("The server closed the connection unexpectedly!")
|
||||
);
|
||||
return new Promise((resolve) => (ws.onopen = resolve));
|
||||
},
|
||||
|
||||
write(chunk) {
|
||||
ws.send(JSON.stringify(chunk));
|
||||
// Return immediately, since the web socket gives us no easy way to tell
|
||||
// when the write completes.
|
||||
},
|
||||
|
||||
close() {
|
||||
return closeWS(1000);
|
||||
},
|
||||
|
||||
abort(reason) {
|
||||
return closeWS(4000, reason && reason.message);
|
||||
},
|
||||
});
|
||||
|
||||
function closeWS(code: number, reasonString?: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
ws.onclose = (e) => {
|
||||
if (e.wasClean) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("The connection was not closed cleanly"));
|
||||
}
|
||||
};
|
||||
ws.close(code, reasonString);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createInviteLink(
|
||||
value: ContentType,
|
||||
role: "reader" | "writer" | "admin",
|
||||
// default to same address as window.location, but without hash
|
||||
{
|
||||
baseURL = window.location.href.replace(/#.*$/, ""),
|
||||
}: { baseURL?: string } = {}
|
||||
): string {
|
||||
const coValue = value.coValue;
|
||||
const node = coValue.node;
|
||||
let currentCoValue = coValue;
|
||||
|
||||
while (currentCoValue.header.ruleset.type === "ownedByTeam") {
|
||||
currentCoValue = node.expectCoValueLoaded(
|
||||
currentCoValue.header.ruleset.team
|
||||
);
|
||||
}
|
||||
|
||||
if (currentCoValue.header.ruleset.type !== "team") {
|
||||
throw new Error("Can't create invite link for object without team");
|
||||
}
|
||||
|
||||
const team = new Team(
|
||||
cojsonInternals.expectTeamContent(currentCoValue.getCurrentContent()),
|
||||
node
|
||||
);
|
||||
|
||||
const inviteSecret = team.createInvite(role);
|
||||
|
||||
return `${baseURL}#invitedTo=${value.id}&inviteSecret=${inviteSecret}`;
|
||||
}
|
||||
|
||||
export function parseInviteLink(inviteURL: string):
|
||||
| {
|
||||
valueID: CoID<ContentType>;
|
||||
inviteSecret: InviteSecret;
|
||||
}
|
||||
| undefined {
|
||||
const url = new URL(inviteURL);
|
||||
const valueID = url.hash
|
||||
.split("&")[0]
|
||||
?.replace(/^#invitedTo=/, "") as CoID<ContentType>;
|
||||
const inviteSecret = url.hash
|
||||
.split("&")[1]
|
||||
?.replace(/^inviteSecret=/, "") as InviteSecret;
|
||||
if (!valueID || !inviteSecret) {
|
||||
return undefined;
|
||||
}
|
||||
return { valueID, inviteSecret };
|
||||
}
|
||||
|
||||
export function consumeInviteLinkFromWindowLocation(node: LocalNode): Promise<
|
||||
| {
|
||||
valueID: string;
|
||||
inviteSecret: string;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const result = parseInviteLink(window.location.href);
|
||||
|
||||
if (result) {
|
||||
node.acceptInvite(result.valueID, result.inviteSecret)
|
||||
.then(() => {
|
||||
resolve(result);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
window.location.href.replace(/#.*$/, "")
|
||||
);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
16
packages/jazz-browser/tsconfig.json
Normal file
16
packages/jazz-browser/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
}
|
||||
75
packages/jazz-browser/yarn.lock
Normal file
75
packages/jazz-browser/yarn.lock
Normal file
@@ -0,0 +1,75 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@noble/ciphers@^0.1.3":
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.1.4.tgz#96327dca147829ed9eee0d96cfdf7c57915765f0"
|
||||
integrity sha512-d3ZR8vGSpy3v/nllS+bD/OMN5UZqusWiQqkyj7AwzTnhXFH72pF5oB4Ach6DQ50g5kXxC28LdaYBEpsyv9KOUQ==
|
||||
|
||||
"@noble/curves@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d"
|
||||
integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==
|
||||
dependencies:
|
||||
"@noble/hashes" "1.3.1"
|
||||
|
||||
"@noble/hashes@1.3.1", "@noble/hashes@^1.3.1":
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9"
|
||||
integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==
|
||||
|
||||
"@scure/base@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"
|
||||
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==
|
||||
|
||||
"@types/prop-types@*":
|
||||
version "15.7.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
|
||||
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
|
||||
|
||||
"@types/react@^18.2.19":
|
||||
version "18.2.19"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.19.tgz#f77cb2c8307368e624d464a25b9675fa35f95a8b"
|
||||
integrity sha512-e2S8wmY1ePfM517PqCG80CcE48Xs5k0pwJzuDZsfE8IZRRBfOMCF+XqnFxu6mWtyivum1MQm4aco+WIt6Coimw==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
"@types/scheduler" "*"
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@types/scheduler@*":
|
||||
version "0.16.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"
|
||||
integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==
|
||||
|
||||
cojson@^0.0.14:
|
||||
version "0.0.14"
|
||||
resolved "https://registry.yarnpkg.com/cojson/-/cojson-0.0.14.tgz#e7b190ade1efc20d6f0fa12411d7208cdc0f19f7"
|
||||
integrity sha512-TFenIGswEEhnZlCmq+B1NZPztjovZ72AjK1YkkZca54ZFbB1lAHdPt2hqqu/QBO24C9+6DtuoS2ixm6gbSBWCg==
|
||||
dependencies:
|
||||
"@noble/ciphers" "^0.1.3"
|
||||
"@noble/curves" "^1.1.0"
|
||||
"@noble/hashes" "^1.3.1"
|
||||
"@scure/base" "^1.1.1"
|
||||
fast-json-stable-stringify "^2.1.0"
|
||||
isomorphic-streams "https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
||||
|
||||
csstype@^3.0.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
|
||||
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
|
||||
|
||||
fast-json-stable-stringify@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
|
||||
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
||||
|
||||
"isomorphic-streams@git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae":
|
||||
version "1.0.3"
|
||||
resolved "git+https://github.com/sgwilym/isomorphic-streams.git#aa9394781bfc92f8d7c981be7daf8af4b4cd4fae"
|
||||
|
||||
typescript@^5.1.6:
|
||||
version "5.1.6"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274"
|
||||
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==
|
||||
@@ -1,18 +1,21 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:require-extensions/recommended",
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint", "require-extensions"],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
2
packages/jazz-react-auth-local/.npmignore
Normal file
2
packages/jazz-react-auth-local/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
coverage
|
||||
node_modules
|
||||
@@ -1,17 +1,23 @@
|
||||
{
|
||||
"name": "jazz-react-auth-local",
|
||||
"version": "0.0.5",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-react": "^0.0.8",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.19"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2"
|
||||
}
|
||||
"name": "jazz-react-auth-local",
|
||||
"version": "0.0.13",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser-auth-local": "^0.0.6",
|
||||
"jazz-react": "^0.0.16",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.19"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.tsx",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { newRandomAgentSecret, AgentSecret, agentSecretToBytes, agentSecretFromBytes} from "cojson/src/crypto";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
export function useLocalAuth(onCredential: (credentials: AgentSecret) => void) {
|
||||
const [displayName, setDisplayName] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionStorage.credential) {
|
||||
const credential = JSON.parse(sessionStorage.credential);
|
||||
onCredential(credential);
|
||||
}
|
||||
}, [onCredential]);
|
||||
|
||||
const signUp = useCallback(() => {
|
||||
(async function () {
|
||||
const credential = newRandomAgentSecret();
|
||||
|
||||
console.log(credential);
|
||||
|
||||
const webAuthNCredential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
challenge: Uint8Array.from([0, 1, 2]),
|
||||
rp: {
|
||||
name: "TodoApp",
|
||||
// TODO: something safer as default?
|
||||
id: window.location.hostname,
|
||||
},
|
||||
user: {
|
||||
id: agentSecretToBytes(credential),
|
||||
name: displayName,
|
||||
displayName: displayName,
|
||||
},
|
||||
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: "platform",
|
||||
},
|
||||
timeout: 60000,
|
||||
attestation: "direct",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
webAuthNCredential,
|
||||
credential,
|
||||
agentSecretToBytes(credential)
|
||||
);
|
||||
|
||||
sessionStorage.credential = JSON.stringify(credential);
|
||||
onCredential(credential);
|
||||
})();
|
||||
}, [displayName]);
|
||||
|
||||
const signIn = useCallback(() => {
|
||||
(async function () {
|
||||
const webAuthNCredential = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: Uint8Array.from([0, 1, 2]),
|
||||
// TODO: something safer as default?
|
||||
rpId: window.location.hostname,
|
||||
allowCredentials: [],
|
||||
timeout: 60000,
|
||||
},
|
||||
});
|
||||
|
||||
const userIdBytes = new Uint8Array(
|
||||
(webAuthNCredential as any).response.userHandle
|
||||
);
|
||||
const credential =
|
||||
agentSecretFromBytes(userIdBytes);
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Invalid credential");
|
||||
}
|
||||
|
||||
sessionStorage.credential = JSON.stringify(credential);
|
||||
onCredential(credential);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return { displayName, setDisplayName, signUp, signIn };
|
||||
}
|
||||
149
packages/jazz-react-auth-local/src/index.tsx
Normal file
149
packages/jazz-react-auth-local/src/index.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from "react";
|
||||
import { useMemo, useState, ReactNode } from "react";
|
||||
import { BrowserLocalAuth } from "jazz-browser-auth-local";
|
||||
import { ReactAuthHook } from "jazz-react";
|
||||
|
||||
export type LocalAuthComponent = (props: {
|
||||
loading: boolean;
|
||||
logIn: () => void;
|
||||
signUp: (username: string) => void;
|
||||
}) => ReactNode;
|
||||
|
||||
export function LocalAuth({
|
||||
appName,
|
||||
appHostname,
|
||||
Component = LocalAuthBasicUI,
|
||||
}: {
|
||||
appName: string;
|
||||
appHostname?: string;
|
||||
Component?: LocalAuthComponent;
|
||||
}): ReactAuthHook {
|
||||
return function useLocalAuth() {
|
||||
const [authState, setAuthState] = useState<
|
||||
| { state: "loading" }
|
||||
| {
|
||||
state: "ready";
|
||||
logIn: () => void;
|
||||
signUp: (username: string) => void;
|
||||
}
|
||||
| { state: "signedIn"; logOut: () => void }
|
||||
>({ state: "loading" });
|
||||
|
||||
const [logOutCounter, setLogOutCounter] = useState(0);
|
||||
|
||||
const auth = useMemo(() => {
|
||||
return new BrowserLocalAuth(
|
||||
{
|
||||
onReady(next) {
|
||||
setAuthState({
|
||||
state: "ready",
|
||||
logIn: next.logIn,
|
||||
signUp: next.signUp,
|
||||
});
|
||||
},
|
||||
onSignedIn(next) {
|
||||
setAuthState({
|
||||
state: "signedIn",
|
||||
logOut: () => {
|
||||
next.logOut();
|
||||
setAuthState({ state: "loading" });
|
||||
setLogOutCounter((c) => c + 1);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
appName,
|
||||
appHostname
|
||||
);
|
||||
}, [appName, appHostname, logOutCounter]);
|
||||
|
||||
const AuthUI =
|
||||
authState.state === "ready"
|
||||
? Component({
|
||||
loading: false,
|
||||
logIn: authState.logIn,
|
||||
signUp: authState.signUp,
|
||||
})
|
||||
: Component({
|
||||
loading: false,
|
||||
logIn: () => {},
|
||||
signUp: (_) => {},
|
||||
});
|
||||
|
||||
return {
|
||||
auth,
|
||||
AuthUI,
|
||||
logOut:
|
||||
authState.state === "signedIn" ? authState.logOut : undefined,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const LocalAuthBasicUI = ({
|
||||
logIn,
|
||||
signUp,
|
||||
}: {
|
||||
logIn: () => void;
|
||||
signUp: (username: string) => void;
|
||||
}) => {
|
||||
const [username, setUsername] = useState<string>("");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "18rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2rem",
|
||||
}}
|
||||
>
|
||||
<form
|
||||
style={{
|
||||
width: "18rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
signUp(username);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
placeholder="Display name"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="webauthn"
|
||||
style={{
|
||||
border: "1px solid #333",
|
||||
padding: "10px 5px",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
value="Sign Up as new account"
|
||||
style={{
|
||||
background: "#aaa",
|
||||
padding: "10px 5px",
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
<button
|
||||
onClick={logIn}
|
||||
style={{ background: "#aaa", padding: "10px 5px" }}
|
||||
>
|
||||
Log In with existing account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,18 +2,13 @@
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"jsx": "react",
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
root: true,
|
||||
rules: {
|
||||
@@ -14,5 +14,4 @@ module.exports = {
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
|
||||
};
|
||||
};
|
||||
2
packages/jazz-react/.npmignore
Normal file
2
packages/jazz-react/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
coverage
|
||||
node_modules
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "jazz-react",
|
||||
"version": "0.0.8",
|
||||
"main": "src/index.tsx",
|
||||
"types": "src/index.tsx",
|
||||
"version": "0.0.16",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.0.16",
|
||||
"cojson": "^0.0.23",
|
||||
"jazz-browser": "^0.0.6",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -13,5 +14,10 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src/**/*.tsx",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,180 +1,81 @@
|
||||
import {
|
||||
LocalNode,
|
||||
internals as cojsonInternals,
|
||||
SessionID,
|
||||
ContentType,
|
||||
SyncMessage,
|
||||
AgentSecret,
|
||||
CoID,
|
||||
ProfileContent,
|
||||
CoMap,
|
||||
AccountID,
|
||||
Profile,
|
||||
} from "cojson";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ReadableStream, WritableStream } from "isomorphic-streams";
|
||||
import { CoID } from "cojson";
|
||||
import { AgentID } from "cojson/src/ids";
|
||||
import { getAgentID } from "cojson/src/crypto";
|
||||
import { AnonymousControlledAccount } from "cojson/src/account";
|
||||
import { IDBStorage } from "jazz-storage-indexeddb";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AuthProvider, createBrowserNode } from "jazz-browser";
|
||||
|
||||
export {
|
||||
createInviteLink,
|
||||
parseInviteLink,
|
||||
consumeInviteLinkFromWindowLocation,
|
||||
} from "jazz-browser";
|
||||
|
||||
type JazzContext = {
|
||||
localNode: LocalNode;
|
||||
logOut: () => void;
|
||||
};
|
||||
|
||||
const JazzContext = React.createContext<JazzContext | undefined>(undefined);
|
||||
|
||||
export type AuthComponent = (props: {
|
||||
onCredential: (credentials: AgentSecret) => void;
|
||||
}) => React.ReactElement;
|
||||
export type ReactAuthHook = () => {
|
||||
auth: AuthProvider;
|
||||
AuthUI: React.ReactNode;
|
||||
logOut?: () => void;
|
||||
};
|
||||
|
||||
export function WithJazz({
|
||||
children,
|
||||
auth: Auth,
|
||||
syncAddress = "wss://sync.jazz.tools",
|
||||
auth: authHook,
|
||||
syncAddress,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
auth: AuthComponent;
|
||||
auth: ReactAuthHook;
|
||||
syncAddress?: string;
|
||||
}) {
|
||||
const [node, setNode] = useState<LocalNode | undefined>();
|
||||
const sessionDone = useRef<() => void>();
|
||||
|
||||
const onCredential = useCallback((credential: AgentSecret) => {
|
||||
const agentID = getAgentID(
|
||||
credential
|
||||
);
|
||||
const sessionHandle = getSessionFor(agentID);
|
||||
|
||||
sessionHandle.session.then((sessionID) =>
|
||||
setNode(new LocalNode(new AnonymousControlledAccount(credential), sessionID))
|
||||
);
|
||||
|
||||
sessionDone.current = sessionHandle.done;
|
||||
}, []);
|
||||
const { auth, AuthUI, logOut } = authHook();
|
||||
|
||||
useEffect(() => {
|
||||
let done: (() => void) | undefined = undefined;
|
||||
|
||||
(async () => {
|
||||
const nodeHandle = await createBrowserNode({
|
||||
auth: auth,
|
||||
syncAddress,
|
||||
});
|
||||
|
||||
setNode(nodeHandle.node);
|
||||
|
||||
done = nodeHandle.done;
|
||||
})().catch((e) => {
|
||||
console.log("Failed to create browser node", e);
|
||||
});
|
||||
|
||||
return () => {
|
||||
sessionDone.current && sessionDone.current();
|
||||
done && done();
|
||||
};
|
||||
}, []);
|
||||
}, [auth, syncAddress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (node) {
|
||||
IDBStorage.connectTo(node, {trace: true})
|
||||
|
||||
let shouldTryToReconnect = true;
|
||||
let ws: WebSocket | undefined;
|
||||
|
||||
(async function websocketReconnectLoop() {
|
||||
while (shouldTryToReconnect) {
|
||||
ws = new WebSocket(syncAddress);
|
||||
|
||||
const timeToReconnect = new Promise<void>((resolve) => {
|
||||
if (
|
||||
!ws ||
|
||||
ws.readyState === WebSocket.CLOSING ||
|
||||
ws.readyState === WebSocket.CLOSED
|
||||
)
|
||||
resolve();
|
||||
ws?.addEventListener(
|
||||
"close",
|
||||
() => {
|
||||
console.log(
|
||||
"Connection closed, reconnecting in 5s"
|
||||
);
|
||||
setTimeout(resolve, 5000);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
|
||||
const incoming = websocketReadableStream<SyncMessage>(ws);
|
||||
const outgoing = websocketWritableStream<SyncMessage>(ws);
|
||||
|
||||
node.sync.addPeer({
|
||||
id: syncAddress + "@" + new Date().toISOString(),
|
||||
incoming,
|
||||
outgoing,
|
||||
role: "server",
|
||||
});
|
||||
|
||||
await timeToReconnect;
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
shouldTryToReconnect = false;
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
}, [node, syncAddress]);
|
||||
|
||||
return node ? (
|
||||
<JazzContext.Provider value={{ localNode: node }}>
|
||||
<>{children}</>
|
||||
</JazzContext.Provider>
|
||||
) : (
|
||||
<Auth onCredential={onCredential} />
|
||||
return (
|
||||
<>
|
||||
{node && logOut ? (
|
||||
<JazzContext.Provider value={{ localNode: node, logOut }}>
|
||||
<>{children}</>
|
||||
</JazzContext.Provider>
|
||||
) : (
|
||||
AuthUI
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SessionHandle = {
|
||||
session: Promise<SessionID>;
|
||||
done: () => void;
|
||||
};
|
||||
|
||||
function getSessionFor(agentID: AgentID): SessionHandle {
|
||||
let done!: () => void;
|
||||
const donePromise = new Promise<void>((resolve) => {
|
||||
done = resolve;
|
||||
});
|
||||
|
||||
let resolveSession: (sessionID: SessionID) => void;
|
||||
const sessionPromise = new Promise<SessionID>((resolve) => {
|
||||
resolveSession = resolve;
|
||||
});
|
||||
|
||||
(async function () {
|
||||
for (let idx = 0; idx < 100; idx++) {
|
||||
// To work better around StrictMode
|
||||
for (let retry = 0; retry < 2; retry++) {
|
||||
console.log("Trying to get lock", agentID + "_" + idx);
|
||||
const sessionFinishedOrNoLock = await navigator.locks.request(
|
||||
agentID + "_" + idx,
|
||||
{ ifAvailable: true },
|
||||
async (lock) => {
|
||||
if (!lock) return "noLock";
|
||||
|
||||
const sessionID =
|
||||
localStorage[agentID + "_" + idx] ||
|
||||
cojsonInternals.newRandomSessionID(agentID);
|
||||
localStorage[agentID + "_" + idx] = sessionID;
|
||||
|
||||
console.log("Got lock", agentID + "_" + idx, sessionID);
|
||||
|
||||
resolveSession(sessionID);
|
||||
|
||||
await donePromise;
|
||||
console.log(
|
||||
"Done with lock",
|
||||
agentID + "_" + idx,
|
||||
sessionID
|
||||
);
|
||||
return "sessionFinished";
|
||||
}
|
||||
);
|
||||
|
||||
if (sessionFinishedOrNoLock === "sessionFinished") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("Couldn't get lock on session after 100x2 tries");
|
||||
})();
|
||||
|
||||
return {
|
||||
session: sessionPromise,
|
||||
done,
|
||||
};
|
||||
}
|
||||
|
||||
export function useJazz() {
|
||||
const context = React.useContext(JazzContext);
|
||||
|
||||
@@ -185,28 +86,34 @@ export function useJazz() {
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useTelepathicState<T extends ContentType>(id: CoID<T>) {
|
||||
export function useTelepathicState<T extends ContentType>(id?: CoID<T>) {
|
||||
const [state, setState] = useState<T>();
|
||||
|
||||
const { localNode } = useJazz();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let unsubscribe: (() => void) | undefined = undefined;
|
||||
|
||||
let done = false;
|
||||
|
||||
localNode.load(id).then((state) => {
|
||||
if (done) return;
|
||||
unsubscribe = state.subscribe((newState) => {
|
||||
console.log(
|
||||
"Got update",
|
||||
id,
|
||||
newState.toJSON(),
|
||||
newState.coValue.sessions
|
||||
);
|
||||
setState(newState as T);
|
||||
localNode
|
||||
.load(id)
|
||||
.then((state) => {
|
||||
if (done) return;
|
||||
unsubscribe = state.subscribe((newState) => {
|
||||
// console.log(
|
||||
// "Got update",
|
||||
// id,
|
||||
// newState.toJSON(),
|
||||
// newState.coValue.sessions
|
||||
// );
|
||||
setState(newState as T);
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("Failed to load", id, e);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
done = true;
|
||||
@@ -217,75 +124,22 @@ export function useTelepathicState<T extends ContentType>(id: CoID<T>) {
|
||||
return state;
|
||||
}
|
||||
|
||||
function websocketReadableStream<T>(ws: WebSocket) {
|
||||
ws.binaryType = "arraybuffer";
|
||||
export function useProfile<P extends ProfileContent = ProfileContent>({
|
||||
accountID,
|
||||
}: {
|
||||
accountID?: AccountID;
|
||||
}): (Profile & CoMap<P>) | undefined {
|
||||
const [profileID, setProfileID] = useState<CoID<Profile & CoMap<P>>>();
|
||||
|
||||
return new ReadableStream<T>({
|
||||
start(controller) {
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "ping") {
|
||||
console.debug(
|
||||
"Got ping from",
|
||||
msg.dc,
|
||||
"latency",
|
||||
Date.now() - msg.time,
|
||||
"ms"
|
||||
);
|
||||
return;
|
||||
}
|
||||
controller.enqueue(msg);
|
||||
};
|
||||
ws.onclose = () => controller.close();
|
||||
ws.onerror = () =>
|
||||
controller.error(new Error("The WebSocket errored!"));
|
||||
},
|
||||
const { localNode } = useJazz();
|
||||
|
||||
cancel() {
|
||||
ws.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function websocketWritableStream<T>(ws: WebSocket) {
|
||||
return new WritableStream<T>({
|
||||
start(controller) {
|
||||
ws.onerror = () => {
|
||||
controller.error(new Error("The WebSocket errored!"));
|
||||
ws.onclose = null;
|
||||
};
|
||||
ws.onclose = () =>
|
||||
controller.error(
|
||||
new Error("The server closed the connection unexpectedly!")
|
||||
);
|
||||
return new Promise((resolve) => (ws.onopen = resolve));
|
||||
},
|
||||
|
||||
write(chunk) {
|
||||
ws.send(JSON.stringify(chunk));
|
||||
// Return immediately, since the web socket gives us no easy way to tell
|
||||
// when the write completes.
|
||||
},
|
||||
|
||||
close() {
|
||||
return closeWS(1000);
|
||||
},
|
||||
|
||||
abort(reason) {
|
||||
return closeWS(4000, reason && reason.message);
|
||||
},
|
||||
});
|
||||
|
||||
function closeWS(code: number, reasonString?: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
ws.onclose = (e) => {
|
||||
if (e.wasClean) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("The connection was not closed cleanly"));
|
||||
}
|
||||
};
|
||||
ws.close(code, reasonString);
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
accountID &&
|
||||
localNode
|
||||
.loadProfile(accountID)
|
||||
.then((profile) => setProfileID(profile.id as typeof profileID))
|
||||
.catch((e) => console.log("Failed to load profile", e));
|
||||
}, [localNode, accountID]);
|
||||
|
||||
return useTelepathicState(profileID);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
|
||||
@@ -6,13 +6,12 @@ module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
// "@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
|
||||
};
|
||||
};
|
||||
2
packages/jazz-storage-indexeddb/.npmignore
Normal file
2
packages/jazz-storage-indexeddb/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
coverage
|
||||
node_modules
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-storage-indexeddb",
|
||||
"version": "0.0.3",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"version": "0.0.10",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.0.16",
|
||||
"cojson": "^0.0.23",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -14,6 +14,9 @@
|
||||
"webdriverio": "^8.15.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest --browser chrome"
|
||||
"test": "vitest --browser chrome",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
"build": "npm run lint && rm -rf ./dist && tsc --declaration --sourceMap --outDir dist",
|
||||
"prepublishOnly": "npm run build"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,66 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import { LocalNode, } from 'cojson';
|
||||
import { getAgentID, newRandomAgentSecret } from 'cojson/src/crypto';
|
||||
import { newRandomSessionID } from 'cojson/src/coValue';
|
||||
import { AnonymousControlledAccount } from 'cojson/src/account';
|
||||
import { IDBStorage } from '.';
|
||||
import { expect, test } from "vitest";
|
||||
import { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
|
||||
import { IDBStorage } from ".";
|
||||
|
||||
test("Should be able to initialize and load from empty DB", async () => {
|
||||
const agentSecret = newRandomAgentSecret();
|
||||
test.skip("Should be able to initialize and load from empty DB", async () => {
|
||||
const agentSecret = cojsonInternals.newRandomAgentSecret();
|
||||
|
||||
const node = new LocalNode(new AnonymousControlledAccount(agentSecret), newRandomSessionID(getAgentID(agentSecret)));
|
||||
const node = new LocalNode(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
cojsonInternals.newRandomSessionID(
|
||||
cojsonInternals.getAgentID(agentSecret)
|
||||
)
|
||||
);
|
||||
|
||||
await IDBStorage.connectTo(node, {trace: true});
|
||||
node.sync.addPeer(await IDBStorage.asPeer({ trace: true }));
|
||||
|
||||
console.log("yay!");
|
||||
|
||||
const team = node.createTeam();
|
||||
const _team = node.createTeam();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(node.sync.peers["storage"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("Should be able to sync data to database and then load that from a new node", async () => {
|
||||
const agentSecret = cojsonInternals.newRandomAgentSecret();
|
||||
|
||||
const node1 = new LocalNode(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
cojsonInternals.newRandomSessionID(
|
||||
cojsonInternals.getAgentID(agentSecret)
|
||||
)
|
||||
);
|
||||
|
||||
node1.sync.addPeer(
|
||||
await IDBStorage.asPeer({ trace: true, localNodeName: "node1" })
|
||||
);
|
||||
|
||||
console.log("yay!");
|
||||
|
||||
const team = node1.createTeam();
|
||||
|
||||
const map = team.createMap();
|
||||
|
||||
map.edit((m) => {
|
||||
m.set("hello", "world");
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const node2 = new LocalNode(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
cojsonInternals.newRandomSessionID(
|
||||
cojsonInternals.getAgentID(agentSecret)
|
||||
)
|
||||
);
|
||||
|
||||
node2.sync.addPeer(
|
||||
await IDBStorage.asPeer({ trace: true, localNodeName: "node2" })
|
||||
);
|
||||
|
||||
const map2 = await node2.load(map.id);
|
||||
|
||||
expect(map2.get("hello")).toBe("world");
|
||||
});
|
||||
|
||||
@@ -1,41 +1,31 @@
|
||||
import { cojsonInternals, SessionID, SyncMessage, Peer, CojsonInternalTypes } from "cojson";
|
||||
import {
|
||||
LocalNode,
|
||||
internals as cojsonInternals,
|
||||
SessionID,
|
||||
ContentType,
|
||||
SyncMessage,
|
||||
JsonValue,
|
||||
} from "cojson";
|
||||
import { CoValueHeader, Transaction } from "cojson/src/coValue";
|
||||
import { Signature } from "cojson/src/crypto";
|
||||
import { RawCoID } from "cojson/src/ids";
|
||||
import {
|
||||
CoValueKnownState,
|
||||
DoneMessage,
|
||||
KnownStateMessage,
|
||||
LoadMessage,
|
||||
NewContentMessage,
|
||||
} from "cojson/src/sync";
|
||||
import { ReadableStream, WritableStream, ReadableStreamDefaultReader, WritableStreamDefaultWriter } from "isomorphic-streams";
|
||||
ReadableStream,
|
||||
WritableStream,
|
||||
ReadableStreamDefaultReader,
|
||||
WritableStreamDefaultWriter,
|
||||
} from "isomorphic-streams";
|
||||
|
||||
type CoValueRow = {
|
||||
id: RawCoID;
|
||||
header: CoValueHeader;
|
||||
id: CojsonInternalTypes.RawCoID;
|
||||
header: CojsonInternalTypes.CoValueHeader;
|
||||
};
|
||||
|
||||
type StoredCoValueRow = CoValueRow & { rowID: number };
|
||||
|
||||
type SessionRow = {
|
||||
coValue: number;
|
||||
sessionID: SessionID;
|
||||
lastIdx: number;
|
||||
lastSignature: Signature;
|
||||
lastSignature: CojsonInternalTypes.Signature;
|
||||
};
|
||||
|
||||
type StoredSessionRow = SessionRow & { rowID: number };
|
||||
|
||||
type TransactionRow = {
|
||||
ses: SessionID;
|
||||
ses: number;
|
||||
idx: number;
|
||||
tx: Transaction;
|
||||
tx: CojsonInternalTypes.Transaction;
|
||||
};
|
||||
|
||||
export class IDBStorage {
|
||||
@@ -43,32 +33,54 @@ export class IDBStorage {
|
||||
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
|
||||
toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
|
||||
|
||||
constructor(db: IDBDatabase, fromLocalNode: ReadableStream<SyncMessage>, toLocalNode: WritableStream<SyncMessage>) {
|
||||
constructor(
|
||||
db: IDBDatabase,
|
||||
fromLocalNode: ReadableStream<SyncMessage>,
|
||||
toLocalNode: WritableStream<SyncMessage>
|
||||
) {
|
||||
this.db = db;
|
||||
this.fromLocalNode = fromLocalNode.getReader();
|
||||
this.toLocalNode = toLocalNode.getWriter();
|
||||
|
||||
(async () => {
|
||||
while (true) {
|
||||
const { done, value } = await this.fromLocalNode.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const result = await this.fromLocalNode.read();
|
||||
done = result.done;
|
||||
|
||||
this.handleSyncMessage(value);
|
||||
if (result.value) {
|
||||
this.handleSyncMessage(result.value);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
static async connectTo(localNode: LocalNode, {trace}: {trace?: boolean} = {}) {
|
||||
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers("local", "storage", {peer1role: "client", peer2role: "server", trace});
|
||||
static async asPeer(
|
||||
{
|
||||
trace,
|
||||
localNodeName = "local",
|
||||
}: { trace?: boolean; localNodeName?: string } | undefined = {
|
||||
localNodeName: "local",
|
||||
}
|
||||
): Promise<Peer> {
|
||||
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
|
||||
localNodeName,
|
||||
"storage",
|
||||
{ peer1role: "client", peer2role: "server", trace }
|
||||
);
|
||||
|
||||
await IDBStorage.open(localNodeAsPeer.incoming, localNodeAsPeer.outgoing);
|
||||
await IDBStorage.open(
|
||||
localNodeAsPeer.incoming,
|
||||
localNodeAsPeer.outgoing
|
||||
);
|
||||
|
||||
localNode.sync.addPeer(storageAsPeer);
|
||||
return storageAsPeer;
|
||||
}
|
||||
|
||||
static async open(fromLocalNode: ReadableStream<SyncMessage>, toLocalNode: WritableStream<SyncMessage>) {
|
||||
static async open(
|
||||
fromLocalNode: ReadableStream<SyncMessage>,
|
||||
toLocalNode: WritableStream<SyncMessage>
|
||||
) {
|
||||
const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open("jazz-storage", 1);
|
||||
request.onerror = () => {
|
||||
@@ -85,17 +97,23 @@ export class IDBStorage {
|
||||
keyPath: "rowID",
|
||||
});
|
||||
|
||||
coValues.createIndex("id", "id", {
|
||||
coValues.createIndex("coValuesById", "id", {
|
||||
unique: true,
|
||||
});
|
||||
|
||||
const sessions = db.createObjectStore("sessions", {
|
||||
keyPath: ["coValue", "sessionID"],
|
||||
autoIncrement: true,
|
||||
keyPath: "rowID",
|
||||
});
|
||||
|
||||
sessions.createIndex("sessionID", "sessionID", {
|
||||
unique: true,
|
||||
});
|
||||
sessions.createIndex("sessionsByCoValue", "coValue");
|
||||
sessions.createIndex(
|
||||
"uniqueSessions",
|
||||
["coValue", "sessionID"],
|
||||
{
|
||||
unique: true,
|
||||
}
|
||||
);
|
||||
|
||||
db.createObjectStore("transactions", {
|
||||
keyPath: ["ses", "idx"],
|
||||
@@ -124,153 +142,277 @@ export class IDBStorage {
|
||||
}
|
||||
|
||||
async sendNewContentAfter(
|
||||
theirKnown: CoValueKnownState
|
||||
theirKnown: CojsonInternalTypes.CoValueKnownState,
|
||||
{
|
||||
coValues,
|
||||
sessions,
|
||||
transactions,
|
||||
}: {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
},
|
||||
asDependencyOf?: CojsonInternalTypes.RawCoID
|
||||
) {
|
||||
const tx = this.db.transaction(
|
||||
["coValues", "sessions", "transactions"],
|
||||
"readonly"
|
||||
);
|
||||
|
||||
tx.onerror = () => {
|
||||
throw(new Error("Error in transaction", { cause: tx.error }));
|
||||
};
|
||||
|
||||
const coValues = tx.objectStore("coValues");
|
||||
const sessions = tx.objectStore("sessions");
|
||||
const transactions = tx.objectStore("transactions");
|
||||
|
||||
const header = await new Promise<CoValueHeader | undefined>(
|
||||
(resolve) => {
|
||||
if (theirKnown.header) {
|
||||
resolve(undefined);
|
||||
} else {
|
||||
const headerRequest = coValues.get(theirKnown.id);
|
||||
|
||||
headerRequest.onsuccess = () => {
|
||||
resolve(
|
||||
(headerRequest.result as CoValueRow).header
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const allOurSessions = await new Promise<StoredSessionRow[]>(
|
||||
(resolve) => {
|
||||
const allOurSessionsRequest = sessions
|
||||
.index("sessionID")
|
||||
.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[theirKnown.id, "\u0000"],
|
||||
[theirKnown.id, "\uffff"]
|
||||
)
|
||||
);
|
||||
|
||||
allOurSessionsRequest.onsuccess = () => {
|
||||
resolve(
|
||||
allOurSessionsRequest.result as StoredSessionRow[]
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const ourKnown: CoValueKnownState = {
|
||||
id: theirKnown.id,
|
||||
header: !!header,
|
||||
sessions: {},
|
||||
};
|
||||
let shouldTellOurKnown = (
|
||||
Object.keys(theirKnown.sessions) as SessionID[]
|
||||
).some((sessionID) => {
|
||||
return !allOurSessions.some(
|
||||
(row) => row.sessionID === sessionID
|
||||
);
|
||||
});
|
||||
|
||||
const newContent: NewContentMessage = {
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
header,
|
||||
new: {},
|
||||
};
|
||||
|
||||
for (const sessionRow of allOurSessions) {
|
||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||
|
||||
if (
|
||||
sessionRow.lastIdx <
|
||||
(theirKnown.sessions[sessionRow.sessionID] || 0)
|
||||
) {
|
||||
shouldTellOurKnown = true;
|
||||
} else if (
|
||||
sessionRow.lastIdx >
|
||||
(theirKnown.sessions[sessionRow.sessionID] || 0)
|
||||
) {
|
||||
const firstNewTxIdx =
|
||||
theirKnown.sessions[sessionRow.sessionID] || 0;
|
||||
|
||||
const newTxInSession = await new Promise<TransactionRow[]>(
|
||||
(resolve) => {
|
||||
const newTxRequest = transactions.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRow.rowID, firstNewTxIdx],
|
||||
[sessionRow.rowID, Infinity]
|
||||
)
|
||||
);
|
||||
|
||||
newTxRequest.onsuccess = () => {
|
||||
resolve(
|
||||
newTxRequest.result as TransactionRow[]
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
newContent.new[sessionRow.sessionID] = {
|
||||
after: firstNewTxIdx,
|
||||
lastSignature: sessionRow.lastSignature,
|
||||
newTransactions: newTxInSession.map((row) => row.tx),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldTellOurKnown) {
|
||||
await this.toLocalNode.write({
|
||||
action: "known",
|
||||
...ourKnown,
|
||||
});
|
||||
}
|
||||
|
||||
if (newContent.header || Object.keys(newContent.new).length > 0) {
|
||||
await this.toLocalNode.write(newContent);
|
||||
}
|
||||
}
|
||||
|
||||
handleLoad(msg: LoadMessage) {
|
||||
return this.sendNewContentAfter(msg);
|
||||
}
|
||||
|
||||
handleContent(msg: NewContentMessage) {
|
||||
const tx = this.db.transaction(
|
||||
["coValues", "sessions", "transactions"],
|
||||
"readwrite"
|
||||
const coValueRow = await promised<StoredCoValueRow | undefined>(
|
||||
coValues.index("coValuesById").get(theirKnown.id)
|
||||
);
|
||||
|
||||
tx.onerror = () => {
|
||||
throw(new Error("Error in transaction", { cause: tx.error }));
|
||||
const allOurSessions = coValueRow
|
||||
? await promised<StoredSessionRow[]>(
|
||||
sessions.index("sessionsByCoValue").getAll(coValueRow.rowID)
|
||||
)
|
||||
: [];
|
||||
|
||||
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
|
||||
id: theirKnown.id,
|
||||
header: !!coValueRow,
|
||||
sessions: {},
|
||||
};
|
||||
|
||||
const newContent: CojsonInternalTypes.NewContentMessage = {
|
||||
action: "content",
|
||||
id: theirKnown.id,
|
||||
header: theirKnown.header ? undefined : coValueRow?.header,
|
||||
new: {},
|
||||
};
|
||||
|
||||
for (const sessionRow of allOurSessions) {
|
||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||
|
||||
if (
|
||||
sessionRow.lastIdx >
|
||||
(theirKnown.sessions[sessionRow.sessionID] || 0)
|
||||
) {
|
||||
const firstNewTxIdx =
|
||||
theirKnown.sessions[sessionRow.sessionID] || 0;
|
||||
|
||||
const newTxInSession = await promised<TransactionRow[]>(
|
||||
transactions.getAll(
|
||||
IDBKeyRange.bound(
|
||||
[sessionRow.rowID, firstNewTxIdx],
|
||||
[sessionRow.rowID, Infinity]
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
newContent.new[sessionRow.sessionID] = {
|
||||
after: firstNewTxIdx,
|
||||
lastSignature: sessionRow.lastSignature,
|
||||
newTransactions: newTxInSession.map((row) => row.tx),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const dependedOnCoValues =
|
||||
coValueRow?.header.ruleset.type === "team"
|
||||
? Object.values(newContent.new).flatMap((sessionEntry) =>
|
||||
sessionEntry.newTransactions.flatMap((tx) => {
|
||||
if (tx.privacy !== "trusting") return [];
|
||||
return tx.changes
|
||||
.map(
|
||||
(change) =>
|
||||
change &&
|
||||
typeof change === "object" &&
|
||||
"op" in change &&
|
||||
change.op === "set" &&
|
||||
"key" in change &&
|
||||
change.key
|
||||
)
|
||||
.filter(
|
||||
(key): key is CojsonInternalTypes.RawCoID =>
|
||||
typeof key === "string" &&
|
||||
key.startsWith("co_")
|
||||
);
|
||||
})
|
||||
)
|
||||
: coValueRow?.header.ruleset.type === "ownedByTeam"
|
||||
? [coValueRow?.header.ruleset.team]
|
||||
: [];
|
||||
|
||||
for (const dependedOnCoValue of dependedOnCoValues) {
|
||||
await this.sendNewContentAfter(
|
||||
{ id: dependedOnCoValue, header: false, sessions: {} },
|
||||
{ coValues, sessions, transactions },
|
||||
asDependencyOf || theirKnown.id
|
||||
);
|
||||
}
|
||||
|
||||
await this.toLocalNode.write({
|
||||
action: "known",
|
||||
...ourKnown,
|
||||
asDependencyOf,
|
||||
});
|
||||
|
||||
if (newContent.header || Object.keys(newContent.new).length > 0) {
|
||||
await this.toLocalNode.write(newContent);
|
||||
}
|
||||
}
|
||||
|
||||
handleLoad(msg: CojsonInternalTypes.LoadMessage) {
|
||||
return this.sendNewContentAfter(msg, this.inTransaction("readonly"));
|
||||
}
|
||||
|
||||
async handleContent(msg: CojsonInternalTypes.NewContentMessage) {
|
||||
const { coValues, sessions, transactions } =
|
||||
this.inTransaction("readwrite");
|
||||
|
||||
let storedCoValueRowID = (
|
||||
await promised<StoredCoValueRow | undefined>(
|
||||
coValues.index("coValuesById").get(msg.id)
|
||||
)
|
||||
)?.rowID;
|
||||
|
||||
if (storedCoValueRowID === undefined) {
|
||||
const header = msg.header;
|
||||
if (!header) {
|
||||
console.error("Expected to be sent header first");
|
||||
await this.toLocalNode.write({
|
||||
action: "known",
|
||||
id: msg.id,
|
||||
header: false,
|
||||
sessions: {},
|
||||
isCorrection: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
storedCoValueRowID = (await promised<IDBValidKey>(
|
||||
coValues.put({
|
||||
id: msg.id,
|
||||
header: header,
|
||||
} satisfies CoValueRow)
|
||||
)) as number;
|
||||
}
|
||||
|
||||
const allOurSessions = await new Promise<{
|
||||
[sessionID: SessionID]: StoredSessionRow;
|
||||
}>((resolve) => {
|
||||
const allOurSessionsRequest = sessions
|
||||
.index("sessionsByCoValue")
|
||||
.getAll(storedCoValueRowID);
|
||||
|
||||
allOurSessionsRequest.onsuccess = () => {
|
||||
resolve(
|
||||
Object.fromEntries(
|
||||
(
|
||||
allOurSessionsRequest.result as StoredSessionRow[]
|
||||
).map((row) => [row.sessionID, row])
|
||||
)
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
|
||||
id: msg.id,
|
||||
header: true,
|
||||
sessions: {},
|
||||
};
|
||||
let invalidAssumptions = false;
|
||||
|
||||
for (const sessionID of Object.keys(msg.new) as SessionID[]) {
|
||||
const sessionRow = allOurSessions[sessionID];
|
||||
if (sessionRow) {
|
||||
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
|
||||
}
|
||||
|
||||
if ((sessionRow?.lastIdx || 0) < (msg.new[sessionID]?.after || 0)) {
|
||||
invalidAssumptions = true;
|
||||
} else {
|
||||
const newTransactions =
|
||||
msg.new[sessionID]?.newTransactions || [];
|
||||
|
||||
const actuallyNewOffset =
|
||||
(sessionRow?.lastIdx || 0) -
|
||||
(msg.new[sessionID]?.after || 0);
|
||||
const actuallyNewTransactions =
|
||||
newTransactions.slice(actuallyNewOffset);
|
||||
|
||||
let nextIdx = sessionRow?.lastIdx || 0;
|
||||
|
||||
const sessionUpdate = {
|
||||
coValue: storedCoValueRowID,
|
||||
sessionID: sessionID,
|
||||
lastIdx:
|
||||
(sessionRow?.lastIdx || 0) +
|
||||
actuallyNewTransactions.length,
|
||||
lastSignature: msg.new[sessionID]!.lastSignature,
|
||||
};
|
||||
|
||||
const sessionRowID = (await promised(
|
||||
sessions.put(
|
||||
sessionRow?.rowID
|
||||
? {
|
||||
rowID: sessionRow.rowID,
|
||||
...sessionUpdate,
|
||||
}
|
||||
: sessionUpdate
|
||||
)
|
||||
)) as number;
|
||||
|
||||
for (const newTransaction of actuallyNewTransactions) {
|
||||
nextIdx++;
|
||||
await promised(
|
||||
transactions.add({
|
||||
ses: sessionRowID,
|
||||
idx: nextIdx,
|
||||
tx: newTransaction,
|
||||
} satisfies TransactionRow)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidAssumptions) {
|
||||
await this.toLocalNode.write({
|
||||
action: "known",
|
||||
...ourKnown,
|
||||
isCorrection: invalidAssumptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleKnown(msg: CojsonInternalTypes.KnownStateMessage) {
|
||||
return this.sendNewContentAfter(msg, this.inTransaction("readonly"));
|
||||
}
|
||||
|
||||
handleDone(_msg: CojsonInternalTypes.DoneMessage) {}
|
||||
|
||||
inTransaction(mode: "readwrite" | "readonly"): {
|
||||
coValues: IDBObjectStore;
|
||||
sessions: IDBObjectStore;
|
||||
transactions: IDBObjectStore;
|
||||
} {
|
||||
const tx = this.db.transaction(
|
||||
["coValues", "sessions", "transactions"],
|
||||
mode
|
||||
);
|
||||
|
||||
tx.onerror = (event) => {
|
||||
const target = event.target as unknown as {
|
||||
error: DOMException;
|
||||
source?: { name: string };
|
||||
} | null;
|
||||
throw new Error(
|
||||
`Error in transaction (${target?.source?.name}): ${target?.error}`,
|
||||
{ cause: target?.error }
|
||||
);
|
||||
};
|
||||
const coValues = tx.objectStore("coValues");
|
||||
const sessions = tx.objectStore("sessions");
|
||||
const transactions = tx.objectStore("transactions");
|
||||
|
||||
|
||||
return { coValues, sessions, transactions };
|
||||
}
|
||||
|
||||
handleKnown(msg: KnownStateMessage) {
|
||||
return this.sendNewContentAfter(msg);
|
||||
}
|
||||
|
||||
handleDone(msg: DoneMessage) {}
|
||||
}
|
||||
|
||||
function promised<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
|
||||
66
yarn.lock
66
yarn.lock
@@ -1175,6 +1175,17 @@
|
||||
"@radix-ui/react-use-previous" "1.0.1"
|
||||
"@radix-ui/react-use-size" "1.0.1"
|
||||
|
||||
"@radix-ui/react-collection@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"
|
||||
integrity sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-context" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-slot" "1.0.2"
|
||||
|
||||
"@radix-ui/react-compose-refs@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
|
||||
@@ -1189,6 +1200,26 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-dismissable-layer@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz#883a48f5f938fa679427aa17fcba70c5494c6978"
|
||||
integrity sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "1.0.1"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
"@radix-ui/react-use-escape-keydown" "1.0.3"
|
||||
|
||||
"@radix-ui/react-portal@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.3.tgz#ffb961244c8ed1b46f039e6c215a6c4d9989bda1"
|
||||
integrity sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
|
||||
"@radix-ui/react-presence@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba"
|
||||
@@ -1214,6 +1245,25 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
|
||||
"@radix-ui/react-toast@^1.1.4":
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.1.4.tgz#9a7fc2d71700886f3292f7699c905f1e01be59e1"
|
||||
integrity sha512-wf+fc8DOywrpRK3jlPlWVe+ELYGHdKDaaARJZNuUTWyWYq7+ANCFLp4rTjZ/mcGkJJQ/vZ949Zis9xxEpfq9OA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "1.0.1"
|
||||
"@radix-ui/react-collection" "1.0.3"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-context" "1.0.1"
|
||||
"@radix-ui/react-dismissable-layer" "1.0.4"
|
||||
"@radix-ui/react-portal" "1.0.3"
|
||||
"@radix-ui/react-presence" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||
"@radix-ui/react-use-layout-effect" "1.0.1"
|
||||
"@radix-ui/react-visually-hidden" "1.0.3"
|
||||
|
||||
"@radix-ui/react-use-callback-ref@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
|
||||
@@ -1229,6 +1279,14 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
|
||||
"@radix-ui/react-use-escape-keydown@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755"
|
||||
integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
|
||||
"@radix-ui/react-use-layout-effect@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399"
|
||||
@@ -1251,6 +1309,14 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-layout-effect" "1.0.1"
|
||||
|
||||
"@radix-ui/react-visually-hidden@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz#51aed9dd0fe5abcad7dee2a234ad36106a6984ac"
|
||||
integrity sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
|
||||
"@rollup/pluginutils@^3.1.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
|
||||
|
||||
Reference in New Issue
Block a user