Compare commits

...

26 Commits

Author SHA1 Message Date
Anselm
6557532743 Publish
- jazz-example-todo@0.0.10
 - cojson@0.0.23
 - jazz-browser@0.0.6
 - jazz-browser-auth-local@0.0.6
 - jazz-react@0.0.16
 - jazz-react-auth-local@0.0.13
 - jazz-storage-indexeddb@0.0.10
2023-08-19 14:54:51 +01:00
Anselm
ea6835664b Lots of fixes related to first-time invite race conditions 2023-08-19 14:54:22 +01:00
Anselm Eickhoff
a00332f4af Merge pull request #39 from gardencmp/anselm-gar-78
Implement Invitations
2023-08-19 12:46:10 +01:00
Anselm
6a6fb2eb3c Publish
- jazz-example-todo@0.0.9
 - cojson@0.0.22
 - jazz-browser@0.0.5
 - jazz-browser-auth-local@0.0.5
 - jazz-react@0.0.15
 - jazz-react-auth-local@0.0.12
 - jazz-storage-indexeddb@0.0.9
2023-08-19 12:24:08 +01:00
Anselm
0437223d50 Invitation flow improvements 2023-08-19 12:23:31 +01:00
Anselm
30c7e1bf6d Replace "insert" op with "set" 2023-08-19 12:23:17 +01:00
Anselm
aaa9d876d5 Implement high-level interface for invites 2023-08-18 18:13:20 +01:00
Anselm
264009a1a9 Implement underlying permissions for invites 2023-08-18 16:32:39 +01:00
Anselm Eickhoff
f2cb5d1b59 Merge pull request #38 from gardencmp/anselm-gar-108
Create proper accounts in local auth
2023-08-17 22:49:53 +01:00
Anselm
8610db2d8e more style fixes 2023-08-17 22:46:08 +01:00
Anselm
c672a03338 Large text in input 2023-08-17 22:44:43 +01:00
Anselm
cb60088d2a Publish
- jazz-example-todo@0.0.8
 - cojson@0.0.21
 - jazz-browser@0.0.4
 - jazz-browser-auth-local@0.0.4
 - jazz-react@0.0.14
 - jazz-react-auth-local@0.0.11
 - jazz-storage-indexeddb@0.0.8
2023-08-17 22:24:01 +01:00
Anselm
a59d5d3b70 Fix max call stack size tsc errors 2023-08-17 22:23:47 +01:00
Anselm
dcdf829a05 Publish
- jazz-example-todo@0.0.7
 - cojson@0.0.20
 - jazz-browser@0.0.3
 - jazz-browser-auth-local@0.0.3
 - jazz-react@0.0.13
 - jazz-react-auth-local@0.0.10
 - jazz-storage-indexeddb@0.0.7
2023-08-17 22:04:08 +01:00
Anselm
a907321d02 Fix main and types fields 2023-08-17 22:03:43 +01:00
Anselm
a2b9be9dd0 Lint 2023-08-17 21:52:58 +01:00
Anselm
432d114438 Lint 2023-08-17 21:51:44 +01:00
Anselm
17a2e2337a Publish
- jazz-example-todo@0.0.6
 - cojson@0.0.19
 - jazz-browser@0.0.2
 - jazz-browser-auth-local@0.0.2
 - jazz-react@0.0.12
 - jazz-react-auth-local@0.0.9
 - jazz-storage-indexeddb@0.0.6
