Compare commits

...

4 Commits

Author SHA1 Message Date
Guido D'Orsi
b5db0c2d23 feat: add a quest board example app to test the writeOnly role 2024-12-11 17:52:18 +01:00
Guido D'Orsi
4eea7a56f2 test: add e2e tests for the writeOnly role 2024-12-11 17:51:25 +01:00
Guido D'Orsi
575419cf2a fix: add writeOnly to createInvite types 2024-12-11 13:12:54 +01:00
Guido D'Orsi
dbd20bcb2b feat: add writeOnly role on groups 2024-12-11 12:04:15 +01:00
45 changed files with 2769 additions and 373 deletions

31
examples/quest-board/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
sync-db/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -0,0 +1,35 @@
# Quest board example with Jazz and React
## Installing & running the example locally
(This requires `pnpm` to be installed, see [https://pnpm.io/installation](https://pnpm.io/installation))
Start by downloading the [jazz repository](https://github.com/garden-co/jazz):
```bash
npx degit gardencmp/jazz jazz
```
Go to the chat example directory:
```bash
cd jazz/examples/chat
```
Install and build dependencies:
```bash
pnpm i && npx turbo build
```
Start the dev server:
```bash
pnpm dev
```
## Questions / problems / feedback
If you have feedback, let us know on [Discord](https://discord.gg/utDMjHYg42) or open an issue or PR to fix something that seems wrong.
## Configuration: sync server
By default, the example app uses [Jazz Cloud](https://jazz.tools/cloud) (`wss://cloud.jazz.tools`) - so cross-device use, invites and collaboration should just work.
You can also run a local sync server by running `npx jazz-run sync` and adding the query param `?sync=ws://localhost:4200` to the URL of the example app (for example: `http://localhost:5173/?peer=ws://localhost:4200`), or by setting the `sync` parameter of the `<Jazz.Provider>` provider component in [./src/main.tsx](./src/main.tsx).

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "stone",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/jazz-logo.png" />
<link rel="stylesheet" href="/src/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jazz Quest Board Example</title>
</head>
<body class="h-full">
<div id="root" class="h-full"></div>
<script type="module" src="/src/app.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,50 @@
{
"name": "jazz-example-quest-board",
"private": true,
"version": "0.0.116",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.1.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cojson": "workspace:*",
"hash-slash": "workspace:0.2.1",
"jazz-browser-media-images": "^0.8.38",
"jazz-react": "workspace:*",
"jazz-tools": "workspace:*",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router": "^6.16.0",
"react-router-dom": "^6.16.0",
"react-use": "^17.4.0",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"uniqolor": "^1.1.0"
},
"devDependencies": {
"@playwright/test": "^1.46.1",
"@types/qrcode": "^1.5.1",
"@types/react": "^18.2.19",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"is-ci": "^3.0.1",
"postcss": "^8.4.27",
"tailwindcss": "3.3.2",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@@ -0,0 +1,53 @@
import { defineConfig, devices } from "@playwright/test";
import isCI from "is-ci";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: isCI,
/* Retry on CI only */
retries: isCI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: isCI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:5173/",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
permissions: ["clipboard-read", "clipboard-write"],
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
/* Run your local dev server before starting the tests */
webServer: [
{
command: "pnpm preview --port 5173",
url: "http://localhost:5173/",
reuseExistingServer: !isCI,
},
],
});

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -0,0 +1,48 @@
import { useIframeHashRouter } from "hash-slash";
import { DemoAuthBasicUI, createJazzReactApp, useDemoAuth } from "jazz-react";
import { ID } from "jazz-tools";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import GuildMasterPage from "./app/GuildMasterPage.tsx";
import InvitePage from "./app/InvitePage.tsx";
import ProposalPage from "./app/ProposalPage.tsx";
import { QuestBoard, QuestBoardAccount } from "./schema.ts";
const Jazz = createJazzReactApp({
AccountSchema: QuestBoardAccount,
});
export const { useAccount, useCoState, useAcceptInvite } = Jazz;
function App() {
const [auth, state] = useDemoAuth();
const router = useIframeHashRouter();
return (
<>
<Jazz.Provider
auth={auth}
// peer="wss://cloud.jazz.tools/?key=chat-example-jazz@garden.co"
peer="ws://localhost:4200"
>
{router.route({
"/": () => <GuildMasterPage />,
"/invite/:id": () => (
<InvitePage
onAcceptInvite={(id) => router.navigate(`/#/board/${id}`)}
/>
),
"/board/:id": (id) => <ProposalPage boardID={id as ID<QuestBoard>} />,
})}
</Jazz.Provider>
{state.state !== "signedIn" && (
<DemoAuthBasicUI appName="Jazz Quest Board" state={state} />
)}
</>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,112 @@
import { useAccount } from "@/app";
import { PublicQuest, QuestProposal, QuestRating } from "@/schema";
import { createInviteLink } from "jazz-react";
import { QuestCard } from "./components/quest-card";
import { QuestProposalCard } from "./components/quest-proposal-card";
export default function GuildMasterPage() {
const { me } = useAccount({
root: {
publicQuests: [{}],
proposals: [{}],
},
});
const board = me?.root;
const proposals = board?.proposals;
const publicQuests = board?.publicQuests;
const publicGroup = board?._owner;
const handleApprove = (proposal: QuestProposal) => {
if (!publicGroup || !publicQuests) return;
const author = proposal._owner.profile;
const publicQuest = PublicQuest.create(
{
title: proposal.title,
description: proposal.description,
proposal,
author,
},
{ owner: publicGroup },
);
proposal.status = "accepted";
proposal.publicQuest = publicQuest;
publicQuests.push(publicQuest);
};
const handleReject = (proposal: QuestProposal) => {
proposal.status = "rejected";
};
const handleRate = (quest: PublicQuest, rating: QuestRating) => {
quest.rating = rating;
};
const handleShare = async () => {
if (!proposals) return;
const invite = createInviteLink(proposals, "writeOnly");
const url = new URL(invite);
url.searchParams.set("board", board.id);
await proposals._owner.waitForSync();
navigator.clipboard.writeText(url.toString());
alert(`Invite link copied to clipboard`);
};
return (
<div className="min-h-screen bg-[url('/parchment-background.jpg')] bg-cover bg-center">
<main className="container mx-auto p-8">
<h1 className="text-4xl font-bold mb-8 text-center text-amber-900 font-serif">
Guild Master's Quarters
</h1>
<div className="mb-8 text-center">
<button
className="bg-amber-700 text-amber-100 px-6 py-3 rounded-full hover:bg-amber-800 transition-colors font-semibold text-lg shadow-lg"
onClick={handleShare}
>
Share
</button>
</div>
<div>
<h2 className="text-2xl font-semibold mb-4 text-amber-900 font-serif">
Available Quests
</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{publicQuests?.map((quest) => (
<QuestCard
key={quest.id}
quest={quest}
isGuildMaster={true}
onRate={handleRate}
/>
))}
</div>
</div>
<div>
<h2 className="text-2xl font-semibold mb-4 text-amber-900 font-serif">
Quest Proposals Awaiting Review
</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{proposals?.map((proposal) =>
proposal.status === "pending" ? (
<QuestProposalCard
key={proposal.id}
proposal={proposal}
isGuildMaster={true}
onApprove={handleApprove}
onReject={handleReject}
/>
) : null,
)}
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { useAcceptInvite } from "@/app";
import { QuestBoard, QuestProposals } from "@/schema";
import { ID } from "jazz-tools";
export default function InvitePage({
onAcceptInvite,
}: { onAcceptInvite: (boardId: ID<QuestBoard>) => void }) {
useAcceptInvite({
invitedObjectSchema: QuestProposals,
onAccept: () => {
const boardId = new URL(window.location.href).searchParams.get("board");
if (!boardId) return;
onAcceptInvite(boardId as ID<QuestBoard>);
},
});
return null;
}

View File

@@ -0,0 +1,88 @@
import { useCoState } from "@/app";
import { useQuestProposalDraft } from "@/lib/useQuestProposalDraft";
import { QuestBoard, QuestProposal } from "@/schema";
import { ID } from "jazz-tools";
import { QuestCard } from "./components/quest-card";
import { QuestProposalCard } from "./components/quest-proposal-card";
import { QuestProposalForm } from "./components/quest-proposal-form";
export default function ProposalPage({ boardID }: { boardID: ID<QuestBoard> }) {
const board = useCoState(QuestBoard, boardID, {
proposals: [{}],
publicQuests: [{}],
});
const { draftProposal, clearDraftProposal } = useQuestProposalDraft();
const handleSubmitProposal = () => {
if (!board || !draftProposal) return;
if (!draftProposal.title || !draftProposal.description) return;
const ownership = { owner: board.proposals._owner };
const proposal = QuestProposal.create(
{
title: draftProposal.title,
description: draftProposal.description,
status: "pending",
},
ownership,
);
clearDraftProposal();
board.proposals.push(proposal);
};
return (
<div className="min-h-screen bg-[url('/parchment-background.jpg')] bg-cover bg-center">
<main className="container mx-auto p-8">
<h1 className="text-4xl font-bold mb-8 text-center text-amber-900 font-serif">
Public Fantasy Quest Board
</h1>
<div className="mb-8 text-center">
<a
href="/"
className="bg-amber-700 text-amber-100 px-6 py-3 rounded-full hover:bg-amber-800 transition-colors font-semibold text-lg shadow-lg"
>
Go back to your board
</a>
</div>
<div>
<h2 className="text-2xl font-semibold mb-4 text-amber-900 font-serif">
Public board
</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{board?.publicQuests.map((quest) => (
<QuestCard key={quest.id} quest={quest} isGuildMaster={false} />
))}
</div>
</div>
<div className="mb-8 bg-amber-50 p-6 rounded-lg shadow-md">
<h2 className="text-2xl font-semibold mb-4 text-amber-900 font-serif">
Submit a Quest Proposal
</h2>
<QuestProposalForm
onSubmit={handleSubmitProposal}
draft={draftProposal}
/>
</div>
<div>
<h2 className="text-2xl font-semibold mb-4 text-amber-900 font-serif">
Your Proposals
</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{board?.proposals.map((proposal) =>
proposal && proposal.status !== "accepted" ? (
<QuestProposalCard
key={proposal.id}
proposal={proposal}
isGuildMaster={false}
/>
) : null,
)}
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-amber-700 text-amber-100 hover:bg-amber-800",
destructive: "bg-red-700 text-red-100 hover:bg-red-800",
outline:
"border border-amber-700 bg-transparent hover:bg-amber-100 text-amber-900",
secondary: "bg-amber-200 text-amber-900 hover:bg-amber-300",
ghost: "hover:bg-amber-100 text-amber-900",
link: "text-amber-900 underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,55 @@
import { Button } from "@/app/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/app/components/ui/card";
import { PublicQuest, QuestRating } from "@/schema";
interface QuestCardProps {
quest: PublicQuest;
isGuildMaster: boolean;
onRate?: (quest: PublicQuest, rating: QuestRating) => void;
}
const RATINGS = ["C", "B", "A", "S", "SS"] as const;
export function QuestCard({ quest, isGuildMaster, onRate }: QuestCardProps) {
return (
<Card className="w-full max-w-md bg-amber-50 border-2 border-amber-700 shadow-lg">
<CardHeader className="bg-amber-100 border-b-2 border-amber-700">
<CardTitle className="text-amber-900 font-serif">
{quest.title}
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<p className="text-sm text-amber-800 mb-4 font-serif">
{quest.description}
</p>
{quest.rating && (
<p className="text-sm font-semibold text-amber-900 font-serif">
Rating: {quest.rating}
</p>
)}
</CardContent>
{isGuildMaster && !quest.rating && (
<CardFooter>
<div className="flex space-x-2">
{RATINGS.map((rating) => (
<Button
key={rating}
onClick={() => onRate?.(quest, rating)}
variant="outline"
className="border-amber-700 text-amber-900 hover:bg-amber-200"
>
{rating}
</Button>
))}
</div>
</CardFooter>
)}
</Card>
);
}

View File

@@ -0,0 +1,69 @@
import { Badge } from "@/app/components/ui/badge";
import { Button } from "@/app/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/app/components/ui/card";
import { QuestProposal } from "@/schema";
interface QuestProposalCardProps {
proposal: QuestProposal;
isGuildMaster: boolean;
onApprove?: (proposal: QuestProposal) => void;
onReject?: (proposal: QuestProposal) => void;
}
export function QuestProposalCard({
proposal,
isGuildMaster,
onApprove,
onReject,
}: QuestProposalCardProps) {
return (
<Card className="w-full max-w-md bg-amber-50 border-2 border-amber-700 shadow-lg">
<CardHeader className="bg-amber-100 border-b-2 border-amber-700">
<CardTitle className="text-amber-900 font-serif">
{proposal.title}
</CardTitle>
<Badge
variant={
proposal.status === "accepted"
? "default"
: proposal.status === "rejected"
? "destructive"
: "secondary"
}
className="font-serif"
>
{proposal.status.charAt(0).toUpperCase() + proposal.status.slice(1)}
</Badge>
</CardHeader>
<CardContent className="pt-4">
<p className="text-sm text-amber-800 mb-4 font-serif">
{proposal.description}
</p>
</CardContent>
{isGuildMaster && proposal.status === "pending" && (
<CardFooter className="flex justify-between">
<Button
onClick={() => onApprove?.(proposal)}
variant="outline"
className="border-amber-700 text-amber-900 hover:bg-amber-200"
>
Approve
</Button>
<Button
onClick={() => onReject?.(proposal)}
variant="outline"
className="border-amber-700 text-amber-900 hover:bg-amber-200"
>
Reject
</Button>
</CardFooter>
)}
</Card>
);
}

View File

@@ -0,0 +1,55 @@
import { Button } from "@/app/components/ui/button";
import { Input } from "@/app/components/ui/input";
import { Textarea } from "@/app/components/ui/textarea";
import { QuestProposalDraft } from "@/schema";
export function QuestProposalForm({
onSubmit,
draft,
}: { onSubmit: () => void; draft: QuestProposalDraft | undefined }) {
if (!draft) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit();
};
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
draft.title = e.target.value;
};
const handleDescriptionChange = (
e: React.ChangeEvent<HTMLTextAreaElement>,
) => {
draft.description = e.target.value;
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="text"
placeholder="Quest Title"
aria-label="Quest Title"
value={draft.title ?? ""}
onChange={handleTitleChange}
required
className="bg-amber-100 border-amber-300 text-amber-900 placeholder-amber-500"
/>
<Textarea
placeholder="Quest Description"
aria-label="Quest Description"
value={draft.description ?? ""}
onChange={handleDescriptionChange}
required
className="bg-amber-100 border-amber-300 text-amber-900 placeholder-amber-500"
/>
<Button
type="submit"
className="w-full bg-amber-700 hover:bg-amber-800 text-amber-100"
>
Submit Quest Proposal
</Button>
</form>
);
}

View File

@@ -0,0 +1,36 @@
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,56 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,86 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };

View File

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

View File

@@ -0,0 +1,31 @@
import { useAccount } from "@/app";
import { QuestProposalDraft } from "@/schema";
import { useEffect } from "react";
export function useQuestProposalDraft() {
const { me } = useAccount({
root: {
draftProposal: {},
},
});
useEffect(() => {
if (!me?.root) return;
if (me.root.draftProposal) return;
me.root.draftProposal = QuestProposalDraft.create({}, { owner: me });
}, [me?.root]);
function clearDraftProposal() {
if (!me?.root) return;
if (!me.root.draftProposal) return;
// @ts-expect-error We should accept null values and reject undefined values
me.root.draftProposal = null;
}
return {
draftProposal: me?.root.draftProposal,
clearDraftProposal,
};
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,56 @@
import { Account, CoList, CoMap, Group, Profile, co } from "jazz-tools";
export class QuestProposalDraft extends CoMap {
title = co.optional.string;
description = co.optional.string;
}
export class QuestProposal extends CoMap {
title = co.string;
description = co.string;
status = co.json<"rejected" | "accepted" | "pending">();
publicQuest = co.optional.ref(PublicQuest);
}
export type QuestRating = "C" | "B" | "A" | "S" | "SS";
export class PublicQuest extends CoMap {
title = co.string;
description = co.string;
proposal = co.ref(QuestProposal);
rating = co.optional.json<QuestRating>();
author = co.optional.ref(Profile);
}
export class QuestProposals extends CoList.Of(co.ref(QuestProposal)) {}
export class PublicQuests extends CoList.Of(co.ref(PublicQuest)) {}
export class QuestBoard extends CoMap {
proposals = co.ref(QuestProposals);
publicQuests = co.ref(PublicQuests);
draftProposal = co.optional.ref(QuestProposalDraft);
}
export class QuestBoardAccount extends Account {
profile = co.ref(Profile);
root = co.ref(QuestBoard);
async migrate(creationProps?: { name: string }) {
super.migrate(creationProps);
if (!this._refs.root) {
const publicOwnership = { owner: Group.create({ owner: this }) };
const proposalsOwnership = { owner: Group.create({ owner: this }) };
publicOwnership.owner.addMember("everyone", "reader");
this.root = QuestBoard.create(
{
proposals: QuestProposals.create([], proposalsOwnership),
publicQuests: PublicQuests.create([], publicOwnership),
},
publicOwnership,
);
}
}
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,82 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
blue: {
700: "#3313F7",
DEFAULT: "#3313F7",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
lineClamp: {
10: "10",
},
},
},
plugins: [require("tailwindcss-animate")],
};

View File

@@ -0,0 +1,64 @@
import { Browser, expect, test } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";
async function createIsolatedPage(browser: Browser) {
const context = await browser.newContext();
const page = await context.newPage();
return page;
}
test("interaction between three users", async ({ browser, page }) => {
const users = {
guildMaster: page,
player1: await createIsolatedPage(browser),
player2: await createIsolatedPage(browser),
};
for (const [name, page] of Object.entries(users)) {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.fillUsername(name);
await loginPage.signup();
}
await users.guildMaster.getByRole("button", { name: "Share" }).click();
// Wait for the share to be completed
await users.guildMaster.waitForFunction(() => {
return Boolean(navigator.clipboard.readText());
});
const inviteLink = await users.guildMaster.evaluate(() => {
return navigator.clipboard.readText();
});
// Invite users to the board
await users.player1.goto(inviteLink);
await users.player2.goto(inviteLink);
// Player 1 submits a quest
await users.player1.getByLabel("Quest Title").fill("Quest From Player 1");
await users.player1
.getByLabel("Quest Description")
.fill("Quest Description From Player 1");
await users.player1
.getByRole("button", { name: "Submit Quest Proposal" })
.click();
// The guild master can see the quest proposal
expect(users.guildMaster.getByText("Quest From Player 1")).toBeVisible();
// The player their own quest proposal
expect(users.player1.getByText("Quest From Player 1")).toBeVisible();
// The player cannot see the other player's quest proposal
expect(users.player2.getByText("Quest From Player 1")).not.toBeVisible();
await users.guildMaster.getByRole("button", { name: "Approve" }).click();
// Everyone can see the approved quests
expect(users.guildMaster.getByText("Quest From Player 1")).toBeVisible();
expect(users.player2.getByText("Quest From Player 1")).toBeVisible();
});

View File

@@ -0,0 +1,40 @@
import { Locator, Page, expect } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly signupButton: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByRole("textbox");
this.signupButton = page.getByRole("button", {
name: "Sign up",
});
}
async goto() {
this.page.goto("/");
}
async fillUsername(value: string) {
await this.usernameInput.clear();
await this.usernameInput.fill(value);
}
async loginAs(value: string) {
await this.page
.getByRole("button", {
name: `Log in as "${value}"`,
})
.click();
}
async signup() {
await this.signupButton.click();
}
async expectLoaded() {
await expect(this.signupButton).toBeVisible();
}
}

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,16 @@
import path from "path";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
minify: false,
},
});

View File

@@ -684,7 +684,7 @@ export class CoValueCore {
if (this.header.ruleset.type === "group") {
const content = expectGroup(this.getCurrentContent());
const currentKeyId = content.get("readKey");
const currentKeyId = content.getCurrentKeyReadId();
if (!currentKeyId) {
throw new Error("No readKey set");

View File

@@ -13,7 +13,7 @@ import {
isParentGroupReference,
} from "../ids.js";
import { JsonObject } from "../jsonValue.js";
import { Role } from "../permissions.js";
import { AccountRole, Role } from "../permissions.js";
import { expectGroup } from "../typeUtils/expectGroup.js";
import {
ControlledAccountOrAgent,
@@ -33,6 +33,7 @@ export type GroupShape = {
[key: RawAccountID | AgentID]: Role;
[EVERYONE]?: Role;
readKey?: KeyID;
[writeKeyFor: `writeKeyFor_${RawAccountID | AgentID}`]: KeyID;
[revelationFor: `${KeyID}_for_${RawAccountID | AgentID}`]: Sealed<KeySecret>;
[revelationFor: `${KeyID}_for_${Everyone}`]: KeySecret;
[oldKeyForNewKey: `${KeyID}_for_${KeyID}`]: Encrypted<
@@ -213,18 +214,19 @@ export class RawGroup<
account: RawAccount | ControlledAccountOrAgent | AgentID | Everyone,
role: Role,
) {
const currentReadKey = this.core.getCurrentReadKey();
if (!currentReadKey.secret) {
throw new Error("Can't add member without read key secret");
}
if (account === EVERYONE) {
if (!(role === "reader" || role === "writer")) {
throw new Error(
"Can't make everyone something other than reader or writer",
);
}
const currentReadKey = this.core.getCurrentReadKey();
if (!currentReadKey.secret) {
throw new Error("Can't add member without read key secret");
}
this.set(account, role, "trusting");
if (this.get(account) !== role) {
@@ -236,44 +238,168 @@ export class RawGroup<
currentReadKey.secret,
"trusting",
);
return;
}
const memberKey = typeof account === "string" ? account : account.id;
const agent =
typeof account === "string"
? account
: account.currentAgentID()._unsafeUnwrap({ withStackTrace: true });
/**
* WriteOnly members can only see their own changes.
*
* We don't want to reveal the readKey to them so we create a new one and reveal it
* to everyone else.
*
* To never reveal the readKey to writeOnly members we also create a dedicated writeKey for the
* invite.
*/
if (role === "writeOnly" || role === "writeOnlyInvite") {
const writeKeyForNewMember = this.core.crypto.newRandomKeySecret();
this.set(memberKey, role, "trusting");
this.set(`writeKeyFor_${memberKey}`, writeKeyForNewMember.id, "trusting");
this.storeKeyRevelationForMember(
memberKey,
agent,
writeKeyForNewMember.id,
writeKeyForNewMember.secret,
);
for (const otherMemberKey of this.getMemberKeys()) {
const memberRole = this.get(otherMemberKey);
if (
memberRole === "reader" ||
memberRole === "writer" ||
memberRole === "admin" ||
memberRole === "readerInvite" ||
memberRole === "writerInvite" ||
memberRole === "adminInvite"
) {
const otherMemberAgent = this.core.node
.resolveAccountAgent(
otherMemberKey,
"Expected member agent to be loaded",
)
._unsafeUnwrap({ withStackTrace: true });
this.storeKeyRevelationForMember(
otherMemberKey,
otherMemberAgent,
writeKeyForNewMember.id,
writeKeyForNewMember.secret,
);
}
}
} else {
const memberKey = typeof account === "string" ? account : account.id;
const agent =
typeof account === "string"
? account
: account.currentAgentID()._unsafeUnwrap({ withStackTrace: true });
const currentReadKey = this.core.getCurrentReadKey();
if (!currentReadKey.secret) {
throw new Error("Can't add member without read key secret");
}
this.set(memberKey, role, "trusting");
if (this.get(memberKey) !== role) {
throw new Error("Failed to set role");
}
this.set(
`${currentReadKey.id}_for_${memberKey}`,
this.core.crypto.seal({
message: currentReadKey.secret,
from: this.core.node.account.currentSealerSecret(),
to: this.core.crypto.getAgentSealerID(agent),
nOnceMaterial: {
in: this.id,
tx: this.core.nextTransactionID(),
},
}),
"trusting",
this.storeKeyRevelationForMember(
memberKey,
agent,
currentReadKey.id,
currentReadKey.secret,
);
for (const keyID of this.getWriteOnlyKeys()) {
const secret = this.core.getReadKey(keyID);
if (!secret) {
console.error("Can't find key", keyID);
continue;
}
this.storeKeyRevelationForMember(memberKey, agent, keyID, secret);
}
}
}
private storeKeyRevelationForMember(
memberKey: RawAccountID | AgentID,
agent: AgentID,
keyID: KeyID,
secret: KeySecret,
) {
this.set(
`${keyID}_for_${memberKey}`,
this.core.crypto.seal({
message: secret,
from: this.core.node.account.currentSealerSecret(),
to: this.core.crypto.getAgentSealerID(agent),
nOnceMaterial: {
in: this.id,
tx: this.core.nextTransactionID(),
},
}),
"trusting",
);
}
private getWriteOnlyKeys() {
const keys: KeyID[] = [];
for (const key of this.keys()) {
if (key.startsWith("writeKeyFor_")) {
keys.push(
this.get(key as `writeKeyFor_${RawAccountID | AgentID}`) as KeyID,
);
}
}
return keys;
}
getCurrentKeyReadId() {
if (this.myRole() === "writeOnly") {
const accountId = this.core.node.account.id;
return this.get(`writeKeyFor_${accountId}`) as KeyID;
}
return this.get("readKey");
}
getMemberKeys(): (RawAccountID | AgentID)[] {
return this.keys().filter((key): key is RawAccountID | AgentID => {
return key.startsWith("co_") || isAgentID(key);
});
}
/** @internal */
rotateReadKey() {
const currentlyPermittedReaders = this.keys().filter((key) => {
if (key.startsWith("co_") || isAgentID(key)) {
const role = this.get(key);
return role === "admin" || role === "writer" || role === "reader";
} else {
return false;
}
}) as (RawAccountID | AgentID)[];
const memberKeys = this.getMemberKeys();
const currentlyPermittedReaders = memberKeys.filter((key) => {
const role = this.get(key);
return (
role === "admin" ||
role === "writer" ||
role === "reader" ||
role === "adminInvite" ||
role === "writerInvite" ||
role === "readerInvite"
);
});
const writeOnlyMembers = memberKeys.filter((key) => {
const role = this.get(key);
return role === "writeOnly" || role === "writeOnlyInvite";
});
// Get these early, so we fail fast if they are unavailable
const parentGroups = this.getParentGroups();
@@ -293,28 +419,54 @@ export class RawGroup<
const newReadKey = this.core.crypto.newRandomKeySecret();
for (const readerID of currentlyPermittedReaders) {
const reader = this.core.node
const agent = this.core.node
.resolveAccountAgent(
readerID,
"Expected to know currently permitted reader",
)
._unsafeUnwrap({ withStackTrace: true });
this.set(
`${newReadKey.id}_for_${readerID}`,
this.core.crypto.seal({
message: newReadKey.secret,
from: this.core.node.account.currentSealerSecret(),
to: this.core.crypto.getAgentSealerID(reader),
nOnceMaterial: {
in: this.id,
tx: this.core.nextTransactionID(),
},
}),
"trusting",
this.storeKeyRevelationForMember(
readerID,
agent,
newReadKey.id,
newReadKey.secret,
);
}
for (const writeOnlyMemberID of writeOnlyMembers) {
const agent = this.core.node
.resolveAccountAgent(
writeOnlyMemberID,
"Expected to know writeOnly member",
)
._unsafeUnwrap({ withStackTrace: true });
const writeOnlyKey = this.core.crypto.newRandomKeySecret();
this.storeKeyRevelationForMember(
writeOnlyMemberID,
agent,
writeOnlyKey.id,
writeOnlyKey.secret,
);
this.set(`writeKeyFor_${writeOnlyMemberID}`, writeOnlyKey.id, "trusting");
for (const readerID of currentlyPermittedReaders) {
const agent = this.core.node
.resolveAccountAgent(
readerID,
"Expected to know currently permitted reader",
)
._unsafeUnwrap({ withStackTrace: true });
this.storeKeyRevelationForMember(
readerID,
agent,
writeOnlyKey.id,
writeOnlyKey.secret,
);
}
}
this.set(
`${currentReadKey.id}_for_${newReadKey.id}`,
this.core.crypto.encryptKeySecret({
@@ -426,7 +578,7 @@ export class RawGroup<
*
* @category 2. Role changing
*/
createInvite(role: "reader" | "writer" | "admin"): InviteSecret {
createInvite(role: AccountRole): InviteSecret {
const secretSeed = this.core.crypto.newRandomSecretSeed();
const inviteSecret = this.core.crypto.agentSecretFromSecretSeed(secretSeed);
@@ -562,7 +714,7 @@ function isMorePermissiveAndShouldInherit(
}
if (roleInParent === "reader") {
return !roleInChild;
return !roleInChild || roleInChild === "writeOnly";
}
return false;

View File

@@ -387,7 +387,8 @@ export class LocalNode {
existingRole === "admin" ||
(existingRole === "writer" && inviteRole === "writerInvite") ||
(existingRole === "writer" && inviteRole === "reader") ||
(existingRole === "reader" && inviteRole === "readerInvite")
(existingRole === "reader" && inviteRole === "readerInvite") ||
(existingRole && inviteRole === "writeOnlyInvite")
) {
console.debug(
"Not accepting invite that would replace or downgrade role",
@@ -410,7 +411,9 @@ export class LocalNode {
? "admin"
: inviteRole === "writerInvite"
? "writer"
: "reader",
: inviteRole === "writeOnlyInvite"
? "writeOnly"
: "reader",
);
group.core._sessionLogs = groupAsInvite.core.sessionLogs;

View File

@@ -22,14 +22,15 @@ export type PermissionsDef =
| { type: "ownedByGroup"; group: RawCoID }
| { type: "unsafeAllowAll" };
export type AccountRole = "reader" | "writer" | "admin" | "writeOnly";
export type Role =
| "reader"
| "writer"
| "admin"
| AccountRole
| "revoked"
| "adminInvite"
| "writerInvite"
| "readerInvite";
| "readerInvite"
| "writeOnlyInvite";
type ValidTransactionsResult = { txID: TransactionID; tx: Transaction };
type MemberState = { [agent: RawAccountID | AgentID]: Role; [EVERYONE]?: Role };
@@ -81,7 +82,8 @@ export function determineValidTransactions(
if (
transactorRoleAtTxTime !== "admin" &&
transactorRoleAtTxTime !== "writer"
transactorRoleAtTxTime !== "writer" &&
transactorRoleAtTxTime !== "writeOnly"
) {
return;
}
@@ -177,6 +179,9 @@ function determineValidTransactionsForGroup(
const memberState: MemberState = {};
const validTransactions: ValidTransactionsResult[] = [];
const keyRevelations = new Set<string>();
const writeKeys = new Set<string>();
for (const { sessionID, txIndex, tx } of allTransactionsSorted) {
// console.log("before", { memberState, validTransactions });
const transactor = accountOrAgentIDfromSessionID(sessionID);
@@ -254,14 +259,26 @@ function determineValidTransactionsForGroup(
memberState[transactor] !== "admin" &&
memberState[transactor] !== "adminInvite" &&
memberState[transactor] !== "writerInvite" &&
memberState[transactor] !== "readerInvite"
memberState[transactor] !== "readerInvite" &&
memberState[transactor] !== "writeOnlyInvite"
) {
console.warn("Only admins can reveal keys");
continue;
}
// TODO: check validity of agents who the key is revealed to?
if (
keyRevelations.has(change.key) &&
memberState[transactor] !== "admin"
) {
console.warn(
"Key revelation already exists and can't be overridden by invite",
);
continue;
}
keyRevelations.add(change.key);
// TODO: check validity of agents who the key is revealed to?
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (isParentExtension(change.key)) {
@@ -277,6 +294,26 @@ function determineValidTransactionsForGroup(
console.warn("Only admins can set child extensions");
continue;
}
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (isWriteKeyForMember(change.key)) {
if (
memberState[transactor] !== "admin" &&
memberState[transactor] !== "writeOnlyInvite"
) {
console.warn("Only admins can set writeKeys");
continue;
}
if (writeKeys.has(change.key) && memberState[transactor] !== "admin") {
console.warn(
"Write key already exists and can't be overridden by invite",
);
continue;
}
writeKeys.add(change.key);
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
}
@@ -288,10 +325,12 @@ function determineValidTransactionsForGroup(
change.value !== "admin" &&
change.value !== "writer" &&
change.value !== "reader" &&
change.value !== "writeOnly" &&
change.value !== "revoked" &&
change.value !== "adminInvite" &&
change.value !== "writerInvite" &&
change.value !== "readerInvite"
change.value !== "readerInvite" &&
change.value !== "writeOnlyInvite"
) {
console.warn("Group transaction must set a valid role");
continue;
@@ -341,6 +380,11 @@ function determineValidTransactionsForGroup(
console.warn("ReaderInvites can only create reader.");
continue;
}
} else if (memberState[transactor] === "writeOnlyInvite") {
if (change.value !== "writeOnly") {
console.warn("WriteOnlyInvites can only create writeOnly.");
continue;
}
} else {
console.warn(
"Group transaction must be made by current admin or invite",
@@ -377,6 +421,12 @@ function agentInAccountOrMemberInGroup(
return transactor;
}
export function isWriteKeyForMember(
co: string,
): co is `writeKeyFor_${RawAccountID | AgentID}` {
return co.startsWith("writeKeyFor_");
}
export function isKeyForKeyField(co: string): co is `${KeyID}_for_${KeyID}` {
return co.startsWith("key_") && co.includes("_for_key");
}

View File

@@ -1,4 +1,4 @@
import { expect, test } from "vitest";
import { describe, expect, test } from "vitest";
import { RawCoList } from "../coValues/coList.js";
import { RawCoMap } from "../coValues/coMap.js";
import { RawCoStream } from "../coValues/coStream.js";
@@ -7,6 +7,7 @@ import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { LocalNode } from "../localNode.js";
import {
createThreeConnectedNodes,
createTwoConnectedNodes,
loadCoValueOrFail,
randomAnonymousAccountAndSessionID,
} from "./testUtils.js";
@@ -59,33 +60,44 @@ test("Can create a FileStream in a group", () => {
});
test("Remove a member from a group where the admin role is inherited", async () => {
const { node1, node2, node3, node1ToNode2Peer, node2ToNode3Peer } =
createThreeConnectedNodes("server", "server", "server");
const { node1, node2, node3 } = await createThreeConnectedNodes(
"server",
"server",
"server",
);
const group = node1.createGroup();
const group = node1.node.createGroup();
group.addMember(node2.account, "admin");
group.addMember(node3.account, "reader");
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"admin",
);
group.addMember(
await loadCoValueOrFail(node1.node, node3.accountID),
"reader",
);
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
await group.core.waitForSync();
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
// The account of node2 create a child group and extend the initial group
// This way the node1 account should become "admin" of the child group
// by inheriting the admin role from the initial group
const childGroup = node2.createGroup();
const childGroup = node2.node.createGroup();
childGroup.extend(groupOnNode2);
const map = childGroup.createMap();
map.set("test", "Available to everyone");
const mapOnNode3 = await loadCoValueOrFail(node3, map.id);
const mapOnNode3 = await loadCoValueOrFail(node3.node, map.id);
// Check that the sync between node2 and node3 worked
expect(mapOnNode3.get("test")).toEqual("Available to everyone");
// The node1 account removes the reader from the group
// The reader should be automatically kicked out of the child group
await group.removeMember(node3.account);
await group.removeMember(node3.node.account);
await group.core.waitForSync();
@@ -97,26 +109,37 @@ test("Remove a member from a group where the admin role is inherited", async ()
// Check that the value has not been updated on node3
expect(mapOnNode3.get("test")).toEqual("Available to everyone");
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
expect(mapOnNode1.get("test")).toEqual("Hidden to node3");
});
test("An admin should be able to rotate the readKey on child groups and keep access to new coValues", async () => {
const { node1, node2, node3, node1ToNode2Peer, node2ToNode1Peer } =
createThreeConnectedNodes("server", "server", "server");
const { node1, node2, node3 } = await createThreeConnectedNodes(
"server",
"server",
"server",
);
const group = node1.createGroup();
const group = node1.node.createGroup();
group.addMember(node2.account, "admin");
group.addMember(node3.account, "reader");
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"admin",
);
group.addMember(
await loadCoValueOrFail(node1.node, node3.accountID),
"reader",
);
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
await group.core.waitForSync();
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
// The account of node2 create a child group and extend the initial group
// This way the node1 account should become "admin" of the child group
// by inheriting the admin role from the initial group
const childGroup = node2.createGroup();
const childGroup = node2.node.createGroup();
childGroup.extend(groupOnNode2);
await childGroup.core.waitForSync();
@@ -124,78 +147,270 @@ test("An admin should be able to rotate the readKey on child groups and keep acc
// The node1 account removes the reader from the group
// In this case we want to ensure that node1 is still able to read new coValues
// Even if some childs are not available when the readKey is rotated
await group.removeMember(node3.account);
await group.removeMember(node3.node.account);
await group.core.waitForSync();
const map = childGroup.createMap();
map.set("test", "Available to node1");
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
expect(mapOnNode1.get("test")).toEqual("Available to node1");
});
test("An admin should be able to rotate the readKey on child groups even if it was unavailable when kicking out a member from a parent group", async () => {
const { node1, node2, node3, node1ToNode2Peer, node2ToNode1Peer } =
createThreeConnectedNodes("server", "server", "server");
const group = node1.createGroup();
group.addMember(node2.account, "admin");
group.addMember(node3.account, "reader");
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
// The account of node2 create a child group and extend the initial group
// This way the node1 account should become "admin" of the child group
// by inheriting the admin role from the initial group
const childGroup = node2.createGroup();
childGroup.extend(groupOnNode2);
// The node1 account removes the reader from the group
// In this case we want to ensure that node1 is still able to read new coValues
// Even if some childs are not available when the readKey is rotated
await group.removeMember(node3.account);
await group.core.waitForSync();
const map = childGroup.createMap();
map.set("test", "Available to node1");
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
expect(mapOnNode1.get("test")).toEqual("Available to node1");
});
test("An admin should be able to rotate the readKey on child groups even if it was unavailable when kicking out a member from a parent group (grandChild)", async () => {
const { node1, node2, node3, node1ToNode2Peer } = createThreeConnectedNodes(
const { node1, node2, node3 } = await createThreeConnectedNodes(
"server",
"server",
"server",
);
const group = node1.createGroup();
const group = node1.node.createGroup();
group.addMember(node2.account, "admin");
group.addMember(node3.account, "reader");
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"admin",
);
group.addMember(
await loadCoValueOrFail(node1.node, node3.accountID),
"reader",
);
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
await group.core.waitForSync();
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
// The account of node2 create a child group and extend the initial group
// This way the node1 account should become "admin" of the child group
// by inheriting the admin role from the initial group
const childGroup = node2.createGroup();
const childGroup = node2.node.createGroup();
childGroup.extend(groupOnNode2);
const grandChildGroup = node2.createGroup();
grandChildGroup.extend(childGroup);
// The node1 account removes the reader from the group
// In this case we want to ensure that node1 is still able to read new coValues
// Even if some childs are not available when the readKey is rotated
await group.removeMember(node3.account);
await group.removeMember(node3.node.account);
await group.core.waitForSync();
const map = childGroup.createMap();
map.set("test", "Available to node1");
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
expect(mapOnNode1.get("test")).toEqual("Available to node1");
});
test("An admin should be able to rotate the readKey on child groups even if it was unavailable when kicking out a member from a parent group (grandChild)", async () => {
const { node1, node2, node3 } = await createThreeConnectedNodes(
"server",
"server",
"server",
);
const group = node1.node.createGroup();
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"admin",
);
group.addMember(
await loadCoValueOrFail(node1.node, node3.accountID),
"reader",
);
await group.core.waitForSync();
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
// The account of node2 create a child group and extend the initial group
// This way the node1 account should become "admin" of the child group
// by inheriting the admin role from the initial group
const childGroup = node2.node.createGroup();
childGroup.extend(groupOnNode2);
const grandChildGroup = node2.node.createGroup();
grandChildGroup.extend(childGroup);
// The node1 account removes the reader from the group
// In this case we want to ensure that node1 is still able to read new coValues
// Even if some childs are not available when the readKey is rotated
await group.removeMember(node3.node.account);
await group.core.waitForSync();
const map = childGroup.createMap();
map.set("test", "Available to node1");
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
expect(mapOnNode1.get("test")).toEqual("Available to node1");
});
test("A user add after a key rotation should have access to the old transactions", async () => {
const { node1, node2, node3 } = await createThreeConnectedNodes(
"server",
"server",
"server",
);
const group = node1.node.createGroup();
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writer",
);
await group.core.waitForSync();
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
const map = groupOnNode2.createMap();
map.set("test", "Written from node2");
await map.core.waitForSync();
await group.removeMember(node3.node.account);
group.addMember(
await loadCoValueOrFail(node1.node, node3.accountID),
"reader",
);
await group.core.waitForSync();
const mapOnNode3 = await loadCoValueOrFail(node3.node, map.id);
expect(mapOnNode3.get("test")).toEqual("Written from node2");
});
test("Invites should have access to the new keys", async () => {
const { node1, node2, node3 } = await createThreeConnectedNodes(
"server",
"server",
"server",
);
const group = node1.node.createGroup();
group.addMember(
await loadCoValueOrFail(node1.node, node3.accountID),
"reader",
);
const invite = group.createInvite("admin");
await group.removeMember(node3.node.account);
const map = group.createMap();
map.set("test", "Written from node1");
await map.core.waitForSync();
await node2.node.acceptInvite(group.id, invite);
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
expect(mapOnNode2.get("test")).toEqual("Written from node1");
});
describe("writeOnly", () => {
test("Admins can invite writeOnly members", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
const group = node1.node.createGroup();
const invite = group.createInvite("writeOnly");
await node2.node.acceptInvite(group.id, invite);
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
expect(groupOnNode2.myRole()).toEqual("writeOnly");
});
test("Edits by writeOnly members are visible to other members", async () => {
const { node1, node2, node3 } = await createThreeConnectedNodes(
"server",
"server",
"server",
);
const group = node1.node.createGroup();
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writeOnly",
);
group.addMember(
await loadCoValueOrFail(node1.node, node3.accountID),
"reader",
);
await group.core.waitForSync();
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
const map = groupOnNode2.createMap();
map.set("test", "Written from a writeOnly member");
expect(map.get("test")).toEqual("Written from a writeOnly member");
await map.core.waitForSync();
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
expect(mapOnNode1.get("test")).toEqual("Written from a writeOnly member");
const mapOnNode3 = await loadCoValueOrFail(node3.node, map.id);
expect(mapOnNode3.get("test")).toEqual("Written from a writeOnly member");
});
test("Edits by other members are not visible to writeOnly members", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
const group = node1.node.createGroup();
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writeOnly",
);
const map = group.createMap();
map.set("test", "Written from the admin");
await map.core.waitForSync();
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
expect(mapOnNode2.get("test")).toEqual(undefined);
});
test("Write only member keys are rotated when a member is kicked out", async () => {
const { node1, node2, node3 } = await createThreeConnectedNodes(
"server",
"server",
"server",
);
const group = node1.node.createGroup();
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writeOnly",
);
group.addMember(
await loadCoValueOrFail(node1.node, node3.accountID),
"reader",
);
await group.core.waitForSync();
const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
const map = groupOnNode2.createMap();
map.set("test", "Written from a writeOnly member");
await map.core.waitForSync();
await group.removeMember(node3.node.account);
await group.core.waitForSync();
map.set("test", "Updated after key rotation");
await map.core.waitForSync();
const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
expect(mapOnNode1.get("test")).toEqual("Updated after key rotation");
const mapOnNode3 = await loadCoValueOrFail(node3.node, map.id);
expect(mapOnNode3.get("test")).toEqual("Written from a writeOnly member");
});
});

View File

@@ -1574,6 +1574,330 @@ test("ReaderInvites can not invite writers", () => {
expect(groupAsInvite.get(invitedWriterID)).toBeUndefined();
});
test("WriteOnlyInvites can not invite writers", () => {
const { groupCore, admin } = newGroup();
const inviteSecret = Crypto.newRandomAgentSecret();
const inviteID = Crypto.getAgentID(inviteSecret);
const group = expectGroup(groupCore.getCurrentContent());
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
const revelation = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: admin.currentSealerID()._unsafeUnwrap(),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
group.set("readKey", readKeyID, "trusting");
group.set(inviteID, "writeOnlyInvite", "trusting");
expect(group.get(inviteID)).toEqual("writeOnlyInvite");
const revelationForInvite = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: Crypto.getAgentSealerID(inviteID),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
const groupAsInvite = expectGroup(
groupCore
.testWithDifferentAccount(
new ControlledAgent(inviteSecret, Crypto),
Crypto.newRandomSessionID(inviteID),
)
.getCurrentContent(),
);
const invitedWriterSecret = Crypto.newRandomAgentSecret();
const invitedWriterID = Crypto.getAgentID(invitedWriterSecret);
groupAsInvite.set(invitedWriterID, "writer", "trusting");
expect(groupAsInvite.get(invitedWriterID)).toBeUndefined();
});
test("WriteOnlyInvites can not invite admins", () => {
const { groupCore, admin } = newGroup();
const inviteSecret = Crypto.newRandomAgentSecret();
const inviteID = Crypto.getAgentID(inviteSecret);
const group = expectGroup(groupCore.getCurrentContent());
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
const revelation = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: admin.currentSealerID()._unsafeUnwrap(),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
group.set("readKey", readKeyID, "trusting");
group.set(inviteID, "writeOnlyInvite", "trusting");
expect(group.get(inviteID)).toEqual("writeOnlyInvite");
const revelationForInvite = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: Crypto.getAgentSealerID(inviteID),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
const groupAsInvite = expectGroup(
groupCore
.testWithDifferentAccount(
new ControlledAgent(inviteSecret, Crypto),
Crypto.newRandomSessionID(inviteID),
)
.getCurrentContent(),
);
const invitedWriterSecret = Crypto.newRandomAgentSecret();
const invitedWriterID = Crypto.getAgentID(invitedWriterSecret);
groupAsInvite.set(invitedWriterID, "admin", "trusting");
expect(groupAsInvite.get(invitedWriterID)).toBeUndefined();
});
test("WriteOnlyInvites can invite writeOnly", () => {
const { groupCore, admin } = newGroup();
const inviteSecret = Crypto.newRandomAgentSecret();
const inviteID = Crypto.getAgentID(inviteSecret);
const group = expectGroup(groupCore.getCurrentContent());
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
const revelation = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: admin.currentSealerID()._unsafeUnwrap(),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
group.set("readKey", readKeyID, "trusting");
group.set(inviteID, "writeOnlyInvite", "trusting");
expect(group.get(inviteID)).toEqual("writeOnlyInvite");
const revelationForInvite = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: Crypto.getAgentSealerID(inviteID),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
const groupAsInvite = expectGroup(
groupCore
.testWithDifferentAccount(
new ControlledAgent(inviteSecret, Crypto),
Crypto.newRandomSessionID(inviteID),
)
.getCurrentContent(),
);
const invitedWriterSecret = Crypto.newRandomAgentSecret();
const invitedWriterID = Crypto.getAgentID(invitedWriterSecret);
groupAsInvite.set(invitedWriterID, "writeOnly", "trusting");
expect(groupAsInvite.get(invitedWriterID)).toEqual("writeOnly");
});
test("WriteOnlyInvites can set writeKeys", () => {
const { groupCore, admin } = newGroup();
const inviteSecret = Crypto.newRandomAgentSecret();
const inviteID = Crypto.getAgentID(inviteSecret);
const group = expectGroup(groupCore.getCurrentContent());
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
const revelation = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: admin.currentSealerID()._unsafeUnwrap(),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
group.set("readKey", readKeyID, "trusting");
group.set(inviteID, "writeOnlyInvite", "trusting");
expect(group.get(inviteID)).toEqual("writeOnlyInvite");
const revelationForInvite = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: Crypto.getAgentSealerID(inviteID),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
const groupAsInvite = expectGroup(
groupCore
.testWithDifferentAccount(
new ControlledAgent(inviteSecret, Crypto),
Crypto.newRandomSessionID(inviteID),
)
.getCurrentContent(),
);
groupAsInvite.set(`writeKeyFor_${admin.id}`, readKeyID, "trusting");
expect(groupAsInvite.get(`writeKeyFor_${admin.id}`)).toEqual(readKeyID);
});
test("Invites can't override key revelations", () => {
const { groupCore, admin } = newGroup();
const inviteSecret = Crypto.newRandomAgentSecret();
const inviteID = Crypto.getAgentID(inviteSecret);
const group = expectGroup(groupCore.getCurrentContent());
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
const revelation = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: admin.currentSealerID()._unsafeUnwrap(),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
group.set("readKey", readKeyID, "trusting");
group.set(inviteID, "readerInvite", "trusting");
expect(group.get(inviteID)).toEqual("readerInvite");
const revelationForInvite = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: Crypto.getAgentSealerID(inviteID),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
const groupAsInvite = expectGroup(
groupCore
.testWithDifferentAccount(
new ControlledAgent(inviteSecret, Crypto),
Crypto.newRandomSessionID(inviteID),
)
.getCurrentContent(),
);
groupAsInvite.set(
`${readKeyID}_for_${admin.id}`,
"Evil change" as any,
"trusting",
);
expect(groupAsInvite.get(`${readKeyID}_for_${admin.id}`)).toBe(revelation);
});
test("WriteOnlyInvites can't override writeKeys", () => {
const { groupCore, admin } = newGroup();
const inviteSecret = Crypto.newRandomAgentSecret();
const inviteID = Crypto.getAgentID(inviteSecret);
const group = expectGroup(groupCore.getCurrentContent());
const { secret: readKey, id: readKeyID } = Crypto.newRandomKeySecret();
const revelation = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: admin.currentSealerID()._unsafeUnwrap(),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${admin.id}`, revelation, "trusting");
group.set("readKey", readKeyID, "trusting");
group.set(inviteID, "writeOnlyInvite", "trusting");
expect(group.get(inviteID)).toEqual("writeOnlyInvite");
const revelationForInvite = Crypto.seal({
message: readKey,
from: admin.currentSealerSecret(),
to: Crypto.getAgentSealerID(inviteID),
nOnceMaterial: {
in: groupCore.id,
tx: groupCore.nextTransactionID(),
},
});
group.set(`${readKeyID}_for_${inviteID}`, revelationForInvite, "trusting");
const groupAsInvite = expectGroup(
groupCore
.testWithDifferentAccount(
new ControlledAgent(inviteSecret, Crypto),
Crypto.newRandomSessionID(inviteID),
)
.getCurrentContent(),
);
groupAsInvite.set(`writeKeyFor_${admin.id}`, readKeyID, "trusting");
groupAsInvite.set(
`writeKeyFor_${admin.id}`,
"Evil change" as any,
"trusting",
);
expect(groupAsInvite.get(`writeKeyFor_${admin.id}`)).toEqual(readKeyID);
});
test("Can give read permission to 'everyone'", () => {
const { node, groupCore } = newGroup();

View File

@@ -60,17 +60,11 @@ export async function createTwoConnectedNodes(
};
}
export function createThreeConnectedNodes(
export async function createThreeConnectedNodes(
node1Role: Peer["role"],
node2Role: Peer["role"],
node3Role: Peer["role"],
) {
// Setup nodes
const node1 = createTestNode();
const node2 = createTestNode();
const node3 = createTestNode();
// Connect nodes initially
const [node1ToNode2Peer, node2ToNode1Peer] = connectedPeers(
"node1ToNode2",
"node2ToNode1",
@@ -98,12 +92,23 @@ export function createThreeConnectedNodes(
},
);
node1.syncManager.addPeer(node1ToNode2Peer);
node1.syncManager.addPeer(node1ToNode3Peer);
node2.syncManager.addPeer(node2ToNode1Peer);
node2.syncManager.addPeer(node2ToNode3Peer);
node3.syncManager.addPeer(node3ToNode1Peer);
node3.syncManager.addPeer(node3ToNode2Peer);
const node1 = await LocalNode.withNewlyCreatedAccount({
peersToLoadFrom: [node1ToNode2Peer, node1ToNode3Peer],
crypto: Crypto,
creationProps: { name: "Node 1" },
});
const node2 = await LocalNode.withNewlyCreatedAccount({
peersToLoadFrom: [node2ToNode1Peer, node2ToNode3Peer],
crypto: Crypto,
creationProps: { name: "Node 2" },
});
const node3 = await LocalNode.withNewlyCreatedAccount({
peersToLoadFrom: [node3ToNode1Peer, node3ToNode2Peer],
crypto: Crypto,
creationProps: { name: "Node 3" },
});
return {
node1,

View File

@@ -204,7 +204,7 @@ export function provideBrowserLockSession(
/** @category Invite Links */
export function createInviteLink<C extends CoValue>(
value: C,
role: "reader" | "writer" | "admin",
role: "reader" | "writer" | "admin" | "writeOnly",
// default to same address as window.location, but without hash
{
baseURL = window.location.href.replace(/#.*$/, ""),

572
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:local": "VITE_WS_PEER=ws://localhost:4200/ vite",
"build": "tsc && vite build",
"preview": "vite preview",
"format-and-lint": "biome check .",

View File

@@ -7,6 +7,7 @@ import { ResumeSyncState } from "./pages/ResumeSyncState";
import { RetryUnavailable } from "./pages/RetryUnavailable";
import { Sharing } from "./pages/Sharing";
import { TestInput } from "./pages/TestInput";
import { WriteOnlyRole } from "./pages/WriteOnly";
function Index() {
return (
@@ -26,6 +27,9 @@ function Index() {
<li>
<Link to="/sharing">Sharing</Link>
</li>
<li>
<Link to="/write-only">Write Only</Link>
</li>
</ul>
);
}
@@ -51,6 +55,10 @@ const router = createBrowserRouter([
path: "/sharing",
element: <Sharing />,
},
{
path: "/write-only",
element: <WriteOnlyRole />,
},
{
path: "/",
element: <Index />,

View File

@@ -0,0 +1,108 @@
import { createInviteLink } from "jazz-react";
import { CoList, CoMap, Group, ID, co } from "jazz-tools";
import { useState } from "react";
import { useAcceptInvite, useAccount, useCoState } from "../jazz";
class SharedCoMap extends CoMap {
value = co.string;
}
class SharedCoList extends CoList.Of(co.ref(SharedCoMap)) {}
export function WriteOnlyRole() {
const { me } = useAccount();
const [id, setId] = useState<ID<SharedCoList> | undefined>(undefined);
const [revealLevels, setRevealLevels] = useState(1);
const [inviteLinks, setInviteLinks] = useState<Record<string, string>>({});
const coList = useCoState(SharedCoList, id, []);
const createCoList = async () => {
if (!me || id) return;
const group = Group.create({ owner: me });
const coList = SharedCoList.create([], { owner: group });
setInviteLinks({
writer: createInviteLink(coList, "writer"),
reader: createInviteLink(coList, "reader"),
admin: createInviteLink(coList, "admin"),
writeOnly: createInviteLink(coList, "writeOnly"),
});
await group.waitForSync();
setId(coList.id);
};
const addNewItem = async () => {
if (!me || !coList) return;
const group = coList._owner as Group;
const coMap = SharedCoMap.create({ value: "" }, { owner: group });
coList.push(coMap);
};
const revokeAccess = () => {
if (!coList) return;
const coListGroup = coList._owner as Group;
for (const member of coListGroup.members) {
if (
member.account &&
member.role !== "admin" &&
member.account.id !== me.id
) {
coListGroup.removeMember(member.account);
}
}
};
useAcceptInvite({
invitedObjectSchema: SharedCoList,
onAccept: (id) => {
setId(id);
},
});
return (
<div>
<h1>Sharing</h1>
<p data-testid="id">{coList?.id}</p>
{Object.entries(inviteLinks).map(([role, inviteLink]) => (
<div key={role} style={{ display: "flex", gap: 5 }}>
<p style={{ fontWeight: "bold" }}>{role} invitation:</p>
<p data-testid={`invite-link-${role}`}>{inviteLink}</p>
</div>
))}
<pre data-testid="values">
{coList?.map(
(map) => map && <EditSharedCoMap key={map.id} id={map.id} />,
)}
</pre>
{!id && <button onClick={createCoList}>Create the list</button>}
{id && <button onClick={addNewItem}>Add a new item</button>}
{coList && <button onClick={revokeAccess}>Revoke access</button>}
</div>
);
}
function EditSharedCoMap(props: {
id: ID<SharedCoMap>;
}) {
const coMap = useCoState(SharedCoMap, props.id, {});
if (!coMap) return null;
return (
<>
<div>{coMap.value}</div>
<input
value={coMap.value}
onChange={(e) => (coMap.value = e.target.value)}
/>
</>
);
}

View File

@@ -0,0 +1,101 @@
import { Browser, Page, expect, test } from "@playwright/test";
test.describe("WriteOnly role", () => {
test("should share simple coValues", async ({ page, browser }) => {
await page.goto("/write-only");
await page.getByRole("button", { name: "Create the list" }).click();
await waitForReady(page);
const id = await page.getByTestId("id").textContent();
const inviteLink = await page
.getByTestId("invite-link-writeOnly1")
.textContent();
// Create a new incognito instance and accept the invite
const newUserPage = await (await browser.newContext()).newPage();
await newUserPage.goto(inviteLink!);
await waitForReady(newUserPage);
await expect(newUserPage.getByTestId("id")).toHaveText(id ?? "");
});
test("writeOnly1 roles should be able to see only their own changes", async ({
page,
browser,
context,
}) => {
const pages = {
initialOwner: page,
writeOnly1: await createIsolatedPage(browser),
writeOnly2: await createIsolatedPage(browser),
reader: await createIsolatedPage(browser),
};
await pages.initialOwner.goto("/write-only?userName=InitialOwner");
await pages.writeOnly1.goto("/write-only?userName=WriteOnly");
await pages.writeOnly2.goto("/write-only?userName=WriteOnly2");
await pages.initialOwner
.getByRole("button", { name: "Create the list" })
.click();
await waitForReady(pages.initialOwner);
const writeOnlyInviteLink = await pages.initialOwner
.getByTestId("invite-link-writeOnly")
.textContent();
await pages.writeOnly1.goto(writeOnlyInviteLink!);
await pages.writeOnly2.goto(writeOnlyInviteLink!);
await waitForReady(pages.writeOnly1);
await waitForReady(pages.writeOnly2);
await pages.writeOnly1
.getByRole("button", { name: "Add a new item" })
.click();
await pages.writeOnly2
.getByRole("button", { name: "Add a new item" })
.click();
await pages.writeOnly1.getByRole("textbox").fill("From WriteOnly1");
await pages.writeOnly2.getByRole("textbox").fill("From WriteOnly2");
await expect(pages.initialOwner.getByText("From WriteOnly1")).toBeVisible();
await expect(pages.initialOwner.getByText("From WriteOnly2")).toBeVisible();
await expect(pages.writeOnly1.getByText("From WriteOnly1")).toBeVisible();
await expect(
pages.writeOnly1.getByText("From WriteOnly2"),
).not.toBeVisible();
await expect(
pages.writeOnly2.getByText("From WriteOnly1"),
).not.toBeVisible();
await expect(pages.writeOnly2.getByText("From WriteOnly2")).toBeVisible();
const readerInviteLink = await pages.initialOwner
.getByTestId("invite-link-reader")
.textContent();
await pages.reader.goto(readerInviteLink!);
await waitForReady(pages.reader);
await expect(pages.reader.getByText("From WriteOnly1")).toBeVisible();
await expect(pages.reader.getByText("From WriteOnly2")).toBeVisible();
});
});
async function waitForReady(page: Page) {
await expect(page.getByTestId("id")).not.toHaveText("", {
timeout: 20_000,
});
}
async function createIsolatedPage(browser: Browser) {
const context = await browser.newContext();
const page = await context.newPage();
return page;
}