Compare commits
13 Commits
cojson@0.0
...
cojson@0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6557532743 | ||
|
|
ea6835664b | ||
|
|
a00332f4af | ||
|
|
6a6fb2eb3c | ||
|
|
0437223d50 | ||
|
|
30c7e1bf6d | ||
|
|
aaa9d876d5 | ||
|
|
264009a1a9 | ||
|
|
f2cb5d1b59 | ||
|
|
8610db2d8e | ||
|
|
c672a03338 | ||
|
|
cb60088d2a | ||
|
|
a59d5d3b70 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-example-todo",
|
||||
"private": true,
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.10",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,10 +12,11 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-react": "^0.0.13",
|
||||
"jazz-react-auth-local": "^0.0.10",
|
||||
"jazz-react": "^0.0.16",
|
||||
"jazz-react-auth-local": "^0.0.13",
|
||||
"lucide-react": "^0.265.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -10,8 +10,16 @@ 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";
|
||||
|
||||
type TaskContent = { done: boolean; text: string };
|
||||
type Task = CoMap<TaskContent>;
|
||||
@@ -19,41 +27,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 listTeam = localNode.createTeam();
|
||||
const list = listTeam.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 md:pt-[30vh] pb-10 px-5">
|
||||
{listId ? (
|
||||
<TodoList listId={listId} />
|
||||
) : (
|
||||
@@ -68,6 +87,7 @@ function App() {
|
||||
window.location.hash = "";
|
||||
logOut();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Log Out
|
||||
</Button>
|
||||
@@ -96,11 +116,41 @@ export function TodoList({ listId }: { listId: CoID<TodoList> }) {
|
||||
console.log("Updated list", listAfter.toJSON());
|
||||
};
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<div className="max-w-full w-4xl">
|
||||
<h1>
|
||||
{list?.get("title")} ({list?.id})
|
||||
</h1>
|
||||
<div className="flex justify-between items-center gap-4 mb-4">
|
||||
<h1>
|
||||
{list?.get("title") ? (
|
||||
<>
|
||||
{list.get("title")}{" "}
|
||||
<span className="text-sm">({list.id})</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton className="mt-1 w-[200px] h-[1em] rounded-full" />
|
||||
)}
|
||||
</h1>
|
||||
{list && list.coValue.getTeam().myRole() === "admin" && <Button
|
||||
size="sm"
|
||||
className="py-0"
|
||||
disabled={!list}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (list) {
|
||||
const inviteLink = createInviteLink(list, "writer");
|
||||
navigator.clipboard.writeText(inviteLink).then(() =>
|
||||
toast({
|
||||
description:
|
||||
"Copied invite link to clipboard!",
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Invite
|
||||
</Button>}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -127,6 +177,7 @@ export function TodoList({ listId }: { listId: CoID<TodoList> }) {
|
||||
onSubmit={(taskText) => createTask(taskText)}
|
||||
label="Add"
|
||||
placeholder="New task"
|
||||
disabled={!list}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -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,6 +6,7 @@ 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>
|
||||
@@ -21,6 +22,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;
|
||||
@@ -5,7 +5,7 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.23",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "./crypto.js";
|
||||
import { AgentID } from "./ids.js";
|
||||
import { CoMap, LocalNode } from "./index.js";
|
||||
import { Team, TeamContent } from "./permissions.js";
|
||||
import { Team, TeamContent } from "./team.js";
|
||||
|
||||
export function accountHeaderForInitialAgentSecret(
|
||||
agentSecret: AgentSecret
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Transaction } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
|
||||
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
|
||||
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { AccountID } from "./index.js";
|
||||
import { Role } from "./permissions.js";
|
||||
|
||||
test("Can create coValue with new agent credentials and add transaction to it", () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
@@ -121,3 +124,58 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("New transactions in a team correctly update owned values, including subscriptions", async () => {
|
||||
const [account, sessionID] = randomAnonymousAccountAndSessionID();
|
||||
const node = new LocalNode(account, sessionID);
|
||||
|
||||
const team = node.createTeam();
|
||||
|
||||
const timeBeforeEdit = Date.now();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
let map = team.createMap();
|
||||
|
||||
let mapAfterEdit = map.edit((map) => {
|
||||
map.set("hello", "world");
|
||||
});
|
||||
|
||||
const listener = jest.fn().mockImplementation();
|
||||
|
||||
map.subscribe(listener);
|
||||
|
||||
expect(listener.mock.calls[0][0].get("hello")).toBe("world");
|
||||
|
||||
const resignationThatWeJustLearnedAbout = {
|
||||
privacy: "trusting",
|
||||
madeAt: timeBeforeEdit,
|
||||
changes: [
|
||||
{
|
||||
op: "set",
|
||||
key: account.id,
|
||||
value: "revoked"
|
||||
} satisfies MapOpPayload<typeof account.id, Role>
|
||||
]
|
||||
} satisfies Transaction;
|
||||
|
||||
const { expectedNewHash } = team.teamMap.coValue.expectedNewHashAfter(sessionID, [
|
||||
resignationThatWeJustLearnedAbout,
|
||||
]);
|
||||
|
||||
const signature = sign(
|
||||
node.account.currentSignerSecret(),
|
||||
expectedNewHash
|
||||
);
|
||||
|
||||
expect(map.coValue.getValidSortedTransactions().length).toBe(1);
|
||||
|
||||
const manuallyAdddedTxSuccess = team.teamMap.coValue.tryAddTransactions(node.ownSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
|
||||
|
||||
expect(manuallyAdddedTxSuccess).toBe(true);
|
||||
|
||||
expect(listener.mock.calls.length).toBe(2);
|
||||
expect(listener.mock.calls[1][0].get("hello")).toBe(undefined);
|
||||
|
||||
expect(map.coValue.getValidSortedTransactions().length).toBe(0);
|
||||
});
|
||||
|
||||
@@ -24,11 +24,10 @@ import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { base58 } from "@scure/base";
|
||||
import {
|
||||
PermissionsDef as RulesetDef,
|
||||
Team,
|
||||
determineValidTransactions,
|
||||
expectTeamContent,
|
||||
isKeyForKeyField,
|
||||
} from "./permissions.js";
|
||||
import { Team, expectTeamContent } from "./team.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { CoValueKnownState, NewContentMessage } from "./sync.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
@@ -97,15 +96,31 @@ export class CoValue {
|
||||
id: RawCoID;
|
||||
node: LocalNode;
|
||||
header: CoValueHeader;
|
||||
sessions: { [key: SessionID]: SessionLog };
|
||||
content?: ContentType;
|
||||
_sessions: { [key: SessionID]: SessionLog };
|
||||
_cachedContent?: ContentType;
|
||||
listeners: Set<(content?: ContentType) => void> = new Set();
|
||||
|
||||
constructor(header: CoValueHeader, node: LocalNode) {
|
||||
constructor(header: CoValueHeader, node: LocalNode, internalInitSessions: { [key: SessionID]: SessionLog } = {}) {
|
||||
this.id = idforHeader(header);
|
||||
this.header = header;
|
||||
this.sessions = {};
|
||||
this._sessions = internalInitSessions;
|
||||
this.node = node;
|
||||
|
||||
if (header.ruleset.type == "ownedByTeam") {
|
||||
this.node
|
||||
.expectCoValueLoaded(header.ruleset.team)
|
||||
.subscribe((_teamUpdate) => {
|
||||
this._cachedContent = undefined;
|
||||
const newContent = this.getCurrentContent();
|
||||
for (const listener of this.listeners) {
|
||||
listener(newContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get sessions(): Readonly<{ [key: SessionID]: SessionLog }> {
|
||||
return this._sessions;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
@@ -172,7 +187,10 @@ export class CoValue {
|
||||
);
|
||||
|
||||
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
|
||||
console.warn("Invalid hash", { expectedNewHash, givenExpectedNewHash });
|
||||
console.warn("Invalid hash", {
|
||||
expectedNewHash,
|
||||
givenExpectedNewHash,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -190,14 +208,14 @@ export class CoValue {
|
||||
|
||||
transactions.push(...newTransactions);
|
||||
|
||||
this.sessions[sessionID] = {
|
||||
this._sessions[sessionID] = {
|
||||
transactions,
|
||||
lastHash: expectedNewHash,
|
||||
streamingHash: newStreamingHash,
|
||||
lastSignature: newSignature,
|
||||
};
|
||||
|
||||
this.content = undefined;
|
||||
this._cachedContent = undefined;
|
||||
|
||||
const content = this.getCurrentContent();
|
||||
|
||||
@@ -296,23 +314,23 @@ export class CoValue {
|
||||
}
|
||||
|
||||
getCurrentContent(): ContentType {
|
||||
if (this.content) {
|
||||
return this.content;
|
||||
if (this._cachedContent) {
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
if (this.header.type === "comap") {
|
||||
this.content = new CoMap(this);
|
||||
this._cachedContent = new CoMap(this);
|
||||
} else if (this.header.type === "colist") {
|
||||
this.content = new CoList(this);
|
||||
this._cachedContent = new CoList(this);
|
||||
} else if (this.header.type === "costream") {
|
||||
this.content = new CoStream(this);
|
||||
this._cachedContent = new CoStream(this);
|
||||
} else if (this.header.type === "static") {
|
||||
this.content = new Static(this);
|
||||
this._cachedContent = new Static(this);
|
||||
} else {
|
||||
throw new Error(`Unknown coValue type ${this.header.type}`);
|
||||
}
|
||||
|
||||
return this.content;
|
||||
return this._cachedContent;
|
||||
}
|
||||
|
||||
getValidSortedTransactions(): DecryptedTransaction[] {
|
||||
@@ -399,7 +417,9 @@ export class CoValue {
|
||||
|
||||
// Try to find key revelation for us
|
||||
|
||||
const readKeyEntry = content.getLastEntry(`${keyID}_for_${this.node.account.id}`);
|
||||
const readKeyEntry = content.getLastEntry(
|
||||
`${keyID}_for_${this.node.account.id}`
|
||||
);
|
||||
|
||||
if (readKeyEntry) {
|
||||
const revealer = accountOrAgentIDfromSessionID(
|
||||
@@ -428,7 +448,8 @@ export class CoValue {
|
||||
for (const field of content.keys()) {
|
||||
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
|
||||
const encryptingKeyID = field.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
|
||||
const encryptingKeySecret =
|
||||
this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
@@ -453,7 +474,6 @@ export class CoValue {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -525,10 +545,7 @@ export class CoValue {
|
||||
),
|
||||
};
|
||||
|
||||
if (
|
||||
!newContent.header &&
|
||||
Object.keys(newContent.new).length === 0
|
||||
) {
|
||||
if (!newContent.header && Object.keys(newContent.new).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -544,4 +561,4 @@ export class CoValue {
|
||||
? [this.header.ruleset.team]
|
||||
: [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ type MapOp<K extends string, V extends JsonValue> = {
|
||||
// TODO: add after TransactionID[] for conflicts/ordering
|
||||
|
||||
export type MapOpPayload<K extends string, V extends JsonValue> = {
|
||||
op: "insert";
|
||||
op: "set";
|
||||
key: K;
|
||||
value: V;
|
||||
} |
|
||||
{
|
||||
op: "delete";
|
||||
op: "del";
|
||||
key: K;
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ export class CoMap<
|
||||
|
||||
const lastEntry = ops[ops.length - 1]!;
|
||||
|
||||
if (lastEntry.op === "delete") {
|
||||
if (lastEntry.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return lastEntry.value;
|
||||
@@ -100,7 +100,7 @@ export class CoMap<
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (lastOpBeforeOrAtTime.op === "delete") {
|
||||
if (lastOpBeforeOrAtTime.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return lastOpBeforeOrAtTime.value;
|
||||
@@ -139,7 +139,7 @@ export class CoMap<
|
||||
|
||||
const lastEntry = ops[ops.length - 1]!;
|
||||
|
||||
if (lastEntry.op === "delete") {
|
||||
if (lastEntry.op === "del") {
|
||||
return undefined;
|
||||
} else {
|
||||
return { at: lastEntry.madeAt, txID: lastEntry.txID, value: lastEntry.value };
|
||||
@@ -155,7 +155,7 @@ export class CoMap<
|
||||
const history: { at: number; txID: TransactionID; value: M[K] | undefined; }[] = [];
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.op === "delete") {
|
||||
if (op.op === "del") {
|
||||
history.push({ at: op.madeAt, txID: op.txID, value: undefined });
|
||||
} else {
|
||||
history.push({ at: op.madeAt, txID: op.txID, value: op.value });
|
||||
@@ -199,7 +199,7 @@ export class WriteableCoMap<
|
||||
set<K extends MapK<M>>(key: K, value: M[K], privacy: "private" | "trusting" = "private"): void {
|
||||
this.coValue.makeTransaction([
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key,
|
||||
value,
|
||||
},
|
||||
@@ -211,7 +211,7 @@ export class WriteableCoMap<
|
||||
delete(key: MapK<M>, privacy: "private" | "trusting" = "private"): void {
|
||||
this.coValue.makeTransaction([
|
||||
{
|
||||
op: "delete",
|
||||
op: "del",
|
||||
key,
|
||||
},
|
||||
], privacy);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { x25519 } from "@noble/curves/ed25519";
|
||||
import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
|
||||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import stableStringify from "fast-json-stable-stringify";
|
||||
import { SessionID } from './ids.js';
|
||||
|
||||
test("Signatures round-trip and use stable stringify", () => {
|
||||
const data = { b: "world", a: "hello" };
|
||||
@@ -49,7 +50,7 @@ test("encrypting round-trips, but invalid receiver can't unseal", () => {
|
||||
|
||||
const nOnceMaterial = {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
} as const;
|
||||
|
||||
const sealed = seal(
|
||||
@@ -101,22 +102,22 @@ test("Encryption for transactions round-trips", () => {
|
||||
|
||||
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
const decrypted1 = decryptForTransaction(encrypted1, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
expect([decrypted1, decrypted2]).toEqual([{ a: "hello" }, { b: "world" }]);
|
||||
@@ -128,22 +129,22 @@ test("Encryption for transactions doesn't decrypt with a wrong key", () => {
|
||||
|
||||
const encrypted1 = encryptForTransaction({ a: "hello" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const encrypted2 = encryptForTransaction({ b: "world" }, secret, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
const decrypted1 = decryptForTransaction(encrypted1, secret2, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 0 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
|
||||
});
|
||||
|
||||
const decrypted2 = decryptForTransaction(encrypted2, secret2, {
|
||||
in: "co_zTEST",
|
||||
tx: { sessionID: "co_zTEST_session_zTEST", txIndex: 1 },
|
||||
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 1 },
|
||||
});
|
||||
|
||||
expect([decrypted1, decrypted2]).toEqual([undefined, undefined]);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { connectedPeers } from "./streamUtils.js";
|
||||
import { AnonymousControlledAccount, ControlledAccount } from "./account.js";
|
||||
import { rawCoIDtoBytes, rawCoIDfromBytes } from "./ids.js";
|
||||
import { Team, expectTeamContent } from "./team.js"
|
||||
|
||||
import type { SessionID, 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 "./team.js";
|
||||
|
||||
type Value = JsonValue | ContentType;
|
||||
|
||||
@@ -42,6 +44,7 @@ export const cojsonInternals = {
|
||||
agentSecretFromSecretSeed,
|
||||
secretSeedLength,
|
||||
shortHashLength,
|
||||
expectTeamContent
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -50,6 +53,7 @@ export {
|
||||
CoMap,
|
||||
AnonymousControlledAccount,
|
||||
ControlledAccount,
|
||||
Team
|
||||
};
|
||||
|
||||
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,
|
||||
Team,
|
||||
TeamContent,
|
||||
expectTeamContent,
|
||||
secretSeedFromInviteSecret,
|
||||
} from "./team.js";
|
||||
import { Peer, SyncManager } from "./sync.js";
|
||||
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
@@ -43,7 +50,10 @@ export class LocalNode {
|
||||
this.ownSessionID = ownSessionID;
|
||||
}
|
||||
|
||||
static withNewlyCreatedAccount(name: string, 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,81 @@ export class LocalNode {
|
||||
if (!profileID) {
|
||||
throw new Error(`Account ${id} has no profile`);
|
||||
}
|
||||
return (await this.loadCoValue(profileID)).getCurrentContent() as Profile;
|
||||
return (
|
||||
await this.loadCoValue(profileID)
|
||||
).getCurrentContent() as Profile;
|
||||
}
|
||||
|
||||
async acceptInvite<T extends ContentType>(
|
||||
teamOrOwnedValueID: CoID<T>,
|
||||
inviteSecret: InviteSecret
|
||||
): Promise<void> {
|
||||
const teamOrOwnedValue = await this.load(teamOrOwnedValueID);
|
||||
|
||||
if (teamOrOwnedValue.coValue.header.ruleset.type === "ownedByTeam") {
|
||||
return this.acceptInvite(
|
||||
teamOrOwnedValue.coValue.header.ruleset.team as CoID<
|
||||
CoMap<TeamContent>
|
||||
>,
|
||||
inviteSecret
|
||||
);
|
||||
} else if (teamOrOwnedValue.coValue.header.ruleset.type !== "team") {
|
||||
throw new Error("Can only accept invites to teams");
|
||||
}
|
||||
|
||||
const team = new Team(expectTeamContent(teamOrOwnedValue), this);
|
||||
|
||||
const inviteAgentSecret = agentSecretFromSecretSeed(
|
||||
secretSeedFromInviteSecret(inviteSecret)
|
||||
);
|
||||
const inviteAgentID = getAgentID(inviteAgentSecret);
|
||||
|
||||
const invitationRole = await new Promise((resolve, reject) => {
|
||||
team.teamMap.subscribe((teamMap) => {
|
||||
const role = teamMap.get(inviteAgentID);
|
||||
if (role) {
|
||||
resolve(role);
|
||||
}
|
||||
});
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error("Couldn't find invitation before timeout")
|
||||
),
|
||||
1000
|
||||
);
|
||||
});
|
||||
|
||||
if (!invitationRole) {
|
||||
throw new Error("No invitation found");
|
||||
}
|
||||
|
||||
const existingRole = team.teamMap.get(this.account.id);
|
||||
|
||||
if (
|
||||
existingRole === "admin" ||
|
||||
(existingRole === "writer" && invitationRole === "reader")
|
||||
) {
|
||||
console.debug("Not accepting invite that would downgrade role");
|
||||
return;
|
||||
}
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteAgentSecret),
|
||||
newRandomSessionID(inviteAgentID)
|
||||
);
|
||||
|
||||
teamAsInvite.addMember(
|
||||
this.account.id,
|
||||
invitationRole === "adminInvite"
|
||||
? "admin"
|
||||
: invitationRole === "writerInvite"
|
||||
? "writer"
|
||||
: "reader"
|
||||
);
|
||||
|
||||
team.teamMap.coValue._sessions = teamAsInvite.teamMap.coValue.sessions;
|
||||
team.teamMap.coValue._cachedContent = undefined;
|
||||
}
|
||||
|
||||
expectCoValueLoaded(id: RawCoID, expectation?: string): CoValue {
|
||||
@@ -146,7 +241,9 @@ export class LocalNode {
|
||||
|
||||
expectProfileLoaded(id: AccountID, expectation?: string): Profile {
|
||||
const account = this.expectCoValueLoaded(id, expectation);
|
||||
const profileID = expectTeamContent(account.getCurrentContent()).get("profile");
|
||||
const profileID = expectTeamContent(account.getCurrentContent()).get(
|
||||
"profile"
|
||||
);
|
||||
if (!profileID) {
|
||||
throw new Error(
|
||||
`${
|
||||
@@ -154,10 +251,16 @@ export class LocalNode {
|
||||
}Account ${id} has no profile`
|
||||
);
|
||||
}
|
||||
return this.expectCoValueLoaded(profileID, expectation).getCurrentContent() as Profile;
|
||||
return this.expectCoValueLoaded(
|
||||
profileID,
|
||||
expectation
|
||||
).getCurrentContent() as Profile;
|
||||
}
|
||||
|
||||
createAccount(name: string, agentSecret = newRandomAgentSecret()): ControlledAccount {
|
||||
createAccount(
|
||||
name: string,
|
||||
agentSecret = newRandomAgentSecret()
|
||||
): ControlledAccount {
|
||||
const account = this.createCoValue(
|
||||
accountHeaderForInitialAgentSecret(agentSecret)
|
||||
).testWithDifferentAccount(
|
||||
@@ -165,7 +268,10 @@ export class LocalNode {
|
||||
newRandomSessionID(getAgentID(agentSecret))
|
||||
);
|
||||
|
||||
const accountAsTeam = new Team(expectTeamContent(account.getCurrentContent()), account.node);
|
||||
const accountAsTeam = new Team(
|
||||
expectTeamContent(account.getCurrentContent()),
|
||||
account.node
|
||||
);
|
||||
|
||||
accountAsTeam.teamMap.edit((editable) => {
|
||||
editable.set(getAgentID(agentSecret), "admin", "trusting");
|
||||
@@ -195,7 +301,9 @@ export class LocalNode {
|
||||
account.node
|
||||
);
|
||||
|
||||
const profile = accountAsTeam.createMap<ProfileContent, ProfileMeta>({ type: "profile" });
|
||||
const profile = accountAsTeam.createMap<ProfileContent, ProfileMeta>({
|
||||
type: "profile",
|
||||
});
|
||||
|
||||
profile.edit((editable) => {
|
||||
editable.set("name", name, "trusting");
|
||||
@@ -205,6 +313,11 @@ export class LocalNode {
|
||||
editable.set("profile", profile.id, "trusting");
|
||||
});
|
||||
|
||||
const accountOnThisNode = this.expectCoValueLoaded(account.id);
|
||||
|
||||
accountOnThisNode._sessions = {...accountAsTeam.teamMap.coValue.sessions};
|
||||
accountOnThisNode._cachedContent = undefined;
|
||||
|
||||
return controlledAccount;
|
||||
}
|
||||
|
||||
@@ -276,24 +389,36 @@ export class LocalNode {
|
||||
): LocalNode {
|
||||
const newNode = new LocalNode(account, ownSessionID);
|
||||
|
||||
newNode.coValues = Object.fromEntries(
|
||||
Object.entries(this.coValues)
|
||||
.map(([id, entry]) => {
|
||||
if (entry.state === "loading") {
|
||||
return undefined;
|
||||
}
|
||||
const coValuesToCopy = Object.entries(this.coValues);
|
||||
|
||||
const newCoValue = new CoValue(
|
||||
entry.coValue.header,
|
||||
newNode
|
||||
);
|
||||
while (coValuesToCopy.length > 0) {
|
||||
const [coValueID, entry] =
|
||||
coValuesToCopy[coValuesToCopy.length - 1]!;
|
||||
|
||||
newCoValue.sessions = entry.coValue.sessions;
|
||||
if (entry.state === "loading") {
|
||||
coValuesToCopy.pop();
|
||||
continue;
|
||||
} else {
|
||||
const allDepsCopied = entry.coValue
|
||||
.getDependedOnCoValues()
|
||||
.every((dep) => newNode.coValues[dep]?.state === "loaded");
|
||||
|
||||
return [id, { state: "loaded", coValue: newCoValue }];
|
||||
})
|
||||
.filter((x): x is Exclude<typeof x, undefined> => !!x)
|
||||
);
|
||||
if (!allDepsCopied) {
|
||||
// move to end of queue
|
||||
coValuesToCopy.unshift(coValuesToCopy.pop()!);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newCoValue = new CoValue(entry.coValue.header, newNode, {...entry.coValue.sessions});
|
||||
|
||||
newNode.coValues[coValueID as RawCoID] = {
|
||||
state: "loaded",
|
||||
coValue: newCoValue,
|
||||
};
|
||||
|
||||
coValuesToCopy.pop();
|
||||
}
|
||||
}
|
||||
|
||||
return newNode;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { newRandomSessionID } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { expectMap } from "./contentType.js";
|
||||
import { expectTeamContent } from "./permissions.js";
|
||||
import { Team, expectTeamContent } from "./team.js";
|
||||
import {
|
||||
createdNowUnique,
|
||||
getSealerID,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
encryptKeySecret,
|
||||
newRandomAgentSecret,
|
||||
getAgentID,
|
||||
getAgentSealerSecret,
|
||||
getAgentSealerID,
|
||||
} from "./crypto.js";
|
||||
import {
|
||||
newTeam,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
teamWithTwoAdmins,
|
||||
teamWithTwoAdminsHighLevel,
|
||||
} from "./testUtils.js";
|
||||
import { AnonymousControlledAccount } from "./index.js";
|
||||
|
||||
test("Initial admin can add another admin to a team", () => {
|
||||
teamWithTwoAdmins();
|
||||
@@ -1265,3 +1268,500 @@ test("Can create two owned objects in the same team and they will have different
|
||||
|
||||
expect(childObject1.id).not.toEqual(childObject2.id);
|
||||
});
|
||||
|
||||
test("Admins can create an adminInvite, which can add an admin", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "adminInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("adminInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedAdminSecret = newRandomAgentSecret();
|
||||
const invitedAdminID = getAgentID(invitedAdminSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedAdminID, "admin", "trusting");
|
||||
|
||||
expect(editable.get(invitedAdminID)).toEqual("admin");
|
||||
|
||||
const readKey = teamAsInvite.getCurrentReadKey();
|
||||
|
||||
expect(readKey.secret).toBeDefined();
|
||||
|
||||
const revelation = seal(
|
||||
readKey.secret!,
|
||||
getAgentSealerSecret(invitedAdminSecret),
|
||||
getAgentSealerID(invitedAdminID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${invitedAdminID}`,
|
||||
revelation,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
expect(editable.get(`${readKey.id}_for_${invitedAdminID}`)).toEqual(
|
||||
revelation
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("Admins can create an adminInvite, which can add an admin (high-level)", async () => {
|
||||
const { node, team, admin } = newTeamHighLevel();
|
||||
|
||||
const inviteSecret = team.createInvite("admin");
|
||||
|
||||
const invitedAdminSecret = newRandomAgentSecret();
|
||||
const invitedAdminID = getAgentID(invitedAdminSecret);
|
||||
|
||||
const nodeAsInvitedAdmin = node.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(invitedAdminSecret),
|
||||
newRandomSessionID(invitedAdminID)
|
||||
);
|
||||
|
||||
await nodeAsInvitedAdmin.acceptInvite(team.id, inviteSecret);
|
||||
|
||||
const thirdAdmin = newRandomAgentSecret();
|
||||
const thirdAdminID = getAgentID(thirdAdmin);
|
||||
|
||||
const teamAsInvitedAdmin = new Team(
|
||||
await nodeAsInvitedAdmin.load(team.id),
|
||||
nodeAsInvitedAdmin
|
||||
);
|
||||
|
||||
expect(teamAsInvitedAdmin.teamMap.get(invitedAdminID)).toEqual("admin");
|
||||
expect(
|
||||
teamAsInvitedAdmin.teamMap.coValue.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
|
||||
teamAsInvitedAdmin.addMember(thirdAdminID, "admin");
|
||||
|
||||
expect(teamAsInvitedAdmin.teamMap.get(thirdAdminID)).toEqual("admin");
|
||||
});
|
||||
|
||||
test("Admins can create a writerInvite, which can add a writer", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "writerInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("writerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedWriterSecret = newRandomAgentSecret();
|
||||
const invitedWriterID = getAgentID(invitedWriterSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedWriterID, "writer", "trusting");
|
||||
|
||||
expect(editable.get(invitedWriterID)).toEqual("writer");
|
||||
|
||||
const readKey = teamAsInvite.getCurrentReadKey();
|
||||
|
||||
expect(readKey.secret).toBeDefined();
|
||||
|
||||
const revelation = seal(
|
||||
readKey.secret!,
|
||||
getAgentSealerSecret(invitedWriterSecret),
|
||||
getAgentSealerID(invitedWriterID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${invitedWriterID}`,
|
||||
revelation,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
expect(editable.get(`${readKey.id}_for_${invitedWriterID}`)).toEqual(
|
||||
revelation
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("Admins can create a writerInvite, which can add a writer (high-level)", async () => {
|
||||
const { node, team, admin } = newTeamHighLevel();
|
||||
|
||||
const inviteSecret = team.createInvite("writer");
|
||||
|
||||
const invitedWriterSecret = newRandomAgentSecret();
|
||||
const invitedWriterID = getAgentID(invitedWriterSecret);
|
||||
|
||||
const nodeAsInvitedWriter = node.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(invitedWriterSecret),
|
||||
newRandomSessionID(invitedWriterID)
|
||||
);
|
||||
|
||||
await nodeAsInvitedWriter.acceptInvite(team.id, inviteSecret);
|
||||
|
||||
const teamAsInvitedWriter = new Team(
|
||||
await nodeAsInvitedWriter.load(team.id),
|
||||
nodeAsInvitedWriter
|
||||
);
|
||||
|
||||
expect(teamAsInvitedWriter.teamMap.get(invitedWriterID)).toEqual("writer");
|
||||
expect(
|
||||
teamAsInvitedWriter.teamMap.coValue.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
test("Admins can create a readerInvite, which can add a reader", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "readerInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("readerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedReaderSecret = newRandomAgentSecret();
|
||||
const invitedReaderID = getAgentID(invitedReaderSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedReaderID, "reader", "trusting");
|
||||
|
||||
expect(editable.get(invitedReaderID)).toEqual("reader");
|
||||
|
||||
const readKey = teamAsInvite.getCurrentReadKey();
|
||||
|
||||
expect(readKey.secret).toBeDefined();
|
||||
|
||||
const revelation = seal(
|
||||
readKey.secret!,
|
||||
getAgentSealerSecret(invitedReaderSecret),
|
||||
getAgentSealerID(invitedReaderID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKey.id}_for_${invitedReaderID}`,
|
||||
revelation,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
expect(editable.get(`${readKey.id}_for_${invitedReaderID}`)).toEqual(
|
||||
revelation
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("Admins can create a readerInvite, which can add a reader (high-level)", async () => {
|
||||
const { node, team, admin } = newTeamHighLevel();
|
||||
|
||||
const inviteSecret = team.createInvite("reader");
|
||||
|
||||
const invitedReaderSecret = newRandomAgentSecret();
|
||||
const invitedReaderID = getAgentID(invitedReaderSecret);
|
||||
|
||||
const nodeAsInvitedReader = node.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(invitedReaderSecret),
|
||||
newRandomSessionID(invitedReaderID)
|
||||
);
|
||||
|
||||
await nodeAsInvitedReader.acceptInvite(team.id, inviteSecret);
|
||||
|
||||
const teamAsInvitedReader = new Team(
|
||||
await nodeAsInvitedReader.load(team.id),
|
||||
nodeAsInvitedReader
|
||||
);
|
||||
|
||||
expect(teamAsInvitedReader.teamMap.get(invitedReaderID)).toEqual("reader");
|
||||
expect(
|
||||
teamAsInvitedReader.teamMap.coValue.getCurrentReadKey().secret
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("WriterInvites can not invite admins", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "writerInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("writerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedAdminSecret = newRandomAgentSecret();
|
||||
const invitedAdminID = getAgentID(invitedAdminSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedAdminID, "admin", "trusting");
|
||||
expect(editable.get(invitedAdminID)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("ReaderInvites can not invite admins", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "readerInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("readerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedAdminSecret = newRandomAgentSecret();
|
||||
const invitedAdminID = getAgentID(invitedAdminSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedAdminID, "admin", "trusting");
|
||||
expect(editable.get(invitedAdminID)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("ReaderInvites can not invite writers", () => {
|
||||
const { node, team, admin } = newTeam();
|
||||
|
||||
const inviteSecret = newRandomAgentSecret();
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
expectTeamContent(team.getCurrentContent()).edit((editable) => {
|
||||
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
|
||||
const revelation = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
admin.currentSealerID(),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
|
||||
editable.set("readKey", readKeyID, "trusting");
|
||||
|
||||
editable.set(inviteID, "readerInvite", "trusting");
|
||||
|
||||
expect(editable.get(inviteID)).toEqual("readerInvite");
|
||||
|
||||
const revelationForInvite = seal(
|
||||
readKey,
|
||||
admin.currentSealerSecret(),
|
||||
getAgentSealerID(inviteID),
|
||||
{
|
||||
in: team.id,
|
||||
tx: team.nextTransactionID(),
|
||||
}
|
||||
);
|
||||
|
||||
editable.set(
|
||||
`${readKeyID}_for_${inviteID}`,
|
||||
revelationForInvite,
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
|
||||
const teamAsInvite = team.testWithDifferentAccount(
|
||||
new AnonymousControlledAccount(inviteSecret),
|
||||
newRandomSessionID(inviteID)
|
||||
);
|
||||
|
||||
const invitedWriterSecret = newRandomAgentSecret();
|
||||
const invitedWriterID = getAgentID(invitedWriterSecret);
|
||||
|
||||
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
|
||||
editable.set(invitedWriterID, "writer", "trusting");
|
||||
expect(editable.get(invitedWriterID)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import { CoID } from "./contentType.js";
|
||||
import { MapOpPayload } from "./contentTypes/coMap.js";
|
||||
import { JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
Encrypted,
|
||||
KeyID,
|
||||
KeySecret,
|
||||
createdNowUnique,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
encryptKeySecret,
|
||||
getAgentSealerID,
|
||||
Sealed,
|
||||
} from "./crypto.js";
|
||||
import {
|
||||
CoValue,
|
||||
@@ -18,16 +10,25 @@ import {
|
||||
TrustingTransaction,
|
||||
accountOrAgentIDfromSessionID,
|
||||
} from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { RawCoID, SessionID, TransactionID, isAgentID } from "./ids.js";
|
||||
import { AccountIDOrAgentID, GeneralizedControlledAccount, Profile } from "./account.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "./ids.js";
|
||||
import {
|
||||
AccountIDOrAgentID,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
|
||||
export type PermissionsDef =
|
||||
| { type: "team"; initialAdmin: AccountIDOrAgentID }
|
||||
| { type: "ownedByTeam"; team: RawCoID }
|
||||
| { type: "unsafeAllowAll" };
|
||||
|
||||
export type Role = "reader" | "writer" | "admin" | "revoked";
|
||||
export type Role =
|
||||
| "reader"
|
||||
| "writer"
|
||||
| "admin"
|
||||
| "revoked"
|
||||
| "adminInvite"
|
||||
| "writerInvite"
|
||||
| "readerInvite";
|
||||
|
||||
export function determineValidTransactions(
|
||||
coValue: CoValue
|
||||
@@ -84,7 +85,7 @@ export function determineValidTransactions(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (change.op !== "insert") {
|
||||
if (change.op !== "set") {
|
||||
console.warn("Team transaction must set a role or readKey");
|
||||
continue;
|
||||
}
|
||||
@@ -97,7 +98,7 @@ export function determineValidTransactions(
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (change.key === 'profile') {
|
||||
} else if (change.key === "profile") {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
console.warn("Only admins can set profile");
|
||||
continue;
|
||||
@@ -105,8 +106,16 @@ export function determineValidTransactions(
|
||||
|
||||
validTransactions.push({ txID: { sessionID, txIndex }, tx });
|
||||
continue;
|
||||
} else if (isKeyForKeyField(change.key) || isKeyForAccountField(change.key)) {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
} else if (
|
||||
isKeyForKeyField(change.key) ||
|
||||
isKeyForAccountField(change.key)
|
||||
) {
|
||||
if (
|
||||
memberState[transactor] !== "admin" &&
|
||||
memberState[transactor] !== "adminInvite" &&
|
||||
memberState[transactor] !== "writerInvite" &&
|
||||
memberState[transactor] !== "readerInvite"
|
||||
) {
|
||||
console.warn("Only admins can reveal keys");
|
||||
continue;
|
||||
}
|
||||
@@ -124,7 +133,10 @@ export function determineValidTransactions(
|
||||
change.value !== "admin" &&
|
||||
change.value !== "writer" &&
|
||||
change.value !== "reader" &&
|
||||
change.value !== "revoked"
|
||||
change.value !== "revoked" &&
|
||||
change.value !== "adminInvite" &&
|
||||
change.value !== "writerInvite" &&
|
||||
change.value !== "readerInvite"
|
||||
) {
|
||||
console.warn("Team transaction must set a valid role");
|
||||
continue;
|
||||
@@ -133,26 +145,41 @@ export function determineValidTransactions(
|
||||
const isFirstSelfAppointment =
|
||||
!memberState[transactor] &&
|
||||
transactor === initialAdmin &&
|
||||
change.op === "insert" &&
|
||||
change.op === "set" &&
|
||||
change.key === transactor &&
|
||||
change.value === "admin";
|
||||
|
||||
if (!isFirstSelfAppointment) {
|
||||
if (memberState[transactor] !== "admin") {
|
||||
if (memberState[transactor] === "admin") {
|
||||
if (
|
||||
memberState[affectedMember] === "admin" &&
|
||||
affectedMember !== transactor &&
|
||||
assignedRole !== "admin"
|
||||
) {
|
||||
console.warn("Admins can only demote themselves.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "adminInvite") {
|
||||
if (change.value !== "admin") {
|
||||
console.warn("AdminInvites can only create admins.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "writerInvite") {
|
||||
if (change.value !== "writer") {
|
||||
console.warn("WriterInvites can only create writers.");
|
||||
continue;
|
||||
}
|
||||
} else if (memberState[transactor] === "readerInvite") {
|
||||
if (change.value !== "reader") {
|
||||
console.warn("ReaderInvites can only create reader.");
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"Team transaction must be made by current admin"
|
||||
"Team transaction must be made by current admin or invite"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
memberState[affectedMember] === "admin" &&
|
||||
affectedMember !== transactor &&
|
||||
assignedRole !== "admin"
|
||||
) {
|
||||
console.warn("Admins can only demote themselves.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
memberState[affectedMember] = change.value;
|
||||
@@ -213,180 +240,17 @@ export function determineValidTransactions(
|
||||
}
|
||||
}
|
||||
|
||||
export type TeamContent = {
|
||||
profile: CoID<Profile> | null;
|
||||
[key: AccountIDOrAgentID]: Role;
|
||||
readKey: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
};
|
||||
|
||||
export function expectTeamContent(
|
||||
content: ContentType
|
||||
): CoMap<TeamContent, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<TeamContent, JsonObject | null>;
|
||||
}
|
||||
|
||||
export class Team {
|
||||
teamMap: CoMap<TeamContent, JsonObject | null>;
|
||||
node: LocalNode;
|
||||
|
||||
constructor(teamMap: CoMap<TeamContent, JsonObject | null>, node: LocalNode) {
|
||||
this.teamMap = teamMap;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
get id(): CoID<CoMap<TeamContent, JsonObject | null>> {
|
||||
return this.teamMap.id;
|
||||
}
|
||||
|
||||
addMember(accountID: AccountIDOrAgentID, role: Role) {
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
const agent = this.node.resolveAccountAgent(
|
||||
accountID,
|
||||
"Expected to know agent to add them to team"
|
||||
);
|
||||
|
||||
map.set(accountID, role, "trusting");
|
||||
|
||||
if (map.get(accountID) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal(
|
||||
currentReadKey.secret,
|
||||
this.teamMap.coValue.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(agent),
|
||||
{
|
||||
in: this.teamMap.coValue.id,
|
||||
tx: this.teamMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
rotateReadKey() {
|
||||
const currentlyPermittedReaders = this.teamMap.keys().filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.teamMap.get(key);
|
||||
return (
|
||||
role === "admin" || role === "writer" || role === "reader"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as AccountIDOrAgentID[];
|
||||
|
||||
const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error(
|
||||
"Can't rotate read key secret we don't have access to"
|
||||
);
|
||||
}
|
||||
|
||||
const currentReadKey = {
|
||||
id: maybeCurrentReadKey.id,
|
||||
secret: maybeCurrentReadKey.secret,
|
||||
};
|
||||
|
||||
const newReadKey = newRandomKeySecret();
|
||||
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.node.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader"
|
||||
);
|
||||
|
||||
map.set(
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
seal(
|
||||
newReadKey.secret,
|
||||
this.teamMap.coValue.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(reader),
|
||||
{
|
||||
in: this.teamMap.coValue.id,
|
||||
tx: this.teamMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${newReadKey.id}`,
|
||||
encryptKeySecret({
|
||||
encrypting: newReadKey,
|
||||
toEncrypt: currentReadKey,
|
||||
}).encrypted,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
map.set("readKey", newReadKey.id, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
removeMember(accountID: AccountIDOrAgentID) {
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
map.set(accountID, "revoked", "trusting");
|
||||
});
|
||||
|
||||
this.rotateReadKey();
|
||||
}
|
||||
|
||||
createMap<M extends { [key: string]: JsonValue }, Meta extends JsonObject | null = 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 { Team } from "./team.js";
|
||||
import {
|
||||
ReadableStream,
|
||||
WritableStream,
|
||||
@@ -86,7 +86,7 @@ test("Node replies with initial tx and header to empty subscribe", async () => {
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -164,7 +164,7 @@ test("Node replies with only new tx to subscribe with some known state", async (
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -253,7 +253,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -285,7 +285,7 @@ test("After subscribing, node sends own known state and new txs to peer", async
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -364,7 +364,7 @@ test("Client replies with known new content to tellKnownState from server", asyn
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -467,7 +467,7 @@ test("No matter the optimistic known state, node respects invalid known state me
|
||||
.transactions[1]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "goodbye",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
@@ -570,7 +570,7 @@ test("If we add a server peer, all updates to all coValues are sent to it, even
|
||||
.transactions[0]!.madeAt,
|
||||
changes: [
|
||||
{
|
||||
op: "insert",
|
||||
op: "set",
|
||||
key: "hello",
|
||||
value: "world",
|
||||
} satisfies MapOpPayload<string, string>,
|
||||
|
||||
233
packages/cojson/src/team.ts
Normal file
233
packages/cojson/src/team.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { CoID, ContentType } from "./contentType.js";
|
||||
import { CoMap } from "./contentTypes/coMap.js";
|
||||
import { JsonObject, JsonValue } from "./jsonValue.js";
|
||||
import {
|
||||
Encrypted,
|
||||
KeyID,
|
||||
KeySecret,
|
||||
createdNowUnique,
|
||||
newRandomKeySecret,
|
||||
seal,
|
||||
encryptKeySecret,
|
||||
getAgentSealerID,
|
||||
Sealed,
|
||||
newRandomSecretSeed,
|
||||
agentSecretFromSecretSeed,
|
||||
getAgentID,
|
||||
} from "./crypto.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { SessionID, isAgentID } from "./ids.js";
|
||||
import {
|
||||
AccountIDOrAgentID,
|
||||
GeneralizedControlledAccount,
|
||||
Profile,
|
||||
} from "./account.js";
|
||||
import { Role } from "./permissions.js";
|
||||
import { base58 } from "@scure/base";
|
||||
|
||||
export type TeamContent = {
|
||||
profile: CoID<Profile> | null;
|
||||
[key: AccountIDOrAgentID]: Role;
|
||||
readKey: KeyID;
|
||||
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
|
||||
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
|
||||
KeySecret,
|
||||
{ encryptedID: KeyID; encryptingID: KeyID }
|
||||
>;
|
||||
};
|
||||
|
||||
export function expectTeamContent(
|
||||
content: ContentType
|
||||
): CoMap<TeamContent, JsonObject | null> {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected map");
|
||||
}
|
||||
|
||||
return content as CoMap<TeamContent, JsonObject | null>;
|
||||
}
|
||||
|
||||
export class Team {
|
||||
teamMap: CoMap<TeamContent, JsonObject | null>;
|
||||
node: LocalNode;
|
||||
|
||||
constructor(
|
||||
teamMap: CoMap<TeamContent, JsonObject | null>,
|
||||
node: LocalNode
|
||||
) {
|
||||
this.teamMap = teamMap;
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
get id(): CoID<CoMap<TeamContent, JsonObject | null>> {
|
||||
return this.teamMap.id;
|
||||
}
|
||||
|
||||
roleOf(accountID: AccountIDOrAgentID): Role | undefined {
|
||||
return this.teamMap.get(accountID);
|
||||
}
|
||||
|
||||
myRole(): Role | undefined {
|
||||
return this.roleOf(this.node.account.id);
|
||||
}
|
||||
|
||||
addMember(accountID: AccountIDOrAgentID, role: Role) {
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
}
|
||||
|
||||
const agent = this.node.resolveAccountAgent(
|
||||
accountID,
|
||||
"Expected to know agent to add them to team"
|
||||
);
|
||||
|
||||
map.set(accountID, role, "trusting");
|
||||
|
||||
if (map.get(accountID) !== role) {
|
||||
throw new Error("Failed to set role");
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${accountID}`,
|
||||
seal(
|
||||
currentReadKey.secret,
|
||||
this.teamMap.coValue.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(agent),
|
||||
{
|
||||
in: this.teamMap.coValue.id,
|
||||
tx: this.teamMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
|
||||
const secretSeed = newRandomSecretSeed();
|
||||
|
||||
const inviteSecret = agentSecretFromSecretSeed(secretSeed);
|
||||
const inviteID = getAgentID(inviteSecret);
|
||||
|
||||
this.addMember(inviteID, `${role}Invite` as Role);
|
||||
|
||||
return inviteSecretFromSecretSeed(secretSeed);
|
||||
}
|
||||
|
||||
rotateReadKey() {
|
||||
const currentlyPermittedReaders = this.teamMap.keys().filter((key) => {
|
||||
if (key.startsWith("co_") || isAgentID(key)) {
|
||||
const role = this.teamMap.get(key);
|
||||
return (
|
||||
role === "admin" || role === "writer" || role === "reader"
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) as AccountIDOrAgentID[];
|
||||
|
||||
const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error(
|
||||
"Can't rotate read key secret we don't have access to"
|
||||
);
|
||||
}
|
||||
|
||||
const currentReadKey = {
|
||||
id: maybeCurrentReadKey.id,
|
||||
secret: maybeCurrentReadKey.secret,
|
||||
};
|
||||
|
||||
const newReadKey = newRandomKeySecret();
|
||||
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
for (const readerID of currentlyPermittedReaders) {
|
||||
const reader = this.node.resolveAccountAgent(
|
||||
readerID,
|
||||
"Expected to know currently permitted reader"
|
||||
);
|
||||
|
||||
map.set(
|
||||
`${newReadKey.id}_for_${readerID}`,
|
||||
seal(
|
||||
newReadKey.secret,
|
||||
this.teamMap.coValue.node.account.currentSealerSecret(),
|
||||
getAgentSealerID(reader),
|
||||
{
|
||||
in: this.teamMap.coValue.id,
|
||||
tx: this.teamMap.coValue.nextTransactionID(),
|
||||
}
|
||||
),
|
||||
"trusting"
|
||||
);
|
||||
}
|
||||
|
||||
map.set(
|
||||
`${currentReadKey.id}_for_${newReadKey.id}`,
|
||||
encryptKeySecret({
|
||||
encrypting: newReadKey,
|
||||
toEncrypt: currentReadKey,
|
||||
}).encrypted,
|
||||
"trusting"
|
||||
);
|
||||
|
||||
map.set("readKey", newReadKey.id, "trusting");
|
||||
});
|
||||
}
|
||||
|
||||
removeMember(accountID: AccountIDOrAgentID) {
|
||||
this.teamMap = this.teamMap.edit((map) => {
|
||||
map.set(accountID, "revoked", "trusting");
|
||||
});
|
||||
|
||||
this.rotateReadKey();
|
||||
}
|
||||
|
||||
createMap<
|
||||
M extends { [key: string]: JsonValue },
|
||||
Meta extends JsonObject | null = null
|
||||
>(meta?: Meta): CoMap<M, Meta> {
|
||||
return this.node
|
||||
.createCoValue({
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "ownedByTeam",
|
||||
team: this.teamMap.id,
|
||||
},
|
||||
meta: meta || null,
|
||||
...createdNowUnique(),
|
||||
})
|
||||
.getCurrentContent() as CoMap<M, Meta>;
|
||||
}
|
||||
|
||||
testWithDifferentAccount(
|
||||
account: GeneralizedControlledAccount,
|
||||
sessionId: SessionID
|
||||
): Team {
|
||||
return new Team(
|
||||
expectTeamContent(
|
||||
this.teamMap.coValue
|
||||
.testWithDifferentAccount(account, sessionId)
|
||||
.getCurrentContent()
|
||||
),
|
||||
this.node
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type InviteSecret = `inviteSecret_z${string}`;
|
||||
|
||||
function inviteSecretFromSecretSeed(secretSeed: Uint8Array): InviteSecret {
|
||||
return `inviteSecret_z${base58.encode(secretSeed)}`;
|
||||
}
|
||||
|
||||
export function secretSeedFromInviteSecret(inviteSecret: InviteSecret) {
|
||||
if (!inviteSecret.startsWith("inviteSecret_z")) {
|
||||
throw new Error("Invalid invite secret");
|
||||
}
|
||||
|
||||
return base58.decode(inviteSecret.slice("inviteSecret_z".length));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AgentSecret, createdNowUnique, getAgentID, newRandomAgentSecret } from "./crypto.js";
|
||||
import { newRandomSessionID } from "./coValue.js";
|
||||
import { LocalNode } from "./node.js";
|
||||
import { expectTeamContent } from "./permissions.js";
|
||||
import { expectTeamContent } from "./team.js";
|
||||
import { AnonymousControlledAccount } from "./account.js";
|
||||
import { SessionID } from "./ids.js";
|
||||
|
||||
|
||||
@@ -12,4 +12,5 @@
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["./src/**/*.test.*"],
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-browser-auth-local",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.6",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jazz-browser": "^0.0.3",
|
||||
"jazz-browser": "^0.0.6",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-browser",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.6",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.0.20",
|
||||
"jazz-storage-indexeddb": "^0.0.7",
|
||||
"cojson": "^0.0.23",
|
||||
"jazz-storage-indexeddb": "^0.0.10",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { InviteSecret } from "cojson";
|
||||
import {
|
||||
LocalNode,
|
||||
cojsonInternals,
|
||||
CojsonInternalTypes,
|
||||
SessionID,
|
||||
SyncMessage,
|
||||
Peer,
|
||||
ContentType,
|
||||
Team,
|
||||
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,81 @@ 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 === "ownedByTeam") {
|
||||
currentCoValue = node.expectCoValueLoaded(
|
||||
currentCoValue.header.ruleset.team
|
||||
);
|
||||
}
|
||||
|
||||
if (currentCoValue.header.ruleset.type !== "team") {
|
||||
throw new Error("Can't create invite link for object without team");
|
||||
}
|
||||
|
||||
const team = new Team(
|
||||
cojsonInternals.expectTeamContent(currentCoValue.getCurrentContent()),
|
||||
node
|
||||
);
|
||||
|
||||
const inviteSecret = team.createInvite(role);
|
||||
|
||||
return `${baseURL}#invitedTo=${value.id}&inviteSecret=${inviteSecret}`;
|
||||
}
|
||||
|
||||
export function parseInviteLink(inviteURL: string):
|
||||
| {
|
||||
valueID: CoID<ContentType>;
|
||||
inviteSecret: InviteSecret;
|
||||
}
|
||||
| undefined {
|
||||
const url = new URL(inviteURL);
|
||||
const valueID = url.hash
|
||||
.split("&")[0]
|
||||
?.replace(/^#invitedTo=/, "") as CoID<ContentType>;
|
||||
const inviteSecret = url.hash
|
||||
.split("&")[1]
|
||||
?.replace(/^inviteSecret=/, "") as InviteSecret;
|
||||
if (!valueID || !inviteSecret) {
|
||||
return undefined;
|
||||
}
|
||||
return { valueID, inviteSecret };
|
||||
}
|
||||
|
||||
export function consumeInviteLinkFromWindowLocation(node: LocalNode): Promise<
|
||||
| {
|
||||
valueID: string;
|
||||
inviteSecret: string;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const result = parseInviteLink(window.location.href);
|
||||
|
||||
if (result) {
|
||||
node.acceptInvite(result.valueID, result.inviteSecret)
|
||||
.then(() => {
|
||||
resolve(result);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
window.location.href.replace(/#.*$/, "")
|
||||
);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-react-auth-local",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.13",
|
||||
"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.0.6",
|
||||
"jazz-react": "^0.0.16",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "jazz-react",
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.16",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.0.20",
|
||||
"jazz-browser": "^0.0.3",
|
||||
"cojson": "^0.0.23",
|
||||
"jazz-browser": "^0.0.6",
|
||||
"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;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "jazz-storage-indexeddb",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.10",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cojson": "^0.0.20",
|
||||
"cojson": "^0.0.23",
|
||||
"typescript": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
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 }));
|
||||
@@ -25,14 +24,18 @@ 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!");
|
||||
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
|
||||
66
yarn.lock
66
yarn.lock
@@ -1175,6 +1175,17 @@
|
||||
"@radix-ui/react-use-previous" "1.0.1"
|
||||
"@radix-ui/react-use-size" "1.0.1"
|
||||
|
||||
"@radix-ui/react-collection@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"
|
||||
integrity sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-context" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-slot" "1.0.2"
|
||||
|
||||
"@radix-ui/react-compose-refs@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
|
||||
@@ -1189,6 +1200,26 @@
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
|
||||
"@radix-ui/react-dismissable-layer@1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz#883a48f5f938fa679427aa17fcba70c5494c6978"
|
||||
integrity sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "1.0.1"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
"@radix-ui/react-use-escape-keydown" "1.0.3"
|
||||
|
||||
"@radix-ui/react-portal@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.3.tgz#ffb961244c8ed1b46f039e6c215a6c4d9989bda1"
|
||||
integrity sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
|
||||
"@radix-ui/react-presence@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba"
|
||||
@@ -1214,6 +1245,25 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
|
||||
"@radix-ui/react-toast@^1.1.4":
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.1.4.tgz#9a7fc2d71700886f3292f7699c905f1e01be59e1"
|
||||
integrity sha512-wf+fc8DOywrpRK3jlPlWVe+ELYGHdKDaaARJZNuUTWyWYq7+ANCFLp4rTjZ/mcGkJJQ/vZ949Zis9xxEpfq9OA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/primitive" "1.0.1"
|
||||
"@radix-ui/react-collection" "1.0.3"
|
||||
"@radix-ui/react-compose-refs" "1.0.1"
|
||||
"@radix-ui/react-context" "1.0.1"
|
||||
"@radix-ui/react-dismissable-layer" "1.0.4"
|
||||
"@radix-ui/react-portal" "1.0.3"
|
||||
"@radix-ui/react-presence" "1.0.1"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||
"@radix-ui/react-use-layout-effect" "1.0.1"
|
||||
"@radix-ui/react-visually-hidden" "1.0.3"
|
||||
|
||||
"@radix-ui/react-use-callback-ref@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
|
||||
@@ -1229,6 +1279,14 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
|
||||
"@radix-ui/react-use-escape-keydown@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755"
|
||||
integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-callback-ref" "1.0.1"
|
||||
|
||||
"@radix-ui/react-use-layout-effect@1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399"
|
||||
@@ -1251,6 +1309,14 @@
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-use-layout-effect" "1.0.1"
|
||||
|
||||
"@radix-ui/react-visually-hidden@1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz#51aed9dd0fe5abcad7dee2a234ad36106a6984ac"
|
||||
integrity sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.13.10"
|
||||
"@radix-ui/react-primitive" "1.0.3"
|
||||
|
||||
"@rollup/pluginutils@^3.1.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
|
||||
|
||||
Reference in New Issue
Block a user