2023-08-17 21:47:42 +01:00
Anselm
442e6d58cb Various small improvements 2023-08-17 21:47:01 +01:00
Anselm
2b26771bc0 Use proper accounts in local auth and separate out JS libs 2023-08-17 21:28:53 +01:00
Anselm Eickhoff
0851d21674 Deploy todo-example (#36) 2023-08-17 15:40:52 +01:00
Anselm Eickhoff
2c79dbb335 Merge pull request #33 from gardencmp:anselm/gar-70-simple-indexeddb-storage-peer
Simple IndexedDB storage peer
2023-08-16 16:55:06 +01:00
Anselm
5966170c38 Publish
- jazz-example-todo@0.0.3
 - cojson@0.0.17
 - jazz-react@0.0.9
 - jazz-react-auth-local@0.0.6
 - jazz-storage-indexeddb@0.0.4
2023-08-16 16:20:29 +01:00
Anselm
798a88d1fc Use IndexedDB in jazz-react & todo example 2023-08-16 16:20:00 +01:00
Anselm
60a9961ff2 Ensure IndexedDB loads depended on values 2023-08-16 16:14:07 +01:00
Anselm
7964e5d382 First successful store & load to IndexedDB 2023-08-16 15:48:19 +01:00
70 changed files with 3917 additions and 2879 deletions

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

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

4
examples/todo/Dockerfile Normal file
View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-todo",
"private": true,
"version": "0.0.2",
"version": "0.0.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",

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,12 +3,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>
);

View File

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

View File

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

File diff suppressed because it is too large Load Diff

6
jest.config.js Normal file
View File

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

View File

@@ -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",

View File

@@ -14,7 +14,7 @@ import {
} from "./crypto.js";
import { AgentID } from "./ids.js";
import { CoMap, LocalNode } from "./index.js";
import { Team, TeamContent } from "./permissions.js";
import { Team, TeamContent } from "./team.js";
export function accountHeaderForInitialAgentSecret(
agentSecret: AgentSecret

View File

@@ -2,6 +2,9 @@ import { Transaction } from "./coValue.js";
import { LocalNode } from "./node.js";
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
import { AccountID } from "./index.js";
import { Role } from "./permissions.js";
test("Can create coValue with new agent credentials and add transaction to it", () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID();
@@ -121,3 +124,58 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
)
).toBe(false);
});
test("New transactions in a 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);
});

View File

@@ -24,11 +24,10 @@ import { JsonObject, JsonValue } from "./jsonValue.js";
import { base58 } from "@scure/base";
import {
PermissionsDef as RulesetDef,
Team,
determineValidTransactions,
expectTeamContent,
isKeyForKeyField,
} from "./permissions.js";
import { 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]
: [];
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,60 @@
import { CoValue, newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { CoMap } from "./contentTypes/coMap.js";
import { agentSecretFromBytes, agentSecretToBytes } from "./crypto.js";
import {
agentSecretFromBytes,
agentSecretToBytes,
getAgentID,
newRandomAgentSecret,
newRandomSecretSeed,
agentSecretFromSecretSeed,
secretSeedLength,
shortHashLength,
} from "./crypto.js";
import { connectedPeers } from "./streamUtils.js";
import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
import { 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;
}

View File

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

View File

@@ -1,5 +1,6 @@
import {
AgentSecret,
agentSecretFromSecretSeed,
createdNowUnique,
getAgentID,
getAgentSealerID,
@@ -9,7 +10,13 @@ import {
seal,
} from "./crypto.js";
import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js";
import { Team, TeamContent, expectTeamContent } from "./permissions.js";
import {
InviteSecret,
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;
}

View File

@@ -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();
});
});

View File

@@ -1,16 +1,8 @@
import { CoID, ContentType } from "./contentType.js";
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import { CoID } from "./contentType.js";
import { MapOpPayload } from "./contentTypes/coMap.js";
import { JsonValue } from "./jsonValue.js";
import {
Encrypted,
KeyID,
KeySecret,
createdNowUnique,
newRandomKeySecret,
seal,
encryptKeySecret,
getAgentSealerID,
Sealed,
} from "./crypto.js";
import {
CoValue,
@@ -18,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"))
);
}

View File

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

View File

