Compare commits

..

13 Commits

Author SHA1 Message Date
Anselm
6557532743 Publish
- jazz-example-todo@0.0.10
 - cojson@0.0.23
 - jazz-browser@0.0.6
 - jazz-browser-auth-local@0.0.6
 - jazz-react@0.0.16
 - jazz-react-auth-local@0.0.13
 - jazz-storage-indexeddb@0.0.10
2023-08-19 14:54:51 +01:00
Anselm
ea6835664b Lots of fixes related to first-time invite race conditions 2023-08-19 14:54:22 +01:00
Anselm Eickhoff
a00332f4af Merge pull request #39 from gardencmp/anselm-gar-78
Implement Invitations
2023-08-19 12:46:10 +01:00
Anselm
6a6fb2eb3c Publish
- jazz-example-todo@0.0.9
 - cojson@0.0.22
 - jazz-browser@0.0.5
 - jazz-browser-auth-local@0.0.5
 - jazz-react@0.0.15
 - jazz-react-auth-local@0.0.12
 - jazz-storage-indexeddb@0.0.9
2023-08-19 12:24:08 +01:00
Anselm
0437223d50 Invitation flow improvements 2023-08-19 12:23:31 +01:00
Anselm
30c7e1bf6d Replace "insert" op with "set" 2023-08-19 12:23:17 +01:00
Anselm
aaa9d876d5 Implement high-level interface for invites 2023-08-18 18:13:20 +01:00
Anselm
264009a1a9 Implement underlying permissions for invites 2023-08-18 16:32:39 +01:00
Anselm Eickhoff
f2cb5d1b59 Merge pull request #38 from gardencmp/anselm-gar-108
Create proper accounts in local auth
2023-08-17 22:49:53 +01:00
Anselm
8610db2d8e more style fixes 2023-08-17 22:46:08 +01:00
Anselm
c672a03338 Large text in input 2023-08-17 22:44:43 +01:00
Anselm
cb60088d2a Publish
- jazz-example-todo@0.0.8
 - cojson@0.0.21
 - jazz-browser@0.0.4
 - jazz-browser-auth-local@0.0.4
 - jazz-react@0.0.14
 - jazz-react-auth-local@0.0.11
 - jazz-storage-indexeddb@0.0.8
2023-08-17 22:24:01 +01:00
Anselm
a59d5d3b70 Fix max call stack size tsc errors 2023-08-17 22:23:47 +01:00
36 changed files with 1751 additions and 348 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

6
jest.config.js Normal file
View File

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

View File

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

View File

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

View File

@@ -2,6 +2,9 @@ import { Transaction } from "./coValue.js";
import { LocalNode } from "./node.js";
import { createdNowUnique, getAgentSignerSecret, newRandomAgentSecret, sign } from "./crypto.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
import { AccountID } from "./index.js";
import { Role } from "./permissions.js";
test("Can create coValue with new agent credentials and add transaction to it", () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID();
@@ -121,3 +124,58 @@ test("transactions with correctly signed, but wrong hash are rejected", () => {
)
).toBe(false);
});
test("New transactions in a team correctly update owned values, including subscriptions", async () => {
const [account, sessionID] = randomAnonymousAccountAndSessionID();
const node = new LocalNode(account, sessionID);
const team = node.createTeam();
const timeBeforeEdit = Date.now();
await new Promise((resolve) => setTimeout(resolve, 10));
let map = team.createMap();
let mapAfterEdit = map.edit((map) => {
map.set("hello", "world");
});
const listener = jest.fn().mockImplementation();
map.subscribe(listener);
expect(listener.mock.calls[0][0].get("hello")).toBe("world");
const resignationThatWeJustLearnedAbout = {
privacy: "trusting",
madeAt: timeBeforeEdit,
changes: [
{
op: "set",
key: account.id,
value: "revoked"
} satisfies MapOpPayload<typeof account.id, Role>
]
} satisfies Transaction;
const { expectedNewHash } = team.teamMap.coValue.expectedNewHashAfter(sessionID, [
resignationThatWeJustLearnedAbout,
]);
const signature = sign(
node.account.currentSignerSecret(),
expectedNewHash
);
expect(map.coValue.getValidSortedTransactions().length).toBe(1);
const manuallyAdddedTxSuccess = team.teamMap.coValue.tryAddTransactions(node.ownSessionID, [resignationThatWeJustLearnedAbout], expectedNewHash, signature);
expect(manuallyAdddedTxSuccess).toBe(true);
expect(listener.mock.calls.length).toBe(2);
expect(listener.mock.calls[1][0].get("hello")).toBe(undefined);
expect(map.coValue.getValidSortedTransactions().length).toBe(0);
});

View File

