Compare commits
28 Commits
jazz-react
...
jazz-examp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1747e1aaf | ||
|
|
934365c24d | ||
|
|
ff20c3a260 | ||
|
|
1d0ce83019 | ||
|
|
2951d8452f | ||
|
|
6532e79790 | ||
|
|
09603d17a3 | ||
|
|
10b372da6e | ||
|
|
b556a36db3 | ||
|
|
094c505cf0 | ||
|
|
4d71ab8aac | ||
|
|
f1297c613b | ||
|
|
1ee5c2b3c8 | ||
|
|
f14337f862 | ||
|
|
b8f4571474 | ||
|
|
6557532743 | ||
|
|
ea6835664b | ||
|
|
a00332f4af | ||
|
|
6a6fb2eb3c | ||
|
|
0437223d50 | ||
|
|
30c7e1bf6d | ||
|
|
aaa9d876d5 | ||
|
|
264009a1a9 | ||
|
|
f2cb5d1b59 | ||
|
|
8610db2d8e | ||
|
|
c672a03338 | ||
|
|
cb60088d2a | ||
|
|
a59d5d3b70 |
36
README.md
36
README.md
@@ -1,9 +1,35 @@
|
||||
# Jazz - instant sync
|
||||
|
||||
Jazz is an open-source toolkit for telepathic data.
|
||||
Jazz is an open-source toolkit for *permissioned telepathic data.*
|
||||
|
||||
Ship faster and simplify frontend, backend & devops by building with Telepathic Data.
|
||||
Get real-time multiplayer and cross-device sync for free.
|
||||
- Ship faster & simplify your frontend, backend & devops
|
||||
- Get cross-device sync, real-time multiplayer & offline support for free
|
||||
|
||||
## What is Telepathic Data?
|
||||
...
|
||||
## What is Permissioned Telepathic Data?
|
||||
|
||||
**Telepathic** means:
|
||||
|
||||
- Read and write data from anywhere in your app, as if it was local
|
||||
- Always have that data synced, instantly
|
||||
- to other devices of the same user, or to other users, collaborating
|
||||
- to your backend, workers, etc. *(coming soon)*
|
||||
|
||||
**Permissioned** means:
|
||||
|
||||
- Fine-grained, role-based permissions are *baked into* your data
|
||||
- They are enforced everywhere, locally (using cryptography instead of by a backend)
|
||||
- Roles can be changed dynamically, supporting changing teams, invite links and more
|
||||
|
||||
Note: because it is quite a mouthful, we will refer to just *telepathic data* below, but we always mean *permissioned telepathic data*.
|
||||
|
||||
### Why should you care?
|
||||
|
||||
If you build your app with telepathic data you only have to do three things:
|
||||
|
||||
1. Define your data model
|
||||
2. Define your permission model
|
||||
3. Connect a user interface to telepathic data implementing 1. and 2.
|
||||
|
||||
Things you **don't have to worry about anymore** are: building an API, handling local and backend state, handling user sessions, local persistence, offline support, multiplayer, most editing conflicts, cloud storage & backend persistence (if you use Jazz Global Mesh)
|
||||
|
||||
##
|
||||
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/jazz-logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>Jazz Todo List Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,15 +12,17 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.0.13",
|
||||
"jazz-react-auth-local": "^0.0.10",
|
||||
"jazz-react": "^0.1.1",
|
||||
"jazz-react-auth-local": "^0.1.1",
|
||||
"lucide-react": "^0.265.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.6"
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"uniqolor": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
|
||||
BIN
examples/todo/public/jazz-logo.png
Normal file
BIN
examples/todo/public/jazz-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -10,8 +10,17 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { CoMap, CoID, AccountID } from "cojson";
|
||||
import { useJazz, useProfile, useTelepathicState } from "jazz-react";
|
||||
import {
|
||||
consumeInviteLinkFromWindowLocation,
|
||||
useJazz,
|
||||
useProfile,
|
||||
useTelepathicState,
|
||||
} from "jazz-react";
|
||||
import { SubmittableInput } from "./components/SubmittableInput";
|
||||
import { createInviteLink } from "jazz-react";
|
||||
import { useToast } from "./components/ui/use-toast";
|
||||
import { Skeleton } from "./components/ui/skeleton";
|
||||
import uniqolor from "uniqolor";
|
||||
|
||||
type TaskContent = { done: boolean; text: string };
|
||||
type Task = CoMap<TaskContent>;
|
||||
@@ -19,41 +28,52 @@ type Task = CoMap<TaskContent>;
|
||||
type TodoListContent = {
|
||||
title: string;
|
||||
// other keys form a set of task IDs
|
||||
[taskId: CoID<Task>]: true
|
||||
[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, logOut } = useJazz();
|
||||
|
||||
const createList = useCallback((title: string) => {
|
||||
const listTeam = localNode.createTeam();
|
||||
const list = listTeam.createMap<TodoListContent>();
|
||||
const createList = useCallback(
|
||||
(title: string) => {
|
||||
const listGroup = localNode.createGroup();
|
||||
const list = listGroup.createMap<TodoListContent>();
|
||||
|
||||
list.edit((list) => {
|
||||
list.set("title", title);
|
||||
});
|
||||
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-start gap-10 pt-10 md:pt-[30vh] pb-10">
|
||||
<div className="flex flex-col h-full items-center justify-start gap-10 pt-10 pb-10 px-5">
|
||||
{listId ? (
|
||||
<TodoList listId={listId} />
|
||||
) : (
|
||||
@@ -68,6 +88,7 @@ function App() {
|
||||
window.location.hash = "";
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
@@ -80,27 +101,33 @@ export function TodoList({ listId }: { listId: CoID<TodoList> }) {
|
||||
|
||||
const createTask = (text: string) => {
|
||||
if (!list) return;
|
||||
let task = list.coValue.getTeam().createMap<TaskContent>();
|
||||
const task = list.coValue.getGroup().createMap<TaskContent>();
|
||||
|
||||
task = task.edit((task) => {
|
||||
task.edit((task) => {
|
||||
task.set("text", text);
|
||||
task.set("done", false);
|
||||
});
|
||||
|
||||
console.log("Created task", task.id, task.toJSON());
|
||||
|
||||
const listAfter = list.edit((list) => {
|
||||
list.edit((list) => {
|
||||
list.set(task.id, true);
|
||||
});
|
||||
|
||||
console.log("Updated list", listAfter.toJSON());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<h1>
|
||||
{list?.get("title")} ({list?.id})
|
||||
</h1>
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{list?.get("title") ? (
|
||||
<>
|
||||
{list.get("title")}{" "}
|
||||
<span className="text-sm">({list.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</h1>
|
||||
{list && <InviteButton list={list} />}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -127,6 +154,7 @@ export function TodoList({ listId }: { listId: CoID<TodoList> }) {
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={!list}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -153,7 +181,7 @@ function TaskRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
<span className={task?.get("done") ? "line-through" : ""}>
|
||||
{task?.get("text")}
|
||||
</span>
|
||||
@@ -167,11 +195,56 @@ function TaskRow({ taskId }: { taskId: CoID<Task> }) {
|
||||
function NameBadge({ accountID }: { accountID?: AccountID }) {
|
||||
const profile = useProfile({ accountID });
|
||||
|
||||
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const brightColor = uniqolor(accountID || "", { lightness: 80 }).color;
|
||||
const darkColor = uniqolor(accountID || "", { lightness: 20 }).color;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span
|
||||
className="rounded-full py-0.5 px-2 text-xs"
|
||||
style={{
|
||||
color: theme == "light" ? darkColor : brightColor,
|
||||
background: theme == "light" ? brightColor : darkColor,
|
||||
}}
|
||||
>
|
||||
{profile?.get("name") || "..."}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteButton({ list }: { list: TodoList }) {
|
||||
const [existingInviteLink, setExistingInviteLink] = useState<string>();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
list.coValue.getGroup().myRole() === "admin" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
let inviteLink = existingInviteLink;
|
||||
if (list && !inviteLink) {
|
||||
inviteLink = createInviteLink(list, "writer");
|
||||
setExistingInviteLink(inviteLink);
|
||||
}
|
||||
if (inviteLink) {
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
description: "Copied invite link to clipboard!",
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -2,11 +2,15 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SubmittableInput({
|
||||
onSubmit, label, placeholder,
|
||||
onSubmit,
|
||||
label,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: {
|
||||
onSubmit: (text: string) => void;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
@@ -21,12 +25,14 @@ export function SubmittableInput({
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="-ml-3 -my-2 flex-grow flex-3"
|
||||
className="-ml-3 -my-2 flex-grow flex-3 text-base"
|
||||
name="text"
|
||||
placeholder={placeholder}
|
||||
autoComplete="off" />
|
||||
<Button asChild type="submit" className="flex-shrink flex-1">
|
||||
<Input type="submit" value={label} />
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button asChild type="submit" className="flex-shrink flex-1 cursor-pointer">
|
||||
<Input type="submit" value={label} disabled={disabled} />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ export const PrettyAuthComponent: LocalAuthComponent = ({
|
||||
const [username, setUsername] = useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-full h-full flex items-center justify-center p-5">
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
@@ -28,6 +28,7 @@ export const PrettyAuthComponent: LocalAuthComponent = ({
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="webauthn"
|
||||
className="text-base"
|
||||
/>
|
||||
<Button asChild>
|
||||
<Input
|
||||
|
||||
15
examples/todo/src/components/ui/skeleton.tsx
Normal file
15
examples/todo/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
127
examples/todo/src/components/ui/toast.tsx
Normal file
127
examples/todo/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
33
examples/todo/src/components/ui/toaster.tsx
Normal file
33
examples/todo/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
192
examples/todo/src/components/ui/use-toast.ts
Normal file
192
examples/todo/src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
@@ -6,13 +6,15 @@ import { WithJazz } from "jazz-react";
|
||||
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>
|
||||
<ThemeProvider>
|
||||
<div className="flex items-center gap-2 justify-center mt-5"><img src="jazz-logo.png" className="h-5"/> Jazz Todo List Example</div>
|
||||
<WithJazz
|
||||
auth={LocalAuth({
|
||||
appName: "Todo List Example",
|
||||
appName: "Jazz Todo List Example",
|
||||
Component: PrettyAuthComponent,
|
||||
})}
|
||||
syncAddress={
|
||||
@@ -21,6 +23,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
}
|
||||
>
|
||||
<App />
|
||||
<Toaster />
|
||||
</WithJazz>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -9,5 +9,8 @@ export default defineConfig({
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: false
|
||||
}
|
||||
})
|
||||
|
||||
6
jest.config.js
Normal file
6
jest.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('jest').Config} */
|
||||
const config = {
|
||||
projects: ['<rootDir>/packages/cojson'],
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
18
packages/cojson-simple-sync/.eslintrc.cjs
Normal file
18
packages/cojson-simple-sync/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
root: true,
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
},
|
||||
|
||||
};
|
||||
173
packages/cojson-simple-sync/.gitignore
vendored
Normal file
173
packages/cojson-simple-sync/.gitignore
vendored
Normal file
@@ -0,0 +1,173 @@
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
.DS_Store
|
||||
|
||||
out
|
||||
34
packages/cojson-simple-sync/package.json
Normal file
34
packages/cojson-simple-sync/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "cojson-simple-sync",
|
||||
"module": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@types/ws": "^8.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
"@typescript-eslint/parser": "^6.2.1",
|
||||
"eslint": "^8.46.0",
|
||||
"jest": "^29.6.2",
|
||||
"ts-jest": "^29.1.1",
|
||||
"typescript": "5.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cojson": "^0.1.1",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist && npm run add-shebang && chmod +x ./dist/index.js",
|
||||
"add-shebang": "echo \"#!/usr/bin/env node\" | cat - ./dist/index.js > /tmp/out && mv /tmp/out ./dist/index.js",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"bin": "./dist/index.js",
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
58
packages/cojson-simple-sync/src/index.ts
Normal file
58
packages/cojson-simple-sync/src/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
|
||||
import { WebSocketServer, createWebSocketStream } from "ws";
|
||||
import { Duplex } from "node:stream";
|
||||
import { TransformStream } from "node:stream/web"
|
||||
|
||||
const wss = new WebSocketServer({ port: 4200 });
|
||||
|
||||
console.log("COJSON sync server listening on port " + wss.options.port)
|
||||
|
||||
const agentSecret = cojsonInternals.newRandomAgentSecret();
|
||||
const agentID = cojsonInternals.getAgentID(agentSecret);
|
||||
|
||||
const localNode = new LocalNode(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
cojsonInternals.newRandomSessionID(agentID)
|
||||
);
|
||||
|
||||
wss.on("connection", function connection(ws, req) {
|
||||
const duplexStream = createWebSocketStream(ws, {
|
||||
decodeStrings: false,
|
||||
readableObjectMode: true,
|
||||
writableObjectMode: true,
|
||||
encoding: "utf-8",
|
||||
defaultEncoding: "utf-8",
|
||||
});
|
||||
|
||||
const { readable: incomingStrings, writable: outgoingStrings } = Duplex.toWeb(duplexStream);
|
||||
|
||||
const toJSON = new TransformStream({
|
||||
transform: (chunk, controller) => {
|
||||
controller.enqueue(JSON.parse(chunk));
|
||||
}
|
||||
})
|
||||
|
||||
const fromJSON = new TransformStream({
|
||||
transform: (chunk, controller) => {
|
||||
controller.enqueue(JSON.stringify(chunk));
|
||||
}
|
||||
});
|
||||
|
||||
const clientAddress =
|
||||
(req.headers["x-forwarded-for"] as string | undefined)
|
||||
?.split(",")[0]
|
||||
?.trim() || req.socket.remoteAddress;
|
||||
|
||||
const clientId = clientAddress + "@" + new Date().toISOString();
|
||||
|
||||
localNode.sync.addPeer({
|
||||
id: clientId,
|
||||
role: "client",
|
||||
incoming: incomingStrings.pipeThrough(toJSON),
|
||||
outgoing: fromJSON.writable,
|
||||
});
|
||||
|
||||
void fromJSON.readable.pipeTo(outgoingStrings);
|
||||
|
||||
ws.on("error", (e) => console.error(`Error on connection ${clientId}:`, e));
|
||||
});
|
||||
19
packages/cojson-simple-sync/tsconfig.json
Normal file
19
packages/cojson-simple-sync/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
}
|
||||
2801
packages/cojson-simple-sync/yarn.lock
Normal file
2801
packages/cojson-simple-sync/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ THIS IS WORK IN PROGRESS
|
||||
|
||||
### `Collaborative` Values
|
||||
- CoMap (`string` → `Immutable`, last-writer-wins per key)
|
||||
- Team (`AgentID` → `Role`)
|
||||
- Group (`AgentID` → `Role`)
|
||||
- CoList (`Immutable[]`, addressable positions, insertAfter semantics)
|
||||
- Agent (`{signerID, sealerID}[]`)
|
||||
- CoStream (independent per-session streams of `Immutable`s)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.0.20",
|
||||
"version": "0.1.1",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
|
||||
@@ -19,14 +19,14 @@ test("Can create a node while creating a new account with profile", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("A node with an account can create teams and and objects within them", async () => {
|
||||
test("A node with an account can create groups and and objects within them", async () => {
|
||||
const { node, accountID } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
const team = await node.createTeam();
|
||||
expect(team).not.toBeNull();
|
||||
const group = await node.createGroup();
|
||||
expect(group).not.toBeNull();
|
||||
|
||||
let map = team.createMap();
|
||||
let map = group.createMap();
|
||||
map = map.edit((edit) => {
|
||||
edit.set("foo", "bar", "private");
|
||||
expect(edit.get("foo")).toEqual("bar");
|
||||
@@ -41,10 +41,10 @@ test("Can create account with one node, and then load it on another", async () =
|
||||
const { node, accountID, accountSecret } =
|
||||
LocalNode.withNewlyCreatedAccount("Hermes Puggington");
|
||||
|
||||
const team = await node.createTeam();
|
||||
expect(team).not.toBeNull();
|
||||
const group = await node.createGroup();
|
||||
expect(group).not.toBeNull();
|
||||
|
||||
let map = team.createMap();
|
||||
let map = group.createMap();
|
||||
map = map.edit((edit) => {
|
||||
edit.set("foo", "bar", "private");
|
||||
expect(edit.get("foo")).toEqual("bar");
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "./crypto.js";
|
||||
import { AgentID } from "./ids.js";
|
||||
import { CoMap, LocalNode } from "./index.js";
|
||||
import { Team, TeamContent } from "./permissions.js";
|
||||
import { Group, GroupContent } from "./group.js";
|
||||
|
||||
export function accountHeaderForInitialAgentSecret(
|
||||
agentSecret: AgentSecret
|
||||
@@ -22,7 +22,7 @@ export function accountHeaderForInitialAgentSecret(
|
||||
const agent = getAgentID(agentSecret);
|
||||
return {
|
||||
type: "comap",
|
||||
ruleset: { type: "team", initialAdmin: agent },
|
||||
ruleset: { type: "group", initialAdmin: agent },
|
||||
meta: {
|
||||
type: "account",
|
||||
},
|
||||
@@ -31,13 +31,13 @@ export function accountHeaderForInitialAgentSecret(
|
||||
};
|
||||
}
|
||||
|
||||
export class Account extends Team {
|
||||
export class Account extends Group {
|
||||
get id(): AccountID {
|
||||
return this.teamMap.id as AccountID;
|
||||
return this.groupMap.id as AccountID;
|
||||
}
|
||||
|
||||
getCurrentAgentID(): AgentID {
|
||||
const agents = this.teamMap
|
||||
const agents = this.groupMap
|
||||
.keys()
|
||||
.filter((k): k is AgentID => k.startsWith("sealer_"));
|
||||
|
||||
@@ -70,10 +70,10 @@ export class ControlledAccount
|
||||
|
||||
constructor(
|
||||
agentSecret: AgentSecret,
|
||||
teamMap: CoMap<AccountContent, AccountMeta>,
|
||||
groupMap: CoMap<AccountContent, AccountMeta>,
|
||||
node: LocalNode
|
||||
) {
|
||||
super(teamMap, node);
|
||||
super(groupMap, node);
|
||||
|
||||
this.agentSecret = agentSecret;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ export class AnonymousControlledAccount
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountContent = TeamContent & { profile: CoID<Profile> };
|
||||
export type AccountContent = GroupContent & { profile: CoID<Profile> };
|
||||
export type AccountMeta = { type: "account" };
|
||||
export type AccountID = CoID<CoMap<AccountContent, AccountMeta>>;
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Transaction } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { AccountID } from "./index.js";
|
||||
import { Role } from "./permissions.js";
|
||||
|
||||
test("Can create coValue with new agent credentials and add transaction to it", () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
@@ -121,3 +124,58 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("New transactions in a group correctly update owned values, including subscriptions", async () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
|
||||
const group = node.createGroup();
|
||||
|
||||
const timeBeforeEdit = Date.now();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
let map = group.createMap();
|
||||
|
||||
let mapAfterEdit = map.edit((map) => {
|
||||
map.set("hello", "world");
|
||||
});
|
||||
|
||||
const listener = jest.fn().mockImplementation();
|
||||
|
||||
map.subscribe(listener);
|
||||
|
||||
expect(listener.mock.calls[0][0].get("hello")).toBe("world");
|
||||
|
||||
const resignationThatWeJustLearnedAbout = {
|
||||
privacy: "trusting",
|
||||
madeAt: timeBeforeEdit,
|
||||
changes: [
|
||||
{
|
||||
op: "set",
|
||||
key: account.id,
|
||||
value: "revoked"
|
||||
} satisfies MapOpPayload<typeof account.id, Role>
|
||||
]
|
||||
} satisfies Transaction;
|
||||
|
||||
const { expectedNewHash } = group.groupMap.coValue.expectedNewHashAfter(sessionID, [
|
||||
resignationThatWeJustLearnedAbout,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
node.account.currentSignerSecret(),
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
expect(map.coValue.getValidSortedTransactions().length).toBe(1);
|
||||
|
||||
const manuallyAdddedTxSuccess = group.groupMap.coValue.tryAddTransactions(node.ownSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
|
||||
|
||||
expect(manuallyAdddedTxSuccess).toBe(true);
|
||||
|
||||
expect(listener.mock.calls.length).toBe(2);
|
||||
expect(listener.mock.calls[1][0].get("hello")).toBe(undefined);
|
||||
|
||||
expect(map.coValue.getValidSortedTransactions().length).toBe(0);
|
||||
});
|
||||
|
||||
@@ -24,11 +24,10 @@ import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import {
|
||||
PermissionsDef as RulesetDef,
|
||||
Team,
|
||||
determineValidTransactions,
|
||||
expectTeamContent,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { Group, expectGroupContent } from "./group.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
@@ -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 == "ownedByGroup") {
|
||||
this.node
|
||||
.expectCoValueLoaded(header.ruleset.group)
|
||||
.subscribe((_groupUpdate) => {
|
||||
this._cachedContent = undefined;
|
||||
const newContent = this.getCurrentContent();
|
||||
for (const listener of this.listeners) {
|
||||
listener(newContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get sessions(): Readonly<{ [key: SessionID]: SessionLog }> {
|
||||
return this._sessions;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
@@ -172,7 +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[] {
|
||||
@@ -367,8 +385,8 @@ export class CoValue {
|
||||
}
|
||||
|
||||
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
|
||||
if (this.header.ruleset.type === "team") {
|
||||
const content = expectTeamContent(this.getCurrentContent());
|
||||
if (this.header.ruleset.type === "group") {
|
||||
const content = expectGroupContent(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.get("readKey");
|
||||
|
||||
@@ -382,24 +400,26 @@ export class CoValue {
|
||||
secret: secret,
|
||||
id: currentKeyId,
|
||||
};
|
||||
} else if (this.header.ruleset.type === "ownedByTeam") {
|
||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.team)
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentReadKey();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only teams or values owned by teams have read secrets"
|
||||
"Only groups or values owned by groups have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (this.header.ruleset.type === "team") {
|
||||
const content = expectTeamContent(this.getCurrentContent());
|
||||
if (this.header.ruleset.type === "group") {
|
||||
const content = expectGroupContent(this.getCurrentContent());
|
||||
|
||||
// Try to find key revelation for us
|
||||
|
||||
const readKeyEntry = content.getLastEntry(`${keyID}_for_${this.node.account.id}`);
|
||||
const readKeyEntry = content.getLastEntry(
|
||||
`${keyID}_for_${this.node.account.id}`
|
||||
);
|
||||
|
||||
if (readKeyEntry) {
|
||||
const revealer = accountOrAgentIDfromSessionID(
|
||||
@@ -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,30 +474,29 @@ export class CoValue {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} else if (this.header.ruleset.type === "ownedByTeam") {
|
||||
} else if (this.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.team)
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getReadKey(keyID);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only teams or values owned by teams have read secrets"
|
||||
"Only groups or values owned by groups have read secrets"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getTeam(): Team {
|
||||
if (this.header.ruleset.type !== "ownedByTeam") {
|
||||
throw new Error("Only values owned by teams have teams");
|
||||
getGroup(): Group {
|
||||
if (this.header.ruleset.type !== "ownedByGroup") {
|
||||
throw new Error("Only values owned by groups have groups");
|
||||
}
|
||||
|
||||
return new Team(
|
||||
expectTeamContent(
|
||||
return new Group(
|
||||
expectGroupContent(
|
||||
this.node
|
||||
.expectCoValueLoaded(this.header.ruleset.team)
|
||||
.expectCoValueLoaded(this.header.ruleset.group)
|
||||
.getCurrentContent()
|
||||
),
|
||||
this.node
|
||||
@@ -525,10 +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;
|
||||
}
|
||||
|
||||
@@ -536,12 +553,12 @@ export class CoValue {
|
||||
}
|
||||
|
||||
getDependedOnCoValues(): RawCoID[] {
|
||||
return this.header.ruleset.type === "team"
|
||||
? expectTeamContent(this.getCurrentContent())
|
||||
return this.header.ruleset.type === "group"
|
||||
? expectGroupContent(this.getCurrentContent())
|
||||
.keys()
|
||||
.filter((k): k is AccountID => k.startsWith("co_"))
|
||||
: this.header.ruleset.type === "ownedByTeam"
|
||||
? [this.header.ruleset.team]
|
||||
: this.header.ruleset.type === "ownedByGroup"
|
||||
? [this.header.ruleset.group]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ type MapOp<K extends string, V extends JsonValue> = {
|
||||
// TODO: add after TransactionID[] for conflicts/ordering
|
||||
|
||||
export type MapOpPayload<K extends string, V extends JsonValue> = {
|
||||
op: "insert";
|
||||
op: "set";
|
||||
key: K;
|
||||
value: V;
|
||||
} |
|
||||
{
|
||||
op: "delete";
|
||||
op: "del";
|
||||
key: K;
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ export class CoMap<
|
||||
|
||||
const lastEntry = ops[ops.length - 1]!;
|
||||
|
||||
if (lastEntry.op === "delete") {
|
||||
if (lastEntry.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return lastEntry.value;
|
||||
@@ -100,7 +100,7 @@ export class CoMap<
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastOpBeforeOrAtTime.op === "delete") {
|
||||
if (lastOpBeforeOrAtTime.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return lastOpBeforeOrAtTime.value;
|
||||
@@ -139,7 +139,7 @@ export class CoMap<
|
||||
|
||||
const lastEntry = ops[ops.length - 1]!;
|
||||
|
||||
if (lastEntry.op === "delete") {
|
||||
if (lastEntry.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return { at: lastEntry.madeAt, txID: lastEntry.txID, value: lastEntry.value };
|
||||
@@ -155,7 +155,7 @@ export class CoMap<
|
||||
const history: { at: number; txID: TransactionID; value: M[K] | undefined; }[] = [];
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.op === "delete") {
|
||||
if (op.op === "del") {
|
||||
history.push({ at: op.madeAt, txID: op.txID, value: undefined });
|
||||
} else {
|
||||
history.push({ at: op.madeAt, txID: op.txID, value: op.value });
|
||||
@@ -199,7 +199,7 @@ export class WriteableCoMap<
|
||||
set<K extends MapK<M>>(key: K, value: M[K], privacy: "private" | "trusting" = "private"): void {
|
||||
this.coValue.makeTransaction([
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key,
|
||||
value,
|
||||
},
|
||||
@@ -211,7 +211,7 @@ export class WriteableCoMap<
|
||||
delete(key: MapK<M>, privacy: "private" | "trusting" = "private"): void {
|
||||
this.coValue.makeTransaction([
|
||||
{
|
||||
op: "delete",
|
||||
op: "del",
|
||||
key,
|
||||
},
|
||||
], privacy);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { x25519 } from "@noble/curves/ed25519";
|
||||
import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import stableStringify from "fast-json-stable-stringify";
|
||||
import { SessionID } from './ids.js';
|
||||
|
||||
test("Signatures round-trip and use stable stringify", () => {
|
||||
const data = { b: "world", a: "hello" };
|
||||
@@ -49,7 +50,7 @@ test("encrypting round-trips, but invalid receiver can't unseal", () => {
|
||||
|
||||
const nOnceMaterial = {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
} as const;
|
||||
|
||||
const sealed = seal(
|
||||
@@ -101,22 +102,22 @@ test("Encryption for transactions round-trips", () => {
|
||||
|
||||
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
const decrypted1 = decryptForTransaction(encrypted1, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
expect([decrypted1, decrypted2]).toEqual([{ a: "hello" }, { b: "world" }]);
|
||||
@@ -128,22 +129,22 @@ test("Encryption for transactions doesn't decrypt with a wrong key", () => {
|
||||
|
||||
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
const decrypted1 = decryptForTransaction(encrypted1, secret2, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret2, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
expect([decrypted1, decrypted2]).toEqual([undefined, undefined]);
|
||||
|
||||
233
packages/cojson/src/group.ts
Normal file
233
packages/cojson/src/group.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
import { CoMap } from "./contentTypes/coMap.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
Encrypted,
|
||||
KeyID,
|
||||
KeySecret,
|
||||
createdNowUnique,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
encryptKeySecret,
|
||||
getAgentSealerID,
|
||||
Sealed,
|
||||
newRandomSecretSeed,
|
||||
agentSecretFromSecretSeed,
|
||||
getAgentID,
|
||||
} from "./crypto.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { SessionID, isAgentID } from "./ids.js";
|
||||
import {
|
||||
AccountIDOrAgentID,
|
||||
GeneralizedControlledAccount,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
import { Role } from "./permissions.js";
|
||||
import { base58 } from "@scure/base";
|
||||
|
||||
export type GroupContent = {
|
||||
profile: CoID<Profile> | null;
|
||||
[key: AccountIDOrAgentID]: Role;
|
||||
readKey: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
};
|
||||
|
||||
export function expectGroupContent(
|
||||
content: ContentType
|
||||
): CoMap<GroupContent, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<GroupContent, JsonObject | null>;
|
||||
}
|
||||
|
||||
export class Group {
|
||||
groupMap: CoMap<GroupContent, JsonObject | null>;
|
||||
node: LocalNode;
|
||||
|
||||
constructor(
|
||||
groupMap: CoMap<GroupContent, JsonObject | null>,
|
||||
node: LocalNode
|
||||
) {
|
||||
this.groupMap = groupMap;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
get id(): CoID<CoMap<GroupContent, JsonObject | null>> {
|
||||
return this.groupMap.id;
|
||||
}
|
||||
|
||||
roleOf(accountID: AccountIDOrAgentID): Role | undefined {
|
||||
return this.groupMap.get(accountID);
|
||||
}
|
||||
|
||||
myRole(): Role | undefined {
|
||||
return this.roleOf(this.node.account.id);
|
||||
}
|
||||
|
||||
addMember(accountID: AccountIDOrAgentID, role: Role) {
|
||||
this.groupMap = this.groupMap.edit((map) => {
|
||||
const currentReadKey = this.groupMap.coValue.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
const agent = this.node.resolveAccountAgent(
|
||||
accountID,
|
||||
"Expected to know agent to add them to group"
|
||||
);
|
||||
|
||||
map.set(accountID, role, "trusting");
|
||||
|
||||
if (map.get(accountID) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal(
|
||||
currentReadKey.secret,
|
||||
this.groupMap.coValue.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(agent),
|
||||
{
|
||||
in: this.groupMap.coValue.id,
|
||||
tx: this.groupMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
|
||||
const secretSeed = newRandomSecretSeed();
|
||||
|
||||
const inviteSecret = agentSecretFromSecretSeed(secretSeed);
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
this.addMember(inviteID, `${role}Invite` as Role);
|
||||
|
||||
return inviteSecretFromSecretSeed(secretSeed);
|
||||
}
|
||||
|
||||
rotateReadKey() {
|
||||
const currentlyPermittedReaders = this.groupMap.keys().filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.groupMap.get(key);
|
||||
return (
|
||||
role === "admin" || role === "writer" || role === "reader"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as AccountIDOrAgentID[];
|
||||
|
||||
const maybeCurrentReadKey = this.groupMap.coValue.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error(
|
||||
"Can't rotate read key secret we don't have access to"
|
||||
);
|
||||
}
|
||||
|
||||
const currentReadKey = {
|
||||
id: maybeCurrentReadKey.id,
|
||||
secret: maybeCurrentReadKey.secret,
|
||||
};
|
||||
|
||||
const newReadKey = newRandomKeySecret();
|
||||
|
||||
this.groupMap = this.groupMap.edit((map) => {
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.node.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader"
|
||||
);
|
||||
|
||||
map.set(
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
seal(
|
||||
newReadKey.secret,
|
||||
this.groupMap.coValue.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(reader),
|
||||
{
|
||||
in: this.groupMap.coValue.id,
|
||||
tx: this.groupMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${newReadKey.id}`,
|
||||
encryptKeySecret({
|
||||
encrypting: newReadKey,
|
||||
toEncrypt: currentReadKey,
|
||||
}).encrypted,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
map.set("readKey", newReadKey.id, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
removeMember(accountID: AccountIDOrAgentID) {
|
||||
this.groupMap = this.groupMap.edit((map) => {
|
||||
map.set(accountID, "revoked", "trusting");
|
||||
});
|
||||
|
||||
this.rotateReadKey();
|
||||
}
|
||||
|
||||
createMap<
|
||||
M extends { [key: string]: JsonValue },
|
||||
Meta extends JsonObject | null = null
|
||||
>(meta?: Meta): CoMap<M, Meta> {
|
||||
return this.node
|
||||
.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "ownedByGroup",
|
||||
group: this.groupMap.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as CoMap<M, Meta>;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
sessionId: SessionID
|
||||
): Group {
|
||||
return new Group(
|
||||
expectGroupContent(
|
||||
this.groupMap.coValue
|
||||
.testWithDifferentAccount(account, sessionId)
|
||||
.getCurrentContent()
|
||||
),
|
||||
this.node
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type InviteSecret = `inviteSecret_z${string}`;
|
||||
|
||||
function inviteSecretFromSecretSeed(secretSeed: Uint8Array): InviteSecret {
|
||||
return `inviteSecret_z${base58.encode(secretSeed)}`;
|
||||
}
|
||||
|
||||
export function secretSeedFromInviteSecret(inviteSecret: InviteSecret) {
|
||||
if (!inviteSecret.startsWith("inviteSecret_z")) {
|
||||
throw new Error("Invalid invite secret");
|
||||
}
|
||||
|
||||
return base58.decode(inviteSecret.slice("inviteSecret_z".length));
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
|
||||
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
|
||||
import { Group, expectGroupContent } from "./group.js"
|
||||
|
||||
import type { SessionID, AgentID } from "./ids.js";
|
||||
import type { CoID, ContentType } from "./contentType.js";
|
||||
@@ -26,6 +27,7 @@ import type {
|
||||
ProfileContent,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
import type { InviteSecret } from "./group.js";
|
||||
|
||||
type Value = JsonValue | ContentType;
|
||||
|
||||
@@ -42,6 +44,7 @@ export const cojsonInternals = {
|
||||
agentSecretFromSecretSeed,
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
expectGroupContent
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -50,6 +53,7 @@ export {
|
||||
CoMap,
|
||||
AnonymousControlledAccount,
|
||||
ControlledAccount,
|
||||
Group
|
||||
};
|
||||
|
||||
export type {
|
||||
@@ -66,6 +70,7 @@ export type {
|
||||
AccountContent,
|
||||
Profile,
|
||||
ProfileContent,
|
||||
InviteSecret
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CoID, ContentType } from './contentType.js';
|
||||
import { RawCoID } from './ids.js';
|
||||
|
||||
export type JsonAtom = string | number | boolean | null;
|
||||
export type JsonValue = JsonAtom | JsonArray | JsonObject | CoID<ContentType>;
|
||||
export type JsonValue = JsonAtom | JsonArray | JsonObject | RawCoID;
|
||||
export type JsonArray = JsonValue[];
|
||||
export type JsonObject = { [key: string]: JsonValue; };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AgentSecret,
|
||||
agentSecretFromSecretSeed,
|
||||
createdNowUnique,
|
||||
getAgentID,
|
||||
getAgentSealerID,
|
||||
@@ -9,7 +10,13 @@ import {
|
||||
seal,
|
||||
} from "./crypto.js";
|
||||
import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js";
|
||||
import { Team, TeamContent, expectTeamContent } from "./permissions.js";
|
||||
import {
|
||||
InviteSecret,
|
||||
Group,
|
||||
GroupContent,
|
||||
expectGroupContent,
|
||||
secretSeedFromInviteSecret,
|
||||
} from "./group.js";
|
||||
import { Peer, SyncManager } from "./sync.js";
|
||||
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
@@ -43,7 +50,10 @@ export class LocalNode {
|
||||
this.ownSessionID = ownSessionID;
|
||||
}
|
||||
|
||||
static withNewlyCreatedAccount(name: string, initialAgentSecret = newRandomAgentSecret()): {
|
||||
static withNewlyCreatedAccount(
|
||||
name: string,
|
||||
initialAgentSecret = newRandomAgentSecret()
|
||||
): {
|
||||
node: LocalNode;
|
||||
accountID: AccountID;
|
||||
accountSecret: AgentSecret;
|
||||
@@ -70,8 +80,16 @@ export class LocalNode {
|
||||
};
|
||||
}
|
||||
|
||||
static async withLoadedAccount(accountID: AccountID, accountSecret: AgentSecret, sessionID: SessionID, peersToLoadFrom: Peer[]): Promise<LocalNode> {
|
||||
const loadingNode = new LocalNode(new AnonymousControlledAccount(accountSecret), newRandomSessionID(accountID));
|
||||
static async withLoadedAccount(
|
||||
accountID: AccountID,
|
||||
accountSecret: AgentSecret,
|
||||
sessionID: SessionID,
|
||||
peersToLoadFrom: Peer[]
|
||||
): Promise<LocalNode> {
|
||||
const loadingNode = new LocalNode(
|
||||
new AnonymousControlledAccount(accountSecret),
|
||||
newRandomSessionID(accountID)
|
||||
);
|
||||
|
||||
const accountPromise = loadingNode.load(accountID);
|
||||
|
||||
@@ -82,7 +100,10 @@ export class LocalNode {
|
||||
const account = await accountPromise;
|
||||
|
||||
// since this is all synchronous, we can just swap out nodes for the SyncManager
|
||||
const node = loadingNode.testWithDifferentAccount(new ControlledAccount(accountSecret, account, loadingNode), sessionID);
|
||||
const node = loadingNode.testWithDifferentAccount(
|
||||
new ControlledAccount(accountSecret, account, loadingNode),
|
||||
sessionID
|
||||
);
|
||||
node.sync = loadingNode.sync;
|
||||
node.sync.local = node;
|
||||
|
||||
@@ -124,7 +145,87 @@ export class LocalNode {
|
||||
if (!profileID) {
|
||||
throw new Error(`Account ${id} has no profile`);
|
||||
}
|
||||
return (await this.loadCoValue(profileID)).getCurrentContent() as Profile;
|
||||
return (
|
||||
await this.loadCoValue(profileID)
|
||||
).getCurrentContent() as Profile;
|
||||
}
|
||||
|
||||
async acceptInvite<T extends ContentType>(
|
||||
groupOrOwnedValueID: CoID<T>,
|
||||
inviteSecret: InviteSecret
|
||||
): Promise<void> {
|
||||
const groupOrOwnedValue = await this.load(groupOrOwnedValueID);
|
||||
|
||||
if (groupOrOwnedValue.coValue.header.ruleset.type === "ownedByGroup") {
|
||||
return this.acceptInvite(
|
||||
groupOrOwnedValue.coValue.header.ruleset.group as CoID<
|
||||
CoMap<GroupContent>
|
||||
>,
|
||||
inviteSecret
|
||||
);
|
||||
} else if (groupOrOwnedValue.coValue.header.ruleset.type !== "group") {
|
||||
throw new Error("Can only accept invites to groups");
|
||||
}
|
||||
|
||||
const group = new Group(expectGroupContent(groupOrOwnedValue), this);
|
||||
|
||||
const inviteAgentSecret = agentSecretFromSecretSeed(
|
||||
secretSeedFromInviteSecret(inviteSecret)
|
||||
);
|
||||
const inviteAgentID = getAgentID(inviteAgentSecret);
|
||||
|
||||
const inviteRole = await new Promise((resolve, reject) => {
|
||||
group.groupMap.subscribe((groupMap) => {
|
||||
const role = groupMap.get(inviteAgentID);
|
||||
if (role) {
|
||||
resolve(role);
|
||||
}
|
||||
});
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error("Couldn't find invite before timeout")
|
||||
),
|
||||
1000
|
||||
);
|
||||
});
|
||||
|
||||
if (!inviteRole) {
|
||||
throw new Error("No invite found");
|
||||
}
|
||||
|
||||
const existingRole = group.groupMap.get(this.account.id);
|
||||
|
||||
if (
|
||||
existingRole === "admin" ||
|
||||
(existingRole === "writer" && inviteRole === "writerInvite") ||
|
||||
(existingRole === "writer" && inviteRole === "reader") ||
|
||||
(existingRole === "reader" && inviteRole === "readerInvite")
|
||||
) {
|
||||
console.debug("Not accepting invite that would replace or downgrade role");
|
||||
return;
|
||||
}
|
||||
|
||||
const groupAsInvite = group.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteAgentSecret),
|
||||
newRandomSessionID(inviteAgentID)
|
||||
);
|
||||
|
||||
groupAsInvite.addMember(
|
||||
this.account.id,
|
||||
inviteRole === "adminInvite"
|
||||
? "admin"
|
||||
: inviteRole === "writerInvite"
|
||||
? "writer"
|
||||
: "reader"
|
||||
);
|
||||
|
||||
group.groupMap.coValue._sessions = groupAsInvite.groupMap.coValue.sessions;
|
||||
group.groupMap.coValue._cachedContent = undefined;
|
||||
|
||||
for (const groupListener of group.groupMap.coValue.listeners) {
|
||||
groupListener(group.groupMap.coValue.getCurrentContent());
|
||||
}
|
||||
}
|
||||
|
||||
expectCoValueLoaded(id: RawCoID, expectation?: string): CoValue {
|
||||
@@ -146,7 +247,9 @@ export class LocalNode {
|
||||
|
||||
expectProfileLoaded(id: AccountID, expectation?: string): Profile {
|
||||
const account = this.expectCoValueLoaded(id, expectation);
|
||||
const profileID = expectTeamContent(account.getCurrentContent()).get("profile");
|
||||
const profileID = expectGroupContent(account.getCurrentContent()).get(
|
||||
"profile"
|
||||
);
|
||||
if (!profileID) {
|
||||
throw new Error(
|
||||
`${
|
||||
@@ -154,10 +257,16 @@ export class LocalNode {
|
||||
}Account ${id} has no profile`
|
||||
);
|
||||
}
|
||||
return this.expectCoValueLoaded(profileID, expectation).getCurrentContent() as Profile;
|
||||
return this.expectCoValueLoaded(
|
||||
profileID,
|
||||
expectation
|
||||
).getCurrentContent() as Profile;
|
||||
}
|
||||
|
||||
createAccount(name: string, agentSecret = newRandomAgentSecret()): ControlledAccount {
|
||||
createAccount(
|
||||
name: string,
|
||||
agentSecret = newRandomAgentSecret()
|
||||
): ControlledAccount {
|
||||
const account = this.createCoValue(
|
||||
accountHeaderForInitialAgentSecret(agentSecret)
|
||||
).testWithDifferentAccount(
|
||||
@@ -165,9 +274,12 @@ export class LocalNode {
|
||||
newRandomSessionID(getAgentID(agentSecret))
|
||||
);
|
||||
|
||||
const accountAsTeam = new Team(expectTeamContent(account.getCurrentContent()), account.node);
|
||||
const accountAsGroup = new Group(
|
||||
expectGroupContent(account.getCurrentContent()),
|
||||
account.node
|
||||
);
|
||||
|
||||
accountAsTeam.teamMap.edit((editable) => {
|
||||
accountAsGroup.groupMap.edit((editable) => {
|
||||
editable.set(getAgentID(agentSecret), "admin", "trusting");
|
||||
|
||||
const readKey = newRandomKeySecret();
|
||||
@@ -195,16 +307,23 @@ export class LocalNode {
|
||||
account.node
|
||||
);
|
||||
|
||||
const profile = accountAsTeam.createMap<ProfileContent, ProfileMeta>({ type: "profile" });
|
||||
const profile = accountAsGroup.createMap<ProfileContent, ProfileMeta>({
|
||||
type: "profile",
|
||||
});
|
||||
|
||||
profile.edit((editable) => {
|
||||
editable.set("name", name, "trusting");
|
||||
});
|
||||
|
||||
accountAsTeam.teamMap.edit((editable) => {
|
||||
accountAsGroup.groupMap.edit((editable) => {
|
||||
editable.set("profile", profile.id, "trusting");
|
||||
});
|
||||
|
||||
const accountOnThisNode = this.expectCoValueLoaded(account.id);
|
||||
|
||||
accountOnThisNode._sessions = {...accountAsGroup.groupMap.coValue.sessions};
|
||||
accountOnThisNode._cachedContent = undefined;
|
||||
|
||||
return controlledAccount;
|
||||
}
|
||||
|
||||
@@ -217,7 +336,7 @@ export class LocalNode {
|
||||
|
||||
if (
|
||||
coValue.header.type !== "comap" ||
|
||||
coValue.header.ruleset.type !== "team" ||
|
||||
coValue.header.ruleset.type !== "group" ||
|
||||
!coValue.header.meta ||
|
||||
!("type" in coValue.header.meta) ||
|
||||
coValue.header.meta.type !== "account"
|
||||
@@ -230,22 +349,22 @@ export class LocalNode {
|
||||
}
|
||||
|
||||
return new Account(
|
||||
coValue.getCurrentContent() as CoMap<TeamContent, AccountMeta>,
|
||||
coValue.getCurrentContent() as CoMap<GroupContent, AccountMeta>,
|
||||
this
|
||||
).getCurrentAgentID();
|
||||
}
|
||||
|
||||
createTeam(): Team {
|
||||
const teamCoValue = this.createCoValue({
|
||||
createGroup(): Group {
|
||||
const groupCoValue = this.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "team", initialAdmin: this.account.id },
|
||||
ruleset: { type: "group", initialAdmin: this.account.id },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
let teamContent = expectTeamContent(teamCoValue.getCurrentContent());
|
||||
let groupContent = expectGroupContent(groupCoValue.getCurrentContent());
|
||||
|
||||
teamContent = teamContent.edit((editable) => {
|
||||
groupContent = groupContent.edit((editable) => {
|
||||
editable.set(this.account.id, "admin", "trusting");
|
||||
|
||||
const readKey = newRandomKeySecret();
|
||||
@@ -257,8 +376,8 @@ export class LocalNode {
|
||||
this.account.currentSealerSecret(),
|
||||
this.account.currentSealerID(),
|
||||
{
|
||||
in: teamCoValue.id,
|
||||
tx: teamCoValue.nextTransactionID(),
|
||||
in: groupCoValue.id,
|
||||
tx: groupCoValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
@@ -267,7 +386,7 @@ export class LocalNode {
|
||||
editable.set("readKey", readKey.id, "trusting");
|
||||
});
|
||||
|
||||
return new Team(teamContent, this);
|
||||
return new Group(groupContent, this);
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
@@ -276,24 +395,36 @@ export class LocalNode {
|
||||
): LocalNode {
|
||||
const newNode = new LocalNode(account, ownSessionID);
|
||||
|
||||
newNode.coValues = Object.fromEntries(
|
||||
Object.entries(this.coValues)
|
||||
.map(([id, entry]) => {
|
||||
if (entry.state === "loading") {
|
||||
return undefined;
|
||||
}
|
||||
const coValuesToCopy = Object.entries(this.coValues);
|
||||
|
||||
const newCoValue = new CoValue(
|
||||
entry.coValue.header,
|
||||
newNode
|
||||
);
|
||||
while (coValuesToCopy.length > 0) {
|
||||
const [coValueID, entry] =
|
||||
coValuesToCopy[coValuesToCopy.length - 1]!;
|
||||
|
||||
newCoValue.sessions = entry.coValue.sessions;
|
||||
if (entry.state === "loading") {
|
||||
coValuesToCopy.pop();
|
||||
continue;
|
||||
} else {
|
||||
const allDepsCopied = entry.coValue
|
||||
.getDependedOnCoValues()
|
||||
.every((dep) => newNode.coValues[dep]?.state === "loaded");
|
||||
|
||||
return [id, { state: "loaded", coValue: newCoValue }];
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x)
|
||||
);
|
||||
if (!allDepsCopied) {
|
||||
// move to end of queue
|
||||
coValuesToCopy.unshift(coValuesToCopy.pop()!);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newCoValue = new CoValue(entry.coValue.header, newNode, {...entry.coValue.sessions});
|
||||
|
||||
newNode.coValues[coValueID as RawCoID] = {
|
||||
state: "loaded",
|
||||
coValue: newCoValue,
|
||||
};
|
||||
|
||||
coValuesToCopy.pop();
|
||||
}
|
||||
}
|
||||
|
||||
return newNode;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,8 @@
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { CoID } from "./contentType.js";
|
||||
import { MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
Encrypted,
|
||||
KeyID,
|
||||
KeySecret,
|
||||
createdNowUnique,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
encryptKeySecret,
|
||||
getAgentSealerID,
|
||||
Sealed,
|
||||
} from "./crypto.js";
|
||||
import {
|
||||
CoValue,
|
||||
@@ -18,21 +10,30 @@ import {
|
||||
TrustingTransaction,
|
||||
accountOrAgentIDfromSessionID,
|
||||
} from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { RawCoID, SessionID, TransactionID, isAgentID } from "./ids.js";
|
||||
import { AccountIDOrAgentID, GeneralizedControlledAccount, Profile } from "./account.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import {
|
||||
AccountIDOrAgentID,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
|
||||
export type PermissionsDef =
|
||||
| { type: "team"; initialAdmin: AccountIDOrAgentID }
|
||||
| { type: "ownedByTeam"; team: RawCoID }
|
||||
| { type: "group"; initialAdmin: AccountIDOrAgentID }
|
||||
| { type: "ownedByGroup"; group: RawCoID }
|
||||
| { type: "unsafeAllowAll" };
|
||||
|
||||
export type Role = "reader" | "writer" | "admin" | "revoked";
|
||||
export type Role =
|
||||
| "reader"
|
||||
| "writer"
|
||||
| "admin"
|
||||
| "revoked"
|
||||
| "adminInvite"
|
||||
| "writerInvite"
|
||||
| "readerInvite";
|
||||
|
||||
export function determineValidTransactions(
|
||||
coValue: CoValue
|
||||
): { txID: TransactionID; tx: Transaction }[] {
|
||||
if (coValue.header.ruleset.type === "team") {
|
||||
if (coValue.header.ruleset.type === "group") {
|
||||
const allTrustingTransactionsSorted = Object.entries(
|
||||
coValue.sessions
|
||||
).flatMap(([sessionID, sessionLog]) => {
|
||||
@@ -42,7 +43,7 @@ export function determineValidTransactions(
|
||||
if (tx.privacy === "trusting") {
|
||||
return true;
|
||||
} else {
|
||||
console.warn("Unexpected private transaction in Team");
|
||||
console.warn("Unexpected private transaction in Group");
|
||||
return false;
|
||||
}
|
||||
}) as {
|
||||
@@ -59,7 +60,7 @@ export function determineValidTransactions(
|
||||
const initialAdmin = coValue.header.ruleset.initialAdmin;
|
||||
|
||||
if (!initialAdmin) {
|
||||
throw new Error("Team must have initialAdmin");
|
||||
throw new Error("Group must have initialAdmin");
|
||||
}
|
||||
|
||||
const memberState: { [agent: AccountIDOrAgentID]: Role } = {};
|
||||
@@ -80,12 +81,12 @@ export function determineValidTransactions(
|
||||
| MapOpPayload<"readKey", JsonValue>
|
||||
| MapOpPayload<"profile", CoID<Profile>>;
|
||||
if (tx.changes.length !== 1) {
|
||||
console.warn("Team transaction must have exactly one change");
|
||||
console.warn("Group transaction must have exactly one change");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.op !== "insert") {
|
||||
console.warn("Team transaction must set a role or readKey");
|
||||
if (change.op !== "set") {
|
||||
console.warn("Group transaction must set a role or readKey");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@ export function determineValidTransactions(
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (change.key === 'profile') {
|
||||
} else if (change.key === "profile") {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
console.warn("Only admins can set profile");
|
||||
continue;
|
||||
@@ -105,8 +106,16 @@ export function determineValidTransactions(
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (isKeyForKeyField(change.key) || isKeyForAccountField(change.key)) {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
} else if (
|
||||
isKeyForKeyField(change.key) ||
|
||||
isKeyForAccountField(change.key)
|
||||
) {
|
||||
if (
|
||||
memberState[transactor] !== "admin" &&
|
||||
memberState[transactor] !== "adminInvite" &&
|
||||
memberState[transactor] !== "writerInvite" &&
|
||||
memberState[transactor] !== "readerInvite"
|
||||
) {
|
||||
console.warn("Only admins can reveal keys");
|
||||
continue;
|
||||
}
|
||||
@@ -124,35 +133,53 @@ export function determineValidTransactions(
|
||||
change.value !== "admin" &&
|
||||
change.value !== "writer" &&
|
||||
change.value !== "reader" &&
|
||||
change.value !== "revoked"
|
||||
change.value !== "revoked" &&
|
||||
change.value !== "adminInvite" &&
|
||||
change.value !== "writerInvite" &&
|
||||
change.value !== "readerInvite"
|
||||
) {
|
||||
console.warn("Team transaction must set a valid role");
|
||||
console.warn("Group transaction must set a valid role");
|
||||
continue;
|
||||
}
|
||||
|
||||
const isFirstSelfAppointment =
|
||||
!memberState[transactor] &&
|
||||
transactor === initialAdmin &&
|
||||
change.op === "insert" &&
|
||||
change.op === "set" &&
|
||||
change.key === transactor &&
|
||||
change.value === "admin";
|
||||
|
||||
if (!isFirstSelfAppointment) {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
if (memberState[transactor] === "admin") {
|
||||
if (
|
||||
memberState[affectedMember] === "admin" &&
|
||||
affectedMember !== transactor &&
|
||||
assignedRole !== "admin"
|
||||
) {
|
||||
console.warn("Admins can only demote themselves.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "adminInvite") {
|
||||
if (change.value !== "admin") {
|
||||
console.warn("AdminInvites can only create admins.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "writerInvite") {
|
||||
if (change.value !== "writer") {
|
||||
console.warn("WriterInvites can only create writers.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "readerInvite") {
|
||||
if (change.value !== "reader") {
|
||||
console.warn("ReaderInvites can only create reader.");
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Team transaction must be made by current admin"
|
||||
"Group transaction must be made by current admin or invite"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
memberState[affectedMember] === "admin" &&
|
||||
affectedMember !== transactor &&
|
||||
assignedRole !== "admin"
|
||||
) {
|
||||
console.warn("Admins can only demote themselves.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
memberState[affectedMember] = change.value;
|
||||
@@ -162,16 +189,16 @@ export function determineValidTransactions(
|
||||
}
|
||||
|
||||
return validTransactions;
|
||||
} else if (coValue.header.ruleset.type === "ownedByTeam") {
|
||||
const teamContent = coValue.node
|
||||
} else if (coValue.header.ruleset.type === "ownedByGroup") {
|
||||
const groupContent = coValue.node
|
||||
.expectCoValueLoaded(
|
||||
coValue.header.ruleset.team,
|
||||
"Determining valid transaction in owned object but its team wasn't loaded"
|
||||
coValue.header.ruleset.group,
|
||||
"Determining valid transaction in owned object but its group wasn't loaded"
|
||||
)
|
||||
.getCurrentContent();
|
||||
|
||||
if (teamContent.type !== "comap") {
|
||||
throw new Error("Team must be a map");
|
||||
if (groupContent.type !== "comap") {
|
||||
throw new Error("Group must be a map");
|
||||
}
|
||||
|
||||
return Object.entries(coValue.sessions).flatMap(
|
||||
@@ -181,7 +208,7 @@ export function determineValidTransactions(
|
||||
);
|
||||
return sessionLog.transactions
|
||||
.filter((tx) => {
|
||||
const transactorRoleAtTxTime = teamContent.getAtTime(
|
||||
const transactorRoleAtTxTime = groupContent.getAtTime(
|
||||
transactor,
|
||||
tx.madeAt
|
||||
);
|
||||
@@ -213,180 +240,17 @@ export function determineValidTransactions(
|
||||
}
|
||||
}
|
||||
|
||||
export type TeamContent = {
|
||||
profile: CoID<Profile> | null;
|
||||
[key: AccountIDOrAgentID]: Role;
|
||||
readKey: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
};
|
||||
|
||||
export function expectTeamContent(
|
||||
content: ContentType
|
||||
): CoMap<TeamContent, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<TeamContent, JsonObject | null>;
|
||||
}
|
||||
|
||||
export class Team {
|
||||
teamMap: CoMap<TeamContent, JsonObject | null>;
|
||||
node: LocalNode;
|
||||
|
||||
constructor(teamMap: CoMap<TeamContent, JsonObject | null>, node: LocalNode) {
|
||||
this.teamMap = teamMap;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
get id(): CoID<CoMap<TeamContent, JsonObject | null>> {
|
||||
return this.teamMap.id;
|
||||
}
|
||||
|
||||
addMember(accountID: AccountIDOrAgentID, role: Role) {
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
const agent = this.node.resolveAccountAgent(
|
||||
accountID,
|
||||
"Expected to know agent to add them to team"
|
||||
);
|
||||
|
||||
map.set(accountID, role, "trusting");
|
||||
|
||||
if (map.get(accountID) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal(
|
||||
currentReadKey.secret,
|
||||
this.teamMap.coValue.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(agent),
|
||||
{
|
||||
in: this.teamMap.coValue.id,
|
||||
tx: this.teamMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
rotateReadKey() {
|
||||
const currentlyPermittedReaders = this.teamMap.keys().filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.teamMap.get(key);
|
||||
return (
|
||||
role === "admin" || role === "writer" || role === "reader"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as AccountIDOrAgentID[];
|
||||
|
||||
const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error(
|
||||
"Can't rotate read key secret we don't have access to"
|
||||
);
|
||||
}
|
||||
|
||||
const currentReadKey = {
|
||||
id: maybeCurrentReadKey.id,
|
||||
secret: maybeCurrentReadKey.secret,
|
||||
};
|
||||
|
||||
const newReadKey = newRandomKeySecret();
|
||||
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.node.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader"
|
||||
);
|
||||
|
||||
map.set(
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
seal(
|
||||
newReadKey.secret,
|
||||
this.teamMap.coValue.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(reader),
|
||||
{
|
||||
in: this.teamMap.coValue.id,
|
||||
tx: this.teamMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${newReadKey.id}`,
|
||||
encryptKeySecret({
|
||||
encrypting: newReadKey,
|
||||
toEncrypt: currentReadKey,
|
||||
}).encrypted,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
map.set("readKey", newReadKey.id, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
removeMember(accountID: AccountIDOrAgentID) {
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
map.set(accountID, "revoked", "trusting");
|
||||
});
|
||||
|
||||
this.rotateReadKey();
|
||||
}
|
||||
|
||||
createMap<M extends { [key: string]: JsonValue }, Meta extends JsonObject | null = 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"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LocalNode } from "./node.js";
|
||||
import { Peer, PeerID, SyncMessage } from "./sync.js";
|
||||
import { expectMap } from "./contentType.js";
|
||||
import { MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { Team } from "./permissions.js";
|
||||
import { Group } from "./group.js";
|
||||
import {
|
||||
ReadableStream,
|
||||
WritableStream,
|
||||
@@ -23,9 +23,9 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
map.edit((editable) => {
|
||||
editable.set("hello", "world", "trusting");
|
||||
@@ -53,7 +53,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
const reader = outRx.getReader();
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
|
||||
|
||||
const mapTellKnownStateMsg = await reader.read();
|
||||
expect(mapTellKnownStateMsg.value).toEqual({
|
||||
@@ -62,7 +62,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
|
||||
|
||||
const newContentMsg = await reader.read();
|
||||
|
||||
@@ -71,7 +71,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
id: map.coValue.id,
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: { type: "ownedByTeam", team: team.id },
|
||||
ruleset: { type: "ownedByGroup", group: group.id },
|
||||
meta: null,
|
||||
createdAt: map.coValue.header.createdAt,
|
||||
uniqueness: map.coValue.header.uniqueness,
|
||||
@@ -86,7 +86,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -104,9 +104,9 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
map.edit((editable) => {
|
||||
editable.set("hello", "world", "trusting");
|
||||
@@ -137,7 +137,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
const reader = outRx.getReader();
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
|
||||
|
||||
const mapTellKnownStateMsg = await reader.read();
|
||||
expect(mapTellKnownStateMsg.value).toEqual({
|
||||
@@ -146,7 +146,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
|
||||
|
||||
const mapNewContentMsg = await reader.read();
|
||||
|
||||
@@ -164,7 +164,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -186,9 +186,9 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
@@ -214,7 +214,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
const reader = outRx.getReader();
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
|
||||
|
||||
const mapTellKnownStateMsg = await reader.read();
|
||||
expect(mapTellKnownStateMsg.value).toEqual({
|
||||
@@ -223,7 +223,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
|
||||
|
||||
const mapNewContentHeaderOnlyMsg = await reader.read();
|
||||
|
||||
@@ -253,7 +253,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -285,7 +285,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -303,9 +303,9 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
map.edit((editable) => {
|
||||
editable.set("hello", "world", "trusting");
|
||||
@@ -323,7 +323,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
|
||||
const reader = outRx.getReader();
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(teamStateEx(team));
|
||||
// expect((await reader.read()).value).toMatchObject(groupStateEx(group));
|
||||
|
||||
const writer = inTx.getWriter();
|
||||
|
||||
@@ -337,7 +337,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
});
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
|
||||
|
||||
const mapTellKnownStateMsg = await reader.read();
|
||||
expect(mapTellKnownStateMsg.value).toEqual({
|
||||
@@ -346,7 +346,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
|
||||
|
||||
const mapNewContentMsg = await reader.read();
|
||||
|
||||
@@ -364,7 +364,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -382,9 +382,9 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
@@ -410,7 +410,7 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
const reader = outRx.getReader();
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
|
||||
|
||||
const mapTellKnownStateMsg = await reader.read();
|
||||
expect(mapTellKnownStateMsg.value).toEqual({
|
||||
@@ -419,7 +419,7 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
|
||||
|
||||
const mapNewContentHeaderOnlyMsg = await reader.read();
|
||||
|
||||
@@ -467,7 +467,7 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -485,9 +485,9 @@ test("If we add a peer, but it never subscribes to a coValue, it won't get any m
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
const [inRx, _inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
@@ -514,9 +514,9 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
const [inRx, _inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
@@ -535,7 +535,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: team.teamMap.coValue.id,
|
||||
id: group.groupMap.coValue.id,
|
||||
});
|
||||
|
||||
const mapSubscribeMsg = await reader.read();
|
||||
@@ -552,7 +552,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
});
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
|
||||
|
||||
const mapNewContentMsg = await reader.read();
|
||||
|
||||
@@ -570,7 +570,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -588,7 +588,7 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const [inRx, _inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
@@ -607,10 +607,10 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: team.teamMap.coValue.id,
|
||||
id: group.groupMap.coValue.id,
|
||||
});
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
const mapSubscribeMsg = await reader.read();
|
||||
|
||||
@@ -620,7 +620,7 @@ test("If we add a server peer, newly created coValues are auto-subscribed to", a
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(adminID));
|
||||
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
|
||||
|
||||
const mapContentMsg = await reader.read();
|
||||
|
||||
@@ -640,9 +640,9 @@ test("When we connect a new server peer, we try to sync all existing coValues to
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
const [inRx, _inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
@@ -657,11 +657,11 @@ test("When we connect a new server peer, we try to sync all existing coValues to
|
||||
const reader = outRx.getReader();
|
||||
|
||||
// const _adminSubscribeMessage = await reader.read();
|
||||
const teamSubscribeMessage = await reader.read();
|
||||
const groupSubscribeMessage = await reader.read();
|
||||
|
||||
expect(teamSubscribeMessage.value).toEqual({
|
||||
expect(groupSubscribeMessage.value).toEqual({
|
||||
action: "load",
|
||||
...team.teamMap.coValue.knownState(),
|
||||
...group.groupMap.coValue.knownState(),
|
||||
} satisfies SyncMessage);
|
||||
|
||||
const secondMessage = await reader.read();
|
||||
@@ -676,9 +676,9 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
@@ -704,7 +704,7 @@ test("When receiving a subscribe with a known state that is ahead of our own, pe
|
||||
const reader = outRx.getReader();
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admStateEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamStateEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupStateEx(group));
|
||||
const mapTellKnownState = await reader.read();
|
||||
|
||||
expect(mapTellKnownState.value).toEqual({
|
||||
@@ -719,7 +719,7 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
|
||||
const node1 = new LocalNode(admin, session);
|
||||
|
||||
const team = node1.createTeam();
|
||||
const group = node1.createGroup();
|
||||
|
||||
const [inRx1, inTx1] = newStreamPair<SyncMessage>();
|
||||
const [outRx1, outTx1] = newStreamPair<SyncMessage>();
|
||||
@@ -754,40 +754,40 @@ test.skip("When replaying creation and transactions of a coValue as new content,
|
||||
action: "load",
|
||||
id: admin.id,
|
||||
});
|
||||
const teamSubscribeMsg = await from1.read();
|
||||
expect(teamSubscribeMsg.value).toMatchObject({
|
||||
const groupSubscribeMsg = await from1.read();
|
||||
expect(groupSubscribeMsg.value).toMatchObject({
|
||||
action: "load",
|
||||
id: team.teamMap.coValue.id,
|
||||
id: group.groupMap.coValue.id,
|
||||
});
|
||||
|
||||
await to2.write(adminSubscribeMessage.value!);
|
||||
await to2.write(teamSubscribeMsg.value!);
|
||||
await to2.write(groupSubscribeMsg.value!);
|
||||
|
||||
// const adminTellKnownStateMsg = await from2.read();
|
||||
// expect(adminTellKnownStateMsg.value).toMatchObject(admStateEx(admin.id));
|
||||
|
||||
const teamTellKnownStateMsg = await from2.read();
|
||||
expect(teamTellKnownStateMsg.value).toMatchObject(teamStateEx(team));
|
||||
const groupTellKnownStateMsg = await from2.read();
|
||||
expect(groupTellKnownStateMsg.value).toMatchObject(groupStateEx(group));
|
||||
|
||||
expect(
|
||||
node2.sync.peers["test1"]!.optimisticKnownStates[
|
||||
team.teamMap.coValue.id
|
||||
group.groupMap.coValue.id
|
||||
]
|
||||
).toBeDefined();
|
||||
|
||||
// await to1.write(adminTellKnownStateMsg.value!);
|
||||
await to1.write(teamTellKnownStateMsg.value!);
|
||||
await to1.write(groupTellKnownStateMsg.value!);
|
||||
|
||||
// const adminContentMsg = await from1.read();
|
||||
// expect(adminContentMsg.value).toMatchObject(admContEx(admin.id));
|
||||
|
||||
const teamContentMsg = await from1.read();
|
||||
expect(teamContentMsg.value).toMatchObject(teamContentEx(team));
|
||||
const groupContentMsg = await from1.read();
|
||||
expect(groupContentMsg.value).toMatchObject(groupContentEx(group));
|
||||
|
||||
// await to2.write(adminContentMsg.value!);
|
||||
await to2.write(teamContentMsg.value!);
|
||||
await to2.write(groupContentMsg.value!);
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
const mapSubscriptionMsg = await from1.read();
|
||||
expect(mapSubscriptionMsg.value).toMatchObject({
|
||||
@@ -840,9 +840,9 @@ test.skip("When loading a coValue on one node, the server node it is requested f
|
||||
|
||||
const node1 = new LocalNode(admin, session);
|
||||
|
||||
const team = node1.createTeam();
|
||||
const group = node1.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
map.edit((editable) => {
|
||||
editable.set("hello", "world", "trusting");
|
||||
});
|
||||
@@ -868,9 +868,9 @@ test("Can sync a coValue through a server to another client", async () => {
|
||||
|
||||
const client1 = new LocalNode(admin, session);
|
||||
|
||||
const team = client1.createTeam();
|
||||
const group = client1.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
map.edit((editable) => {
|
||||
editable.set("hello", "world", "trusting");
|
||||
});
|
||||
@@ -910,9 +910,9 @@ test("Can sync a coValue with private transactions through a server to another c
|
||||
|
||||
const client1 = new LocalNode(admin, session);
|
||||
|
||||
const team = client1.createTeam();
|
||||
const group = client1.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
map.edit((editable) => {
|
||||
editable.set("hello", "world", "private");
|
||||
});
|
||||
@@ -952,7 +952,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
@@ -971,10 +971,10 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: team.teamMap.coValue.id,
|
||||
id: group.groupMap.coValue.id,
|
||||
});
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
const mapSubscribeMsg = await reader.read();
|
||||
|
||||
@@ -984,7 +984,7 @@ test("When a peer's incoming/readable stream closes, we remove the peer", async
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
|
||||
|
||||
const mapContentMsg = await reader.read();
|
||||
|
||||
@@ -1006,7 +1006,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
|
||||
const [admin, session] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(admin, session);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
const [inRx, inTx] = newStreamPair<SyncMessage>();
|
||||
const [outRx, outTx] = newStreamPair<SyncMessage>();
|
||||
@@ -1025,10 +1025,10 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
|
||||
// });
|
||||
expect((await reader.read()).value).toMatchObject({
|
||||
action: "load",
|
||||
id: team.teamMap.coValue.id,
|
||||
id: group.groupMap.coValue.id,
|
||||
});
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
const mapSubscribeMsg = await reader.read();
|
||||
|
||||
@@ -1038,7 +1038,7 @@ test("When a peer's outgoing/writable stream closes, we remove the peer", async
|
||||
} satisfies SyncMessage);
|
||||
|
||||
// expect((await reader.read()).value).toMatchObject(admContEx(admin.id));
|
||||
expect((await reader.read()).value).toMatchObject(teamContentEx(team));
|
||||
expect((await reader.read()).value).toMatchObject(groupContentEx(group));
|
||||
|
||||
const mapContentMsg = await reader.read();
|
||||
|
||||
@@ -1066,9 +1066,9 @@ test("If we start loading a coValue before connecting to a peer that has it, it
|
||||
|
||||
const node1 = new LocalNode(admin, session);
|
||||
|
||||
const team = node1.createTeam();
|
||||
const group = node1.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
map.edit((editable) => {
|
||||
editable.set("hello", "world", "trusting");
|
||||
});
|
||||
@@ -1096,10 +1096,10 @@ test("If we start loading a coValue before connecting to a peer that has it, it
|
||||
);
|
||||
});
|
||||
|
||||
function teamContentEx(team: Team) {
|
||||
function groupContentEx(group: Group) {
|
||||
return {
|
||||
action: "content",
|
||||
id: team.teamMap.coValue.id,
|
||||
id: group.groupMap.coValue.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1110,10 +1110,10 @@ function admContEx(adminID: AccountID) {
|
||||
};
|
||||
}
|
||||
|
||||
function teamStateEx(team: Team) {
|
||||
function groupStateEx(group: Group) {
|
||||
return {
|
||||
action: "known",
|
||||
id: team.teamMap.coValue.id,
|
||||
id: group.groupMap.coValue.id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "./crypto.js";
|
||||
import { newRandomSessionID } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { expectTeamContent } from "./permissions.js";
|
||||
import { expectGroupContent } from "./group.js";
|
||||
import { AnonymousControlledAccount } from "./account.js";
|
||||
import { SessionID } from "./ids.js";
|
||||
|
||||
@@ -13,69 +13,69 @@ export function randomAnonymousAccountAndSessionID(): [AnonymousControlledAccoun
|
||||
return [new AnonymousControlledAccount(agentSecret), sessionID];
|
||||
}
|
||||
|
||||
export function newTeam() {
|
||||
export function newGroup() {
|
||||
const [admin, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
|
||||
const node = new LocalNode(admin, sessionID);
|
||||
|
||||
const team = node.createCoValue({
|
||||
const group = node.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: { type: "team", initialAdmin: admin.id },
|
||||
ruleset: { type: "group", initialAdmin: admin.id },
|
||||
meta: null,
|
||||
...createdNowUnique(),
|
||||
});
|
||||
|
||||
const teamContent = expectTeamContent(team.getCurrentContent());
|
||||
const groupContent = expectGroupContent(group.getCurrentContent());
|
||||
|
||||
teamContent.edit((editable) => {
|
||||
groupContent.edit((editable) => {
|
||||
editable.set(admin.id, "admin", "trusting");
|
||||
expect(editable.get(admin.id)).toEqual("admin");
|
||||
});
|
||||
|
||||
return { node, team, admin };
|
||||
return { node, group, admin };
|
||||
}
|
||||
|
||||
export function teamWithTwoAdmins() {
|
||||
const { team, admin, node } = newTeam();
|
||||
export function groupWithTwoAdmins() {
|
||||
const { group, admin, node } = newGroup();
|
||||
|
||||
const otherAdmin = node.createAccount("otherAdmin");
|
||||
|
||||
let content = expectTeamContent(team.getCurrentContent());
|
||||
let content = expectGroupContent(group.getCurrentContent());
|
||||
|
||||
content.edit((editable) => {
|
||||
editable.set(otherAdmin.id, "admin", "trusting");
|
||||
expect(editable.get(otherAdmin.id)).toEqual("admin");
|
||||
});
|
||||
|
||||
content = expectTeamContent(team.getCurrentContent());
|
||||
content = expectGroupContent(group.getCurrentContent());
|
||||
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
expect(content.get(otherAdmin.id)).toEqual("admin");
|
||||
return { team, admin, otherAdmin, node };
|
||||
return { group, admin, otherAdmin, node };
|
||||
}
|
||||
|
||||
export function newTeamHighLevel() {
|
||||
export function newGroupHighLevel() {
|
||||
const [admin, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
|
||||
|
||||
const node = new LocalNode(admin, sessionID);
|
||||
|
||||
const team = node.createTeam();
|
||||
const group = node.createGroup();
|
||||
|
||||
return { admin, node, team };
|
||||
return { admin, node, group };
|
||||
}
|
||||
|
||||
export function teamWithTwoAdminsHighLevel() {
|
||||
const { admin, node, team } = newTeamHighLevel();
|
||||
export function groupWithTwoAdminsHighLevel() {
|
||||
const { admin, node, group } = newGroupHighLevel();
|
||||
|
||||
const otherAdmin = node.createAccount("otherAdmin");
|
||||
|
||||
team.addMember(otherAdmin.id, "admin");
|
||||
group.addMember(otherAdmin.id, "admin");
|
||||
|
||||
return { admin, node, team, otherAdmin };
|
||||
return { admin, node, group, otherAdmin };
|
||||
}
|
||||
|
||||
export function shouldNotResolve<T>(
|
||||
|
||||
@@ -12,4 +12,5 @@
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["./src/**/*.test.*"],
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-browser-auth-local",
|
||||
"version": "0.0.3",
|
||||
"version": "0.1.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser": "^0.0.3",
|
||||
"jazz-browser": "^0.1.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-browser",
|
||||
"version": "0.0.3",
|
||||
"version": "0.1.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.0.20",
|
||||
"jazz-storage-indexeddb": "^0.0.7",
|
||||
"cojson": "^0.1.1",
|
||||
"jazz-storage-indexeddb": "^0.1.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { InviteSecret } from "cojson";
|
||||
import {
|
||||
LocalNode,
|
||||
cojsonInternals,
|
||||
CojsonInternalTypes,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
Peer,
|
||||
ContentType,
|
||||
Group,
|
||||
CoID,
|
||||
} from "cojson";
|
||||
import { Peer } from "cojson/src/sync";
|
||||
import { ReadableStream, WritableStream } from "isomorphic-streams";
|
||||
import { IDBStorage } from "jazz-storage-indexeddb";
|
||||
|
||||
@@ -74,7 +78,9 @@ export interface AuthProvider {
|
||||
): Promise<LocalNode>;
|
||||
}
|
||||
|
||||
export type SessionProvider = (accountID: CojsonInternalTypes.AccountIDOrAgentID) => Promise<SessionID>;
|
||||
export type SessionProvider = (
|
||||
accountID: CojsonInternalTypes.AccountIDOrAgentID
|
||||
) => Promise<SessionID>;
|
||||
|
||||
export type SessionHandle = {
|
||||
session: Promise<SessionID>;
|
||||
@@ -98,7 +104,7 @@ function getSessionHandleFor(
|
||||
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", accountID + "_" + idx);
|
||||
console.debug("Trying to get lock", accountID + "_" + idx);
|
||||
const sessionFinishedOrNoLock = await navigator.locks.request(
|
||||
accountID + "_" + idx,
|
||||
{ ifAvailable: true },
|
||||
@@ -110,7 +116,7 @@ function getSessionHandleFor(
|
||||
cojsonInternals.newRandomSessionID(accountID);
|
||||
localStorage[accountID + "_" + idx] = sessionID;
|
||||
|
||||
console.log(
|
||||
console.debug(
|
||||
"Got lock",
|
||||
accountID + "_" + idx,
|
||||
sessionID
|
||||
@@ -228,3 +234,80 @@ function websocketWritableStream<T>(ws: WebSocket) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createInviteLink(
|
||||
value: ContentType,
|
||||
role: "reader" | "writer" | "admin",
|
||||
// default to same address as window.location, but without hash
|
||||
{
|
||||
baseURL = window.location.href.replace(/#.*$/, ""),
|
||||
}: { baseURL?: string } = {}
|
||||
): string {
|
||||
const coValue = value.coValue;
|
||||
const node = coValue.node;
|
||||
let currentCoValue = coValue;
|
||||
|
||||
while (currentCoValue.header.ruleset.type === "ownedByGroup") {
|
||||
currentCoValue = node.expectCoValueLoaded(
|
||||
currentCoValue.header.ruleset.group
|
||||
);
|
||||
}
|
||||
|
||||
if (currentCoValue.header.ruleset.type !== "group") {
|
||||
throw new Error("Can't create invite link for object without group");
|
||||
}
|
||||
|
||||
const group = new Group(
|
||||
cojsonInternals.expectGroupContent(currentCoValue.getCurrentContent()),
|
||||
node
|
||||
);
|
||||
|
||||
const inviteSecret = group.createInvite(role);
|
||||
|
||||
return `${baseURL}#invitedTo=${value.id}&${inviteSecret}`;
|
||||
}
|
||||
|
||||
export function parseInviteLink(inviteURL: string):
|
||||
| {
|
||||
valueID: CoID<ContentType>;
|
||||
inviteSecret: InviteSecret;
|
||||
}
|
||||
| undefined {
|
||||
const url = new URL(inviteURL);
|
||||
const valueID = url.hash
|
||||
.split("&")[0]
|
||||
?.replace(/^#invitedTo=/, "") as CoID<ContentType>;
|
||||
const inviteSecret = url.hash
|
||||
.split("&")[1] as InviteSecret;
|
||||
if (!valueID || !inviteSecret) {
|
||||
return undefined;
|
||||
}
|
||||
return { valueID, inviteSecret };
|
||||
}
|
||||
|
||||
export function consumeInviteLinkFromWindowLocation(node: LocalNode): Promise<
|
||||
| {
|
||||
valueID: string;
|
||||
inviteSecret: string;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const result = parseInviteLink(window.location.href);
|
||||
|
||||
if (result) {
|
||||
node.acceptInvite(result.valueID, result.inviteSecret)
|
||||
.then(() => {
|
||||
resolve(result);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
window.location.href.replace(/#.*$/, "")
|
||||
);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-react-auth-local",
|
||||
"version": "0.0.10",
|
||||
"version": "0.1.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser-auth-local": "^0.0.3",
|
||||
"jazz-react": "^0.0.13",
|
||||
"jazz-browser-auth-local": "^0.1.1",
|
||||
"jazz-react": "^0.1.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-react",
|
||||
"version": "0.0.13",
|
||||
"version": "0.1.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.0.20",
|
||||
"jazz-browser": "^0.0.3",
|
||||
"cojson": "^0.1.1",
|
||||
"jazz-browser": "^0.1.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
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;
|
||||
@@ -100,7 +106,6 @@ export function useTelepathicState<T extends ContentType>(id?: CoID<T>) {
|
||||
// "Got update",
|
||||
// id,
|
||||
// newState.toJSON(),
|
||||
// newState.coValue.sessions
|
||||
// );
|
||||
setState(newState as T);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-storage-indexeddb",
|
||||
"version": "0.0.7",
|
||||
"version": "0.1.1",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.0.20",
|
||||
"cojson": "^0.1.1",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
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 { AnonymousControlledAccount, LocalNode, cojsonInternals } from "cojson";
|
||||
import { IDBStorage } from ".";
|
||||
|
||||
test.skip("Should be able to initialize and load from empty DB", async () => {
|
||||
const agentSecret = newRandomAgentSecret();
|
||||
const agentSecret = cojsonInternals.newRandomAgentSecret();
|
||||
|
||||
const node = new LocalNode(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
newRandomSessionID(getAgentID(agentSecret))
|
||||
cojsonInternals.newRandomSessionID(
|
||||
cojsonInternals.getAgentID(agentSecret)
|
||||
)
|
||||
);
|
||||
|
||||
node.sync.addPeer(await IDBStorage.asPeer({ trace: true }));
|
||||
|
||||
console.log("yay!");
|
||||
|
||||
const _team = node.createTeam();
|
||||
const _group = node.createGroup();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
@@ -25,20 +24,24 @@ test.skip("Should be able to initialize and load from empty DB", async () => {
|
||||
});
|
||||
|
||||
test("Should be able to sync data to database and then load that from a new node", async () => {
|
||||
const agentSecret = newRandomAgentSecret();
|
||||
const agentSecret = cojsonInternals.newRandomAgentSecret();
|
||||
|
||||
const node1 = new LocalNode(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
newRandomSessionID(getAgentID(agentSecret))
|
||||
cojsonInternals.newRandomSessionID(
|
||||
cojsonInternals.getAgentID(agentSecret)
|
||||
)
|
||||
);
|
||||
|
||||
node1.sync.addPeer(await IDBStorage.asPeer({ trace: true, localNodeName: "node1" }));
|
||||
node1.sync.addPeer(
|
||||
await IDBStorage.asPeer({ trace: true, localNodeName: "node1" })
|
||||
);
|
||||
|
||||
console.log("yay!");
|
||||
|
||||
const team = node1.createTeam();
|
||||
const group = node1.createGroup();
|
||||
|
||||
const map = team.createMap();
|
||||
const map = group.createMap();
|
||||
|
||||
map.edit((m) => {
|
||||
m.set("hello", "world");
|
||||
@@ -48,10 +51,14 @@ test("Should be able to sync data to database and then load that from a new node
|
||||
|
||||
const node2 = new LocalNode(
|
||||
new AnonymousControlledAccount(agentSecret),
|
||||
newRandomSessionID(getAgentID(agentSecret))
|
||||
cojsonInternals.newRandomSessionID(
|
||||
cojsonInternals.getAgentID(agentSecret)
|
||||
)
|
||||
);
|
||||
|
||||
node2.sync.addPeer(await IDBStorage.asPeer({ trace: true, localNodeName: "node2" }));
|
||||
node2.sync.addPeer(
|
||||
await IDBStorage.asPeer({ trace: true, localNodeName: "node2" })
|
||||
);
|
||||
|
||||
const map2 = await node2.load(map.id);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cojsonInternals, SessionID, SyncMessage, Peer } from "cojson";
|
||||
import { CojsonInternalTypes } from "cojson";
|
||||
import { cojsonInternals, SessionID, SyncMessage, Peer, CojsonInternalTypes } from "cojson";
|
||||
import {
|
||||
ReadableStream,
|
||||
WritableStream,
|
||||
@@ -206,7 +205,7 @@ export class IDBStorage {
|
||||
}
|
||||
|
||||
const dependedOnCoValues =
|
||||
coValueRow?.header.ruleset.type === "team"
|
||||
coValueRow?.header.ruleset.type === "group"
|
||||
? Object.values(newContent.new).flatMap((sessionEntry) =>
|
||||
sessionEntry.newTransactions.flatMap((tx) => {
|
||||
if (tx.privacy !== "trusting") return [];
|
||||
@@ -227,8 +226,8 @@ export class IDBStorage {
|
||||
);
|
||||
})
|
||||
)
|
||||
: coValueRow?.header.ruleset.type === "ownedByTeam"
|
||||
? [coValueRow?.header.ruleset.team]
|
||||
: coValueRow?.header.ruleset.type === "ownedByGroup"
|
||||
? [coValueRow?.header.ruleset.group]
|
||||
: [];
|
||||
|
||||
for (const dependedOnCoValue of dependedOnCoValues) {
|
||||
@@ -390,7 +389,7 @@ export class IDBStorage {
|
||||
);
|
||||
|
||||
tx.onerror = (event) => {
|
||||
const target = event.target as {
|
||||
const target = event.target as unknown as {
|
||||
error: DOMException;
|
||||
source?: { name: string };
|
||||
} | null;
|
||||
|
||||
75
yarn.lock
75
yarn.lock
@@ -1175,6 +1175,17 @@
|
||||
"@radix-ui/react-use-previous" "1.0.1"
|
||||
"@radix-ui/react-use-size" "1.0.1"
|
||||
|
||||
"@radix-ui/react-collection@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"
|
||||
integrity sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-context" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-slot" "1.0.2"
|
||||
|
||||
"@radix-ui/react-compose-refs@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
|
||||
@@ -1189,6 +1200,26 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-dismissable-layer@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz#883a48f5f938fa679427aa17fcba70c5494c6978"
|
||||
integrity sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "1.0.1"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
"@radix-ui/react-use-escape-keydown" "1.0.3"
|
||||
|
||||
"@radix-ui/react-portal@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.3.tgz#ffb961244c8ed1b46f039e6c215a6c4d9989bda1"
|
||||
integrity sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
|
||||
"@radix-ui/react-presence@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba"
|
||||
@@ -1214,6 +1245,25 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
|
||||
"@radix-ui/react-toast@^1.1.4":
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.1.4.tgz#9a7fc2d71700886f3292f7699c905f1e01be59e1"
|
||||
integrity sha512-wf+fc8DOywrpRK3jlPlWVe+ELYGHdKDaaARJZNuUTWyWYq7+ANCFLp4rTjZ/mcGkJJQ/vZ949Zis9xxEpfq9OA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "1.0.1"
|
||||
"@radix-ui/react-collection" "1.0.3"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-context" "1.0.1"
|
||||
"@radix-ui/react-dismissable-layer" "1.0.4"
|
||||
"@radix-ui/react-portal" "1.0.3"
|
||||
"@radix-ui/react-presence" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||
"@radix-ui/react-use-layout-effect" "1.0.1"
|
||||
"@radix-ui/react-visually-hidden" "1.0.3"
|
||||
|
||||
"@radix-ui/react-use-callback-ref@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
|
||||
@@ -1229,6 +1279,14 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
|
||||
"@radix-ui/react-use-escape-keydown@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755"
|
||||
integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
|
||||
"@radix-ui/react-use-layout-effect@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399"
|
||||
@@ -1251,6 +1309,14 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-layout-effect" "1.0.1"
|
||||
|
||||
"@radix-ui/react-visually-hidden@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz#51aed9dd0fe5abcad7dee2a234ad36106a6984ac"
|
||||
integrity sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
|
||||
"@rollup/pluginutils@^3.1.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
|
||||
@@ -1574,7 +1640,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/which/-/which-2.0.2.tgz#54541d02d6b1daee5ec01ac0d1b37cecf37db1ae"
|
||||
integrity sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==
|
||||
|
||||
"@types/ws@^8.5.3":
|
||||
"@types/ws@^8.5.3", "@types/ws@^8.5.5":
|
||||
version "8.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.5.tgz#af587964aa06682702ee6dcbc7be41a80e4b28eb"
|
||||
integrity sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==
|
||||
@@ -7678,6 +7744,11 @@ unbzip2-stream@1.4.3:
|
||||
buffer "^5.2.1"
|
||||
through "^2.3.8"
|
||||
|
||||
uniqolor@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/uniqolor/-/uniqolor-1.1.0.tgz#7519f81133cd54a1f4a59c33c81dbe04a3ad155d"
|
||||
integrity sha512-j2XyokF24fsj+L5u6fbu4rM3RQc6VWJuAngYM2k0ZdG3yiVxt0smLkps2GmQIYqK8VkELGdM9vFU/HfOkK/zoQ==
|
||||
|
||||
unique-filename@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea"
|
||||
@@ -8043,7 +8114,7 @@ write-pkg@4.0.0:
|
||||
type-fest "^0.4.1"
|
||||
write-json-file "^3.2.0"
|
||||
|
||||
ws@8.13.0, ws@^8.8.0:
|
||||
ws@8.13.0, ws@^8.13.0, ws@^8.8.0:
|
||||
version "8.13.0"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0"
|
||||
integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==
|
||||
|
||||
Reference in New Issue
Block a user