@@ -3,7 +3,7 @@ import { LocalNode } from "./node.js";
import { Peer, PeerID, SyncMessage } from "./sync.js";
import { expectMap } from "./contentType.js";
import { MapOpPayload } from "./contentTypes/coMap.js";
import { Team } from "./permissions.js";
import { 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>,

View File

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

233
packages/cojson/src/team.ts Normal file
View File

@@ -0,0 +1,233 @@
import { CoID, ContentType } from "./contentType.js";
import { CoMap } from "./contentTypes/coMap.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import {
Encrypted,
KeyID,
KeySecret,
createdNowUnique,
newRandomKeySecret,
seal,
encryptKeySecret,
getAgentSealerID,
Sealed,
newRandomSecretSeed,
agentSecretFromSecretSeed,
getAgentID,
} from "./crypto.js";
import { LocalNode } from "./node.js";
import { SessionID, isAgentID } from "./ids.js";
import {
AccountIDOrAgentID,
GeneralizedControlledAccount,
Profile,
} from "./account.js";
import { Role } from "./permissions.js";
import { base58 } from "@scure/base";
export type 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));
}

View File

@@ -1,7 +1,7 @@
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "./crypto.js";
import { newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { expectTeamContent } from "./permissions.js";
import { expectTeamContent } from "./team.js";
import { AnonymousControlledAccount } from "./account.js";
import { SessionID } from "./ids.js";

View File

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

View File

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

View File

@@ -0,0 +1,171 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
.DS_Store

View File

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

View File

@@ -0,0 +1,16 @@
{
"name": "jazz-browser-auth-local",
"version": "0.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"
}
}

View File

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

View File

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

View File

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

View File

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

171
packages/jazz-browser/.gitignore vendored Normal file
View File

@@ -0,0 +1,171 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
.DS_Store

View File

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

View File

@@ -0,0 +1,17 @@
{
"name": "jazz-browser",
"version": "0.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"
}
}

View 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);
}
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,23 @@
{
"name": "jazz-react-auth-local",
"version": "0.0.5",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"jazz-react": "^0.0.8",
"typescript": "^5.1.6"
},
"devDependencies": {
"@types/react": "^18.2.19"
},
"peerDependencies": {
"react": "^17.0.2"
}
"name": "jazz-react-auth-local",
"version": "0.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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
{
"name": "jazz-react",
"version": "0.0.8",
"main": "src/index.tsx",
"types": "src/index.tsx",
"version": "0.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"
}
}

View File