@@ -24,11 +24,10 @@ import { JsonObject, JsonValue } from "./jsonValue.js";
import { base58 } from "@scure/base";
import {
PermissionsDef as RulesetDef,
Team,
determineValidTransactions,
expectTeamContent,
isKeyForKeyField,
} from "./permissions.js";
import { Team, expectTeamContent } from "./team.js";
import { LocalNode } from "./node.js";
import { CoValueKnownState, NewContentMessage } from "./sync.js";
import { RawCoID, SessionID, TransactionID } from "./ids.js";
@@ -97,15 +96,31 @@ export class CoValue {
id: RawCoID;
node: LocalNode;
header: CoValueHeader;
sessions: { [key: SessionID]: SessionLog };
content?: ContentType;
_sessions: { [key: SessionID]: SessionLog };
_cachedContent?: ContentType;
listeners: Set<(content?: ContentType) => void> = new Set();
constructor(header: CoValueHeader, node: LocalNode) {
constructor(header: CoValueHeader, node: LocalNode, internalInitSessions: { [key: SessionID]: SessionLog } = {}) {
this.id = idforHeader(header);
this.header = header;
this.sessions = {};
this._sessions = internalInitSessions;
this.node = node;
if (header.ruleset.type == "ownedByTeam") {
this.node
.expectCoValueLoaded(header.ruleset.team)
.subscribe((_teamUpdate) => {
this._cachedContent = undefined;
const newContent = this.getCurrentContent();
for (const listener of this.listeners) {
listener(newContent);
}
});
}
}
get sessions(): Readonly<{ [key: SessionID]: SessionLog }> {
return this._sessions;
}
testWithDifferentAccount(
@@ -172,7 +187,10 @@ export class CoValue {
);
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
console.warn("Invalid hash", { expectedNewHash, givenExpectedNewHash });
console.warn("Invalid hash", {
expectedNewHash,
givenExpectedNewHash,
});
return false;
}
@@ -190,14 +208,14 @@ export class CoValue {
transactions.push(...newTransactions);
this.sessions[sessionID] = {
this._sessions[sessionID] = {
transactions,
lastHash: expectedNewHash,
streamingHash: newStreamingHash,
lastSignature: newSignature,
};
this.content = undefined;
this._cachedContent = undefined;
const content = this.getCurrentContent();
@@ -296,23 +314,23 @@ export class CoValue {
}
getCurrentContent(): ContentType {
if (this.content) {
return this.content;
if (this._cachedContent) {
return this._cachedContent;
}
if (this.header.type === "comap") {
this.content = new CoMap(this);
this._cachedContent = new CoMap(this);
} else if (this.header.type === "colist") {
this.content = new CoList(this);
this._cachedContent = new CoList(this);
} else if (this.header.type === "costream") {
this.content = new CoStream(this);
this._cachedContent = new CoStream(this);
} else if (this.header.type === "static") {
this.content = new Static(this);
this._cachedContent = new Static(this);
} else {
throw new Error(`Unknown coValue type ${this.header.type}`);
}
return this.content;
return this._cachedContent;
}
getValidSortedTransactions(): DecryptedTransaction[] {
@@ -399,7 +417,9 @@ export class CoValue {
// Try to find key revelation for us
const readKeyEntry = content.getLastEntry(`${keyID}_for_${this.node.account.id}`);
const readKeyEntry = content.getLastEntry(
`${keyID}_for_${this.node.account.id}`
);
if (readKeyEntry) {
const revealer = accountOrAgentIDfromSessionID(
@@ -428,7 +448,8 @@ export class CoValue {
for (const field of content.keys()) {
if (isKeyForKeyField(field) && field.startsWith(keyID)) {
const encryptingKeyID = field.split("_for_")[1] as KeyID;
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
const encryptingKeySecret =
this.getReadKey(encryptingKeyID);
if (!encryptingKeySecret) {
continue;
@@ -453,7 +474,6 @@ export class CoValue {
);
}
}
}
return undefined;
@@ -525,10 +545,7 @@ export class CoValue {
),
};
if (
!newContent.header &&
Object.keys(newContent.new).length === 0
) {
if (!newContent.header && Object.keys(newContent.new).length === 0) {
return undefined;
}
@@ -544,4 +561,4 @@ export class CoValue {
? [this.header.ruleset.team]
: [];
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import {
AgentSecret,
agentSecretFromSecretSeed,
createdNowUnique,
getAgentID,
getAgentSealerID,
@@ -9,7 +10,13 @@ import {
seal,
} from "./crypto.js";
import { CoValue, CoValueHeader, newRandomSessionID } from "./coValue.js";
import { Team, TeamContent, expectTeamContent } from "./permissions.js";
import {
InviteSecret,
Team,
TeamContent,
expectTeamContent,
secretSeedFromInviteSecret,
} from "./team.js";
import { Peer, SyncManager } from "./sync.js";
import { AgentID, RawCoID, SessionID, isAgentID } from "./ids.js";
import { CoID, ContentType } from "./contentType.js";
@@ -43,7 +50,10 @@ export class LocalNode {
this.ownSessionID = ownSessionID;
}
static withNewlyCreatedAccount(name: string, 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;
}

View File

@@ -1,13 +1,15 @@
import { newRandomSessionID } from "./coValue.js";
import { LocalNode } from "./node.js";
import { expectMap } from "./contentType.js";
import { expectTeamContent } from "./permissions.js";
import { Team, expectTeamContent } from "./team.js";
import {
createdNowUnique,
getSealerID,
newRandomKeySecret,
seal,
encryptKeySecret,
newRandomAgentSecret,
getAgentID,
getAgentSealerSecret,
getAgentSealerID,
} from "./crypto.js";
import {
newTeam,
@@ -15,6 +17,7 @@ import {
teamWithTwoAdmins,
teamWithTwoAdminsHighLevel,
} from "./testUtils.js";
import { AnonymousControlledAccount } from "./index.js";
test("Initial admin can add another admin to a team", () => {
teamWithTwoAdmins();
@@ -1265,3 +1268,500 @@ test("Can create two owned objects in the same team and they will have different
expect(childObject1.id).not.toEqual(childObject2.id);
});
test("Admins can create an adminInvite, which can add an admin", () => {
const { node, team, admin } = newTeam();
const inviteSecret = newRandomAgentSecret();
const inviteID = getAgentID(inviteSecret);
expectTeamContent(team.getCurrentContent()).edit((editable) => {
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
const revelation = seal(
readKey,
admin.currentSealerSecret(),
admin.currentSealerID(),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
editable.set("readKey", readKeyID, "trusting");
editable.set(inviteID, "adminInvite", "trusting");
expect(editable.get(inviteID)).toEqual("adminInvite");
const revelationForInvite = seal(
readKey,
admin.currentSealerSecret(),
getAgentSealerID(inviteID),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(
`${readKeyID}_for_${inviteID}`,
revelationForInvite,
"trusting"
);
});
const teamAsInvite = team.testWithDifferentAccount(
new AnonymousControlledAccount(inviteSecret),
newRandomSessionID(inviteID)
);
const invitedAdminSecret = newRandomAgentSecret();
const invitedAdminID = getAgentID(invitedAdminSecret);
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
editable.set(invitedAdminID, "admin", "trusting");
expect(editable.get(invitedAdminID)).toEqual("admin");
const readKey = teamAsInvite.getCurrentReadKey();
expect(readKey.secret).toBeDefined();
const revelation = seal(
readKey.secret!,
getAgentSealerSecret(invitedAdminSecret),
getAgentSealerID(invitedAdminID),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(
`${readKey.id}_for_${invitedAdminID}`,
revelation,
"trusting"
);
expect(editable.get(`${readKey.id}_for_${invitedAdminID}`)).toEqual(
revelation
);
});
});
test("Admins can create an adminInvite, which can add an admin (high-level)", async () => {
const { node, team, admin } = newTeamHighLevel();
const inviteSecret = team.createInvite("admin");
const invitedAdminSecret = newRandomAgentSecret();
const invitedAdminID = getAgentID(invitedAdminSecret);
const nodeAsInvitedAdmin = node.testWithDifferentAccount(
new AnonymousControlledAccount(invitedAdminSecret),
newRandomSessionID(invitedAdminID)
);
await nodeAsInvitedAdmin.acceptInvite(team.id, inviteSecret);
const thirdAdmin = newRandomAgentSecret();
const thirdAdminID = getAgentID(thirdAdmin);
const teamAsInvitedAdmin = new Team(
await nodeAsInvitedAdmin.load(team.id),
nodeAsInvitedAdmin
);
expect(teamAsInvitedAdmin.teamMap.get(invitedAdminID)).toEqual("admin");
expect(
teamAsInvitedAdmin.teamMap.coValue.getCurrentReadKey().secret
).toBeDefined();
teamAsInvitedAdmin.addMember(thirdAdminID, "admin");
expect(teamAsInvitedAdmin.teamMap.get(thirdAdminID)).toEqual("admin");
});
test("Admins can create a writerInvite, which can add a writer", () => {
const { node, team, admin } = newTeam();
const inviteSecret = newRandomAgentSecret();
const inviteID = getAgentID(inviteSecret);
expectTeamContent(team.getCurrentContent()).edit((editable) => {
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
const revelation = seal(
readKey,
admin.currentSealerSecret(),
admin.currentSealerID(),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
editable.set("readKey", readKeyID, "trusting");
editable.set(inviteID, "writerInvite", "trusting");
expect(editable.get(inviteID)).toEqual("writerInvite");
const revelationForInvite = seal(
readKey,
admin.currentSealerSecret(),
getAgentSealerID(inviteID),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(
`${readKeyID}_for_${inviteID}`,
revelationForInvite,
"trusting"
);
});
const teamAsInvite = team.testWithDifferentAccount(
new AnonymousControlledAccount(inviteSecret),
newRandomSessionID(inviteID)
);
const invitedWriterSecret = newRandomAgentSecret();
const invitedWriterID = getAgentID(invitedWriterSecret);
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
editable.set(invitedWriterID, "writer", "trusting");
expect(editable.get(invitedWriterID)).toEqual("writer");
const readKey = teamAsInvite.getCurrentReadKey();
expect(readKey.secret).toBeDefined();
const revelation = seal(
readKey.secret!,
getAgentSealerSecret(invitedWriterSecret),
getAgentSealerID(invitedWriterID),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(
`${readKey.id}_for_${invitedWriterID}`,
revelation,
"trusting"
);
expect(editable.get(`${readKey.id}_for_${invitedWriterID}`)).toEqual(
revelation
);
});
});
test("Admins can create a writerInvite, which can add a writer (high-level)", async () => {
const { node, team, admin } = newTeamHighLevel();
const inviteSecret = team.createInvite("writer");
const invitedWriterSecret = newRandomAgentSecret();
const invitedWriterID = getAgentID(invitedWriterSecret);
const nodeAsInvitedWriter = node.testWithDifferentAccount(
new AnonymousControlledAccount(invitedWriterSecret),
newRandomSessionID(invitedWriterID)
);
await nodeAsInvitedWriter.acceptInvite(team.id, inviteSecret);
const teamAsInvitedWriter = new Team(
await nodeAsInvitedWriter.load(team.id),
nodeAsInvitedWriter
);
expect(teamAsInvitedWriter.teamMap.get(invitedWriterID)).toEqual("writer");
expect(
teamAsInvitedWriter.teamMap.coValue.getCurrentReadKey().secret
).toBeDefined();
});
test("Admins can create a readerInvite, which can add a reader", () => {
const { node, team, admin } = newTeam();
const inviteSecret = newRandomAgentSecret();
const inviteID = getAgentID(inviteSecret);
expectTeamContent(team.getCurrentContent()).edit((editable) => {
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
const revelation = seal(
readKey,
admin.currentSealerSecret(),
admin.currentSealerID(),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
editable.set("readKey", readKeyID, "trusting");
editable.set(inviteID, "readerInvite", "trusting");
expect(editable.get(inviteID)).toEqual("readerInvite");
const revelationForInvite = seal(
readKey,
admin.currentSealerSecret(),
getAgentSealerID(inviteID),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(
`${readKeyID}_for_${inviteID}`,
revelationForInvite,
"trusting"
);
});
const teamAsInvite = team.testWithDifferentAccount(
new AnonymousControlledAccount(inviteSecret),
newRandomSessionID(inviteID)
);
const invitedReaderSecret = newRandomAgentSecret();
const invitedReaderID = getAgentID(invitedReaderSecret);
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
editable.set(invitedReaderID, "reader", "trusting");
expect(editable.get(invitedReaderID)).toEqual("reader");
const readKey = teamAsInvite.getCurrentReadKey();
expect(readKey.secret).toBeDefined();
const revelation = seal(
readKey.secret!,
getAgentSealerSecret(invitedReaderSecret),
getAgentSealerID(invitedReaderID),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(
`${readKey.id}_for_${invitedReaderID}`,
revelation,
"trusting"
);
expect(editable.get(`${readKey.id}_for_${invitedReaderID}`)).toEqual(
revelation
);
});
});
test("Admins can create a readerInvite, which can add a reader (high-level)", async () => {
const { node, team, admin } = newTeamHighLevel();
const inviteSecret = team.createInvite("reader");
const invitedReaderSecret = newRandomAgentSecret();
const invitedReaderID = getAgentID(invitedReaderSecret);
const nodeAsInvitedReader = node.testWithDifferentAccount(
new AnonymousControlledAccount(invitedReaderSecret),
newRandomSessionID(invitedReaderID)
);
await nodeAsInvitedReader.acceptInvite(team.id, inviteSecret);
const teamAsInvitedReader = new Team(
await nodeAsInvitedReader.load(team.id),
nodeAsInvitedReader
);
expect(teamAsInvitedReader.teamMap.get(invitedReaderID)).toEqual("reader");
expect(
teamAsInvitedReader.teamMap.coValue.getCurrentReadKey().secret
).toBeDefined();
});
test("WriterInvites can not invite admins", () => {
const { node, team, admin } = newTeam();
const inviteSecret = newRandomAgentSecret();
const inviteID = getAgentID(inviteSecret);
expectTeamContent(team.getCurrentContent()).edit((editable) => {
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
const revelation = seal(
readKey,
admin.currentSealerSecret(),
admin.currentSealerID(),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
editable.set("readKey", readKeyID, "trusting");
editable.set(inviteID, "writerInvite", "trusting");
expect(editable.get(inviteID)).toEqual("writerInvite");
const revelationForInvite = seal(
readKey,
admin.currentSealerSecret(),
getAgentSealerID(inviteID),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(
`${readKeyID}_for_${inviteID}`,
revelationForInvite,
"trusting"
);
});
const teamAsInvite = team.testWithDifferentAccount(
new AnonymousControlledAccount(inviteSecret),
newRandomSessionID(inviteID)
);
const invitedAdminSecret = newRandomAgentSecret();
const invitedAdminID = getAgentID(invitedAdminSecret);
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
editable.set(invitedAdminID, "admin", "trusting");
expect(editable.get(invitedAdminID)).toBeUndefined();
});
});
test("ReaderInvites can not invite admins", () => {
const { node, team, admin } = newTeam();
const inviteSecret = newRandomAgentSecret();
const inviteID = getAgentID(inviteSecret);
expectTeamContent(team.getCurrentContent()).edit((editable) => {
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
const revelation = seal(
readKey,
admin.currentSealerSecret(),
admin.currentSealerID(),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
editable.set("readKey", readKeyID, "trusting");
editable.set(inviteID, "readerInvite", "trusting");
expect(editable.get(inviteID)).toEqual("readerInvite");
const revelationForInvite = seal(
readKey,
admin.currentSealerSecret(),
getAgentSealerID(inviteID),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(
`${readKeyID}_for_${inviteID}`,
revelationForInvite,
"trusting"
);
});
const teamAsInvite = team.testWithDifferentAccount(
new AnonymousControlledAccount(inviteSecret),
newRandomSessionID(inviteID)
);
const invitedAdminSecret = newRandomAgentSecret();
const invitedAdminID = getAgentID(invitedAdminSecret);
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
editable.set(invitedAdminID, "admin", "trusting");
expect(editable.get(invitedAdminID)).toBeUndefined();
});
});
test("ReaderInvites can not invite writers", () => {
const { node, team, admin } = newTeam();
const inviteSecret = newRandomAgentSecret();
const inviteID = getAgentID(inviteSecret);
expectTeamContent(team.getCurrentContent()).edit((editable) => {
const { secret: readKey, id: readKeyID } = newRandomKeySecret();
const revelation = seal(
readKey,
admin.currentSealerSecret(),
admin.currentSealerID(),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
editable.set("readKey", readKeyID, "trusting");
editable.set(inviteID, "readerInvite", "trusting");
expect(editable.get(inviteID)).toEqual("readerInvite");
const revelationForInvite = seal(
readKey,
admin.currentSealerSecret(),
getAgentSealerID(inviteID),
{
in: team.id,
tx: team.nextTransactionID(),
}
);
editable.set(
`${readKeyID}_for_${inviteID}`,
revelationForInvite,
"trusting"
);
});
const teamAsInvite = team.testWithDifferentAccount(
new AnonymousControlledAccount(inviteSecret),
newRandomSessionID(inviteID)
);
const invitedWriterSecret = newRandomAgentSecret();
const invitedWriterID = getAgentID(invitedWriterSecret);
expectTeamContent(teamAsInvite.getCurrentContent()).edit((editable) => {
editable.set(invitedWriterID, "writer", "trusting");
expect(editable.get(invitedWriterID)).toBeUndefined();
});
});

View File

@@ -1,16 +1,8 @@
import { CoID, ContentType } from "./contentType.js";
import { CoMap, MapOpPayload } from "./contentTypes/coMap.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import { CoID } from "./contentType.js";
import { MapOpPayload } from "./contentTypes/coMap.js";
import { JsonValue } from "./jsonValue.js";
import {
Encrypted,
KeyID,
KeySecret,
createdNowUnique,
newRandomKeySecret,
seal,
encryptKeySecret,
getAgentSealerID,
Sealed,
} from "./crypto.js";
import {
CoValue,
@@ -18,16 +10,25 @@ import {
TrustingTransaction,
accountOrAgentIDfromSessionID,
} from "./coValue.js";
import { LocalNode } from "./node.js";
import { RawCoID, SessionID, TransactionID, isAgentID } from "./ids.js";
import { AccountIDOrAgentID, GeneralizedControlledAccount, Profile } from "./account.js";
import { RawCoID, SessionID, TransactionID } from "./ids.js";
import {
AccountIDOrAgentID,
Profile,
} from "./account.js";
export type PermissionsDef =
| { type: "team"; initialAdmin: AccountIDOrAgentID }
| { type: "ownedByTeam"; team: RawCoID }
| { type: "unsafeAllowAll" };
export type Role = "reader" | "writer" | "admin" | "revoked";
export type Role =
| "reader"
| "writer"
| "admin"
| "revoked"
| "adminInvite"
| "writerInvite"
| "readerInvite";
export function determineValidTransactions(
coValue: CoValue
@@ -84,7 +85,7 @@ export function determineValidTransactions(
continue;
}
if (change.op !== "insert") {
if (change.op !== "set") {
console.warn("Team transaction must set a role or readKey");
continue;
}
@@ -97,7 +98,7 @@ export function determineValidTransactions(
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (change.key === 'profile') {
} else if (change.key === "profile") {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set profile");
continue;
@@ -105,8 +106,16 @@ export function determineValidTransactions(
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (isKeyForKeyField(change.key) || isKeyForAccountField(change.key)) {
if (memberState[transactor] !== "admin") {
} else if (
isKeyForKeyField(change.key) ||
isKeyForAccountField(change.key)
) {
if (
memberState[transactor] !== "admin" &&
memberState[transactor] !== "adminInvite" &&
memberState[transactor] !== "writerInvite" &&
memberState[transactor] !== "readerInvite"
) {
console.warn("Only admins can reveal keys");
continue;
}
@@ -124,7 +133,10 @@ export function determineValidTransactions(
change.value !== "admin" &&
change.value !== "writer" &&
change.value !== "reader" &&
change.value !== "revoked"
change.value !== "revoked" &&
change.value !== "adminInvite" &&
change.value !== "writerInvite" &&
change.value !== "readerInvite"
) {
console.warn("Team transaction must set a valid role");
continue;
@@ -133,26 +145,41 @@ export function determineValidTransactions(
const isFirstSelfAppointment =
!memberState[transactor] &&
transactor === initialAdmin &&
change.op === "insert" &&
change.op === "set" &&
change.key === transactor &&
change.value === "admin";
if (!isFirstSelfAppointment) {
if (memberState[transactor] !== "admin") {
if (memberState[transactor] === "admin") {
if (
memberState[affectedMember] === "admin" &&
affectedMember !== transactor &&
assignedRole !== "admin"
) {
console.warn("Admins can only demote themselves.");
continue;
}
} else if (memberState[transactor] === "adminInvite") {
if (change.value !== "admin") {
console.warn("AdminInvites can only create admins.");
continue;
}
} else if (memberState[transactor] === "writerInvite") {
if (change.value !== "writer") {
console.warn("WriterInvites can only create writers.");
continue;
}
} else if (memberState[transactor] === "readerInvite") {
if (change.value !== "reader") {
console.warn("ReaderInvites can only create reader.");
continue;
}
} else {
console.warn(
"Team transaction must be made by current admin"
"Team transaction must be made by current admin or invite"
);
continue;
}
if (
memberState[affectedMember] === "admin" &&
affectedMember !== transactor &&
assignedRole !== "admin"
) {
console.warn("Admins can only demote themselves.");
continue;
}
}
memberState[affectedMember] = change.value;
@@ -213,180 +240,17 @@ export function determineValidTransactions(
}
}
export type TeamContent = {
profile: CoID<Profile> | null;
[key: AccountIDOrAgentID]: Role;
readKey: KeyID;
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
KeySecret,
{ encryptedID: KeyID; encryptingID: KeyID }
>;
};
export function expectTeamContent(
content: ContentType
): CoMap<TeamContent, JsonObject | null> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<TeamContent, JsonObject | null>;
}
export class Team {
teamMap: CoMap<TeamContent, JsonObject | null>;
node: LocalNode;
constructor(teamMap: CoMap<TeamContent, JsonObject | null>, node: LocalNode) {
this.teamMap = teamMap;
this.node = node;
}
get id(): CoID<CoMap<TeamContent, JsonObject | null>> {
return this.teamMap.id;
}
addMember(accountID: AccountIDOrAgentID, role: Role) {
this.teamMap = this.teamMap.edit((map) => {
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
if (!currentReadKey.secret) {
throw new Error("Can't add member without read key secret");
}
const agent = this.node.resolveAccountAgent(
accountID,
"Expected to know agent to add them to team"
);
map.set(accountID, role, "trusting");
if (map.get(accountID) !== role) {
throw new Error("Failed to set role");
}
map.set(
`${currentReadKey.id}_for_${accountID}`,
seal(
currentReadKey.secret,
this.teamMap.coValue.node.account.currentSealerSecret(),
getAgentSealerID(agent),
{
in: this.teamMap.coValue.id,
tx: this.teamMap.coValue.nextTransactionID(),
}
),
"trusting"
);
});
}
rotateReadKey() {
const currentlyPermittedReaders = this.teamMap.keys().filter((key) => {
if (key.startsWith("co_") || isAgentID(key)) {
const role = this.teamMap.get(key);
return (
role === "admin" || role === "writer" || role === "reader"
);
} else {
return false;
}
}) as AccountIDOrAgentID[];
const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey();
if (!maybeCurrentReadKey.secret) {
throw new Error(
"Can't rotate read key secret we don't have access to"
);
}
const currentReadKey = {
id: maybeCurrentReadKey.id,
secret: maybeCurrentReadKey.secret,
};
const newReadKey = newRandomKeySecret();
this.teamMap = this.teamMap.edit((map) => {
for (const readerID of currentlyPermittedReaders) {
const reader = this.node.resolveAccountAgent(
readerID,
"Expected to know currently permitted reader"
);
map.set(
`${newReadKey.id}_for_${readerID}`,
seal(
newReadKey.secret,
this.teamMap.coValue.node.account.currentSealerSecret(),
getAgentSealerID(reader),
{
in: this.teamMap.coValue.id,
tx: this.teamMap.coValue.nextTransactionID(),
}
),
"trusting"
);
}
map.set(
`${currentReadKey.id}_for_${newReadKey.id}`,
encryptKeySecret({
encrypting: newReadKey,
toEncrypt: currentReadKey,
}).encrypted,
"trusting"
);
map.set("readKey", newReadKey.id, "trusting");
});
}
removeMember(accountID: AccountIDOrAgentID) {
this.teamMap = this.teamMap.edit((map) => {
map.set(accountID, "revoked", "trusting");
});
this.rotateReadKey();
}
createMap<M extends { [key: string]: JsonValue }, Meta extends JsonObject | null = null>(
meta?: Meta
): CoMap<M, Meta> {
return this.node
.createCoValue({
type: "comap",
ruleset: {
type: "ownedByTeam",
team: this.teamMap.id,
},
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as CoMap<M, Meta>;
}
testWithDifferentAccount(
account: GeneralizedControlledAccount,
sessionId: SessionID
): Team {
return new Team(
expectTeamContent(
this.teamMap.coValue
.testWithDifferentAccount(account, sessionId)
.getCurrentContent()
),
this.node
);
}
}
export function isKeyForKeyField(field: string): field is `${KeyID}_for_${KeyID}` {
export function isKeyForKeyField(
field: string
): field is `${KeyID}_for_${KeyID}` {
return field.startsWith("key_") && field.includes("_for_key");
}
export function isKeyForAccountField(field: string): field is `${KeyID}_for_${AccountIDOrAgentID}` {
return field.startsWith("key_") && (field.includes("_for_sealer") || field.includes("_for_co"));
}
export function isKeyForAccountField(
field: string
): field is `${KeyID}_for_${AccountIDOrAgentID}` {
return (
field.startsWith("key_") &&
(field.includes("_for_sealer") || field.includes("_for_co"))
);
}

View File

@@ -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
View File

@@ -0,0 +1,233 @@
import { CoID, ContentType } from "./contentType.js";
import { CoMap } from "./contentTypes/coMap.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import {
Encrypted,
KeyID,
KeySecret,
createdNowUnique,
newRandomKeySecret,
seal,
encryptKeySecret,
getAgentSealerID,
Sealed,
newRandomSecretSeed,
agentSecretFromSecretSeed,
getAgentID,
} from "./crypto.js";
import { LocalNode } from "./node.js";
import { SessionID, isAgentID } from "./ids.js";
import {
AccountIDOrAgentID,
GeneralizedControlledAccount,
Profile,
} from "./account.js";
import { Role } from "./permissions.js";
import { base58 } from "@scure/base";
export type TeamContent = {
profile: CoID<Profile> | null;
[key: AccountIDOrAgentID]: Role;
readKey: KeyID;
[revelationFor: `${KeyID}_for_${AccountIDOrAgentID}`]: Sealed<KeySecret>;
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
KeySecret,
{ encryptedID: KeyID; encryptingID: KeyID }
>;
};
export function expectTeamContent(
content: ContentType
): CoMap<TeamContent, JsonObject | null> {
if (content.type !== "comap") {
throw new Error("Expected map");
}
return content as CoMap<TeamContent, JsonObject | null>;
}
export class Team {
teamMap: CoMap<TeamContent, JsonObject | null>;
node: LocalNode;
constructor(
teamMap: CoMap<TeamContent, JsonObject | null>,
node: LocalNode
) {
this.teamMap = teamMap;
this.node = node;
}
get id(): CoID<CoMap<TeamContent, JsonObject | null>> {
return this.teamMap.id;
}
roleOf(accountID: AccountIDOrAgentID): Role | undefined {
return this.teamMap.get(accountID);
}
myRole(): Role | undefined {
return this.roleOf(this.node.account.id);
}
addMember(accountID: AccountIDOrAgentID, role: Role) {
this.teamMap = this.teamMap.edit((map) => {
const currentReadKey = this.teamMap.coValue.getCurrentReadKey();
if (!currentReadKey.secret) {
throw new Error("Can't add member without read key secret");
}
const agent = this.node.resolveAccountAgent(
accountID,
"Expected to know agent to add them to team"
);
map.set(accountID, role, "trusting");
if (map.get(accountID) !== role) {
throw new Error("Failed to set role");
}
map.set(
`${currentReadKey.id}_for_${accountID}`,
seal(
currentReadKey.secret,
this.teamMap.coValue.node.account.currentSealerSecret(),
getAgentSealerID(agent),
{
in: this.teamMap.coValue.id,
tx: this.teamMap.coValue.nextTransactionID(),
}
),
"trusting"
);
});
}
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
const secretSeed = newRandomSecretSeed();
const inviteSecret = agentSecretFromSecretSeed(secretSeed);
const inviteID = getAgentID(inviteSecret);
this.addMember(inviteID, `${role}Invite` as Role);
return inviteSecretFromSecretSeed(secretSeed);
}
rotateReadKey() {
const currentlyPermittedReaders = this.teamMap.keys().filter((key) => {
if (key.startsWith("co_") || isAgentID(key)) {
const role = this.teamMap.get(key);
return (
role === "admin" || role === "writer" || role === "reader"
);
} else {
return false;
}
}) as AccountIDOrAgentID[];
const maybeCurrentReadKey = this.teamMap.coValue.getCurrentReadKey();
if (!maybeCurrentReadKey.secret) {
throw new Error(
"Can't rotate read key secret we don't have access to"
);
}
const currentReadKey = {
id: maybeCurrentReadKey.id,
secret: maybeCurrentReadKey.secret,
};
const newReadKey = newRandomKeySecret();
this.teamMap = this.teamMap.edit((map) => {
for (const readerID of currentlyPermittedReaders) {
const reader = this.node.resolveAccountAgent(
readerID,
"Expected to know currently permitted reader"
);
map.set(
`${newReadKey.id}_for_${readerID}`,
seal(
newReadKey.secret,
this.teamMap.coValue.node.account.currentSealerSecret(),
getAgentSealerID(reader),
{
in: this.teamMap.coValue.id,
tx: this.teamMap.coValue.nextTransactionID(),
}
),
"trusting"
);
}
map.set(
`${currentReadKey.id}_for_${newReadKey.id}`,
encryptKeySecret({
encrypting: newReadKey,
toEncrypt: currentReadKey,
}).encrypted,
"trusting"
);
map.set("readKey", newReadKey.id, "trusting");
});
}
removeMember(accountID: AccountIDOrAgentID) {
this.teamMap = this.teamMap.edit((map) => {
map.set(accountID, "revoked", "trusting");
});
this.rotateReadKey();
}
createMap<
M extends { [key: string]: JsonValue },
Meta extends JsonObject | null = null
>(meta?: Meta): CoMap<M, Meta> {
return this.node
.createCoValue({
type: "comap",
ruleset: {
type: "ownedByTeam",
team: this.teamMap.id,
},
meta: meta || null,
...createdNowUnique(),
})
.getCurrentContent() as CoMap<M, Meta>;
}
testWithDifferentAccount(
account: GeneralizedControlledAccount,
sessionId: SessionID
): Team {
return new Team(
expectTeamContent(
this.teamMap.coValue
.testWithDifferentAccount(account, sessionId)
.getCurrentContent()
),
this.node
);
}
}
export type InviteSecret = `inviteSecret_z${string}`;
function inviteSecretFromSecretSeed(secretSeed: Uint8Array): InviteSecret {
return `inviteSecret_z${base58.encode(secretSeed)}`;
}
export function secretSeedFromInviteSecret(inviteSecret: InviteSecret) {
if (!inviteSecret.startsWith("inviteSecret_z")) {
throw new Error("Invalid invite secret");
}
return base58.decode(inviteSecret.slice("inviteSecret_z".length));
}

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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": {

View File

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

View File

@@ -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": {

View File

@@ -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": {

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

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