@@ -1,180 +1,81 @@
import {
LocalNode,
internals as cojsonInternals,
SessionID,
ContentType,
SyncMessage,
AgentSecret,
CoID,
ProfileContent,
CoMap,
AccountID,
Profile,
} from "cojson";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { ReadableStream, WritableStream } from "isomorphic-streams";
import { CoID } from "cojson";
import { AgentID } from "cojson/src/ids";
import { getAgentID } from "cojson/src/crypto";
import { AnonymousControlledAccount } from "cojson/src/account";
import { IDBStorage } from "jazz-storage-indexeddb";
import React, { useEffect, useState } from "react";
import { AuthProvider, createBrowserNode } from "jazz-browser";
export {
createInviteLink,
parseInviteLink,
consumeInviteLinkFromWindowLocation,
} from "jazz-browser";
type JazzContext = {
localNode: LocalNode;
logOut: () => void;
};
const JazzContext = React.createContext<JazzContext | undefined>(undefined);
export type AuthComponent = (props: {
onCredential: (credentials: AgentSecret) => void;
}) => React.ReactElement;
export type ReactAuthHook = () => {
auth: AuthProvider;
AuthUI: React.ReactNode;
logOut?: () => void;
};
export function WithJazz({
children,
auth: Auth,
syncAddress = "wss://sync.jazz.tools",
auth: authHook,
syncAddress,
}: {
children: React.ReactNode;
auth: AuthComponent;
auth: ReactAuthHook;
syncAddress?: string;
}) {
const [node, setNode] = useState<LocalNode | undefined>();
const sessionDone = useRef<() => void>();
const onCredential = useCallback((credential: AgentSecret) => {
const agentID = getAgentID(
credential
);
const sessionHandle = getSessionFor(agentID);
sessionHandle.session.then((sessionID) =>
setNode(new LocalNode(new AnonymousControlledAccount(credential), sessionID))
);
sessionDone.current = sessionHandle.done;
}, []);
const { auth, AuthUI, logOut } = authHook();
useEffect(() => {
let done: (() => void) | undefined = undefined;
(async () => {
const nodeHandle = await createBrowserNode({
auth: auth,
syncAddress,
});
setNode(nodeHandle.node);
done = nodeHandle.done;
})().catch((e) => {
console.log("Failed to create browser node", e);
});
return () => {
sessionDone.current && sessionDone.current();
done && done();
};
}, []);
}, [auth, syncAddress]);
useEffect(() => {
if (node) {
IDBStorage.connectTo(node, {trace: true})
let shouldTryToReconnect = true;
let ws: WebSocket | undefined;
(async function websocketReconnectLoop() {
while (shouldTryToReconnect) {
ws = new WebSocket(syncAddress);
const timeToReconnect = new Promise<void>((resolve) => {
if (
!ws ||
ws.readyState === WebSocket.CLOSING ||
ws.readyState === WebSocket.CLOSED
)
resolve();
ws?.addEventListener(
"close",
() => {
console.log(
"Connection closed, reconnecting in 5s"
);
setTimeout(resolve, 5000);
},
{ once: true }
);
});
const incoming = websocketReadableStream<SyncMessage>(ws);
const outgoing = websocketWritableStream<SyncMessage>(ws);
node.sync.addPeer({
id: syncAddress + "@" + new Date().toISOString(),
incoming,
outgoing,
role: "server",
});
await timeToReconnect;
}
})();
return () => {
shouldTryToReconnect = false;
ws?.close();
};
}
}, [node, syncAddress]);
return node ? (
<JazzContext.Provider value={{ localNode: node }}>
<>{children}</>
</JazzContext.Provider>
) : (
<Auth onCredential={onCredential} />
return (
<>
{node && logOut ? (
<JazzContext.Provider value={{ localNode: node, logOut }}>
<>{children}</>
</JazzContext.Provider>
) : (
AuthUI
)}
</>
);
}
type SessionHandle = {
session: Promise<SessionID>;
done: () => void;
};
function getSessionFor(agentID: AgentID): SessionHandle {
let done!: () => void;
const donePromise = new Promise<void>((resolve) => {
done = resolve;
});
let resolveSession: (sessionID: SessionID) => void;
const sessionPromise = new Promise<SessionID>((resolve) => {
resolveSession = resolve;
});
(async function () {
for (let idx = 0; idx < 100; idx++) {
// To work better around StrictMode
for (let retry = 0; retry < 2; retry++) {
console.log("Trying to get lock", agentID + "_" + idx);
const sessionFinishedOrNoLock = await navigator.locks.request(
agentID + "_" + idx,
{ ifAvailable: true },
async (lock) => {
if (!lock) return "noLock";
const sessionID =
localStorage[agentID + "_" + idx] ||
cojsonInternals.newRandomSessionID(agentID);
localStorage[agentID + "_" + idx] = sessionID;
console.log("Got lock", agentID + "_" + idx, sessionID);
resolveSession(sessionID);
await donePromise;
console.log(
"Done with lock",
agentID + "_" + idx,
sessionID
);
return "sessionFinished";
}
);
if (sessionFinishedOrNoLock === "sessionFinished") {
return;
}
}
}
throw new Error("Couldn't get lock on session after 100x2 tries");
})();
return {
session: sessionPromise,
done,
};
}
export function useJazz() {
const context = React.useContext(JazzContext);
@@ -185,28 +86,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);
}

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
{
"name": "jazz-storage-indexeddb",
"version": "0.0.3",
"main": "src/index.ts",
"types": "src/index.ts",
"version": "0.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"
}
}

View File

@@ -1,22 +1,66 @@
import { expect, test } from 'vitest'
import { LocalNode, } from 'cojson';
import { getAgentID, newRandomAgentSecret } from 'cojson/src/crypto';
import { newRandomSessionID } from 'cojson/src/coValue';
import { AnonymousControlledAccount } from 'cojson/src/account';
import { IDBStorage } from '.';
import { expect, test } from "vitest";
import { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
import { IDBStorage } from ".";
test("Should be able to initialize and load from empty DB", async () => {
const agentSecret = newRandomAgentSecret();
test.skip("Should be able to initialize and load from empty DB", async () => {
const agentSecret = cojsonInternals.newRandomAgentSecret();
const node = new LocalNode(new AnonymousControlledAccount(agentSecret), newRandomSessionID(getAgentID(agentSecret)));
const node = new LocalNode(
new AnonymousControlledAccount(agentSecret),
cojsonInternals.newRandomSessionID(
cojsonInternals.getAgentID(agentSecret)
)
);
await IDBStorage.connectTo(node, {trace: true});
node.sync.addPeer(await IDBStorage.asPeer({ trace: true }));
console.log("yay!");
const team = node.createTeam();
const _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");
});

View File

@@ -1,41 +1,31 @@
import { cojsonInternals, SessionID, SyncMessage, Peer, CojsonInternalTypes } from "cojson";
import {
LocalNode,
internals as cojsonInternals,
SessionID,
ContentType,
SyncMessage,
JsonValue,
} from "cojson";
import { CoValueHeader, Transaction } from "cojson/src/coValue";
import { Signature } from "cojson/src/crypto";
import { RawCoID } from "cojson/src/ids";
import {
CoValueKnownState,
DoneMessage,
KnownStateMessage,
LoadMessage,
NewContentMessage,
} from "cojson/src/sync";
import { ReadableStream, WritableStream, ReadableStreamDefaultReader, WritableStreamDefaultWriter } from "isomorphic-streams";
ReadableStream,
WritableStream,
ReadableStreamDefaultReader,
WritableStreamDefaultWriter,
} from "isomorphic-streams";
type CoValueRow = {
id: RawCoID;
header: CoValueHeader;
id: CojsonInternalTypes.RawCoID;
header: CojsonInternalTypes.CoValueHeader;
};
type StoredCoValueRow = CoValueRow & { rowID: number };
type SessionRow = {
coValue: number;
sessionID: SessionID;
lastIdx: number;
lastSignature: Signature;
lastSignature: CojsonInternalTypes.Signature;
};
type StoredSessionRow = SessionRow & { rowID: number };
type TransactionRow = {
ses: SessionID;
ses: number;
idx: number;
tx: Transaction;
tx: CojsonInternalTypes.Transaction;
};
export class IDBStorage {
@@ -43,32 +33,54 @@ export class IDBStorage {
fromLocalNode!: ReadableStreamDefaultReader<SyncMessage>;
toLocalNode: WritableStreamDefaultWriter<SyncMessage>;
constructor(db: IDBDatabase, fromLocalNode: ReadableStream<SyncMessage>, toLocalNode: WritableStream<SyncMessage>) {
constructor(
db: IDBDatabase,
fromLocalNode: ReadableStream<SyncMessage>,
toLocalNode: WritableStream<SyncMessage>
) {
this.db = db;
this.fromLocalNode = fromLocalNode.getReader();
this.toLocalNode = toLocalNode.getWriter();
(async () => {
while (true) {
const { done, value } = await this.fromLocalNode.read();
if (done) {
break;
}
let done = false;
while (!done) {
const result = await this.fromLocalNode.read();
done = result.done;
this.handleSyncMessage(value);
if (result.value) {
this.handleSyncMessage(result.value);
}
}
})();
}
static async connectTo(localNode: LocalNode, {trace}: {trace?: boolean} = {}) {
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers("local", "storage", {peer1role: "client", peer2role: "server", trace});
static async asPeer(
{
trace,
localNodeName = "local",
}: { trace?: boolean; localNodeName?: string } | undefined = {
localNodeName: "local",
}
): Promise<Peer> {
const [localNodeAsPeer, storageAsPeer] = cojsonInternals.connectedPeers(
localNodeName,
"storage",
{ peer1role: "client", peer2role: "server", trace }
);
await IDBStorage.open(localNodeAsPeer.incoming, localNodeAsPeer.outgoing);
await IDBStorage.open(
localNodeAsPeer.incoming,
localNodeAsPeer.outgoing
);
localNode.sync.addPeer(storageAsPeer);
return storageAsPeer;
}
static async open(fromLocalNode: ReadableStream<SyncMessage>, toLocalNode: WritableStream<SyncMessage>) {
static async open(
fromLocalNode: ReadableStream<SyncMessage>,
toLocalNode: WritableStream<SyncMessage>
) {
const dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("jazz-storage", 1);
request.onerror = () => {
@@ -85,17 +97,23 @@ export class IDBStorage {
keyPath: "rowID",
});
coValues.createIndex("id", "id", {
coValues.createIndex("coValuesById", "id", {
unique: true,
});
const sessions = db.createObjectStore("sessions", {
keyPath: ["coValue", "sessionID"],
autoIncrement: true,
keyPath: "rowID",
});
sessions.createIndex("sessionID", "sessionID", {
unique: true,
});
sessions.createIndex("sessionsByCoValue", "coValue");
sessions.createIndex(
"uniqueSessions",
["coValue", "sessionID"],
{
unique: true,
}
);
db.createObjectStore("transactions", {
keyPath: ["ses", "idx"],
@@ -124,153 +142,277 @@ export class IDBStorage {
}
async sendNewContentAfter(
theirKnown: CoValueKnownState
theirKnown: CojsonInternalTypes.CoValueKnownState,
{
coValues,
sessions,
transactions,
}: {
coValues: IDBObjectStore;
sessions: IDBObjectStore;
transactions: IDBObjectStore;
},
asDependencyOf?: CojsonInternalTypes.RawCoID
) {
const tx = this.db.transaction(
["coValues", "sessions", "transactions"],
"readonly"
);
tx.onerror = () => {
throw(new Error("Error in transaction", { cause: tx.error }));
};
const coValues = tx.objectStore("coValues");
const sessions = tx.objectStore("sessions");
const transactions = tx.objectStore("transactions");
const header = await new Promise<CoValueHeader | undefined>(
(resolve) => {
if (theirKnown.header) {
resolve(undefined);
} else {
const headerRequest = coValues.get(theirKnown.id);
headerRequest.onsuccess = () => {
resolve(
(headerRequest.result as CoValueRow).header
);
};
}
}
);
const allOurSessions = await new Promise<StoredSessionRow[]>(
(resolve) => {
const allOurSessionsRequest = sessions
.index("sessionID")
.getAll(
IDBKeyRange.bound(
[theirKnown.id, "\u0000"],
[theirKnown.id, "\uffff"]
)
);
allOurSessionsRequest.onsuccess = () => {
resolve(
allOurSessionsRequest.result as StoredSessionRow[]
);
};
}
);
const ourKnown: CoValueKnownState = {
id: theirKnown.id,
header: !!header,
sessions: {},
};
let shouldTellOurKnown = (
Object.keys(theirKnown.sessions) as SessionID[]
).some((sessionID) => {
return !allOurSessions.some(
(row) => row.sessionID === sessionID
);
});
const newContent: NewContentMessage = {
action: "content",
id: theirKnown.id,
header,
new: {},
};
for (const sessionRow of allOurSessions) {
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
if (
sessionRow.lastIdx <
(theirKnown.sessions[sessionRow.sessionID] || 0)
) {
shouldTellOurKnown = true;
} else if (
sessionRow.lastIdx >
(theirKnown.sessions[sessionRow.sessionID] || 0)
) {
const firstNewTxIdx =
theirKnown.sessions[sessionRow.sessionID] || 0;
const newTxInSession = await new Promise<TransactionRow[]>(
(resolve) => {
const newTxRequest = transactions.getAll(
IDBKeyRange.bound(
[sessionRow.rowID, firstNewTxIdx],
[sessionRow.rowID, Infinity]
)
);
newTxRequest.onsuccess = () => {
resolve(
newTxRequest.result as TransactionRow[]
);
};
}
);
newContent.new[sessionRow.sessionID] = {
after: firstNewTxIdx,
lastSignature: sessionRow.lastSignature,
newTransactions: newTxInSession.map((row) => row.tx),
};
}
}
if (shouldTellOurKnown) {
await this.toLocalNode.write({
action: "known",
...ourKnown,
});
}
if (newContent.header || Object.keys(newContent.new).length > 0) {
await this.toLocalNode.write(newContent);
}
}
handleLoad(msg: LoadMessage) {
return this.sendNewContentAfter(msg);
}
handleContent(msg: NewContentMessage) {
const tx = this.db.transaction(
["coValues", "sessions", "transactions"],
"readwrite"
const coValueRow = await promised<StoredCoValueRow | undefined>(
coValues.index("coValuesById").get(theirKnown.id)
);
tx.onerror = () => {
throw(new Error("Error in transaction", { cause: tx.error }));
const allOurSessions = coValueRow
? await promised<StoredSessionRow[]>(
sessions.index("sessionsByCoValue").getAll(coValueRow.rowID)
)
: [];
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
id: theirKnown.id,
header: !!coValueRow,
sessions: {},
};
const newContent: CojsonInternalTypes.NewContentMessage = {
action: "content",
id: theirKnown.id,
header: theirKnown.header ? undefined : coValueRow?.header,
new: {},
};
for (const sessionRow of allOurSessions) {
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
if (
sessionRow.lastIdx >
(theirKnown.sessions[sessionRow.sessionID] || 0)
) {
const firstNewTxIdx =
theirKnown.sessions[sessionRow.sessionID] || 0;
const newTxInSession = await promised<TransactionRow[]>(
transactions.getAll(
IDBKeyRange.bound(
[sessionRow.rowID, firstNewTxIdx],
[sessionRow.rowID, Infinity]
)
)
);
newContent.new[sessionRow.sessionID] = {
after: firstNewTxIdx,
lastSignature: sessionRow.lastSignature,
newTransactions: newTxInSession.map((row) => row.tx),
};
}
}
const dependedOnCoValues =
coValueRow?.header.ruleset.type === "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);
};
});
}

View File

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

View File

@@ -1175,6 +1175,17 @@
"@radix-ui/react-use-previous" "1.0.1"
"@radix-ui/react-use-size" "1.0.1"
"@radix-ui/react-collection@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"
integrity sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-compose-refs@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
@@ -1189,6 +1200,26 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-dismissable-layer@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz#883a48f5f938fa679427aa17fcba70c5494c6978"
integrity sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown" "1.0.3"
"@radix-ui/react-portal@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.3.tgz#ffb961244c8ed1b46f039e6c215a6c4d9989bda1"
integrity sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-presence@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba"
@@ -1214,6 +1245,25 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-toast@^1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.1.4.tgz#9a7fc2d71700886f3292f7699c905f1e01be59e1"
integrity sha512-wf+fc8DOywrpRK3jlPlWVe+ELYGHdKDaaARJZNuUTWyWYq7+ANCFLp4rTjZ/mcGkJJQ/vZ949Zis9xxEpfq9OA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-collection" "1.0.3"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.4"
"@radix-ui/react-portal" "1.0.3"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-controllable-state" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-visually-hidden" "1.0.3"
"@radix-ui/react-use-callback-ref@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
@@ -1229,6 +1279,14 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755"
integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-layout-effect@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399"
@@ -1251,6 +1309,14 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-visually-hidden@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz#51aed9dd0fe5abcad7dee2a234ad36106a6984ac"
integrity sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@rollup/pluginutils@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"