Compare commits

...

48 Commits

Author SHA1 Message Date
Guido D'Orsi
5d20c81ed5 feat: Add an API to disable the permission errors logs 2024-12-19 13:08:03 +01:00
pax-k
3accbc06e2 chore: add build step to release workflow (debug) 2024-12-18 21:58:25 +02:00
pax-k
39ae4fc8c1 chore: enable tmate debugging on gh workflow failure 2024-12-18 21:13:01 +02:00
Guido D'Orsi
ae60eb0d01 Merge pull request #1050 from garden-co/fix/remove-local-server-e2e
test(e2e): run tests against our cloud
2024-12-18 17:09:29 +01:00
Guido D'Orsi
05eb330173 Merge pull request #1019 from garden-co/form-example-test
Add test for form example
2024-12-18 16:36:03 +01:00
Guido D'Orsi
5d1e192245 test(e2e): run tests against our cloud 2024-12-18 16:34:59 +01:00
Anselm Eickhoff
c5e059b3a9 Merge pull request #1048 from garden-co/docs/org-design-pattern
Add organization design pattern to docs
2024-12-18 15:24:37 +00:00
Trisha Lim
d1785c0178 Address feedback 2024-12-18 15:14:12 +00:00
pax
9b95c32edb Merge pull request #1049 from garden-co/changeset-release/main
Version Packages
2024-12-18 17:08:52 +02:00
github-actions[bot]
d87781d33f Version Packages 2024-12-18 15:04:52 +00:00
pax
ad50ec2d9d Merge pull request #1047 from garden-co/create-jazz-app
create-jazz-app
2024-12-18 17:03:50 +02:00
Trisha Lim
fef055b9fb Add organization design pattern to docs 2024-12-18 14:20:10 +00:00
pax-k
cdc7f9f841 chore: changeset 2024-12-18 14:56:28 +02:00
pax-k
ff6940de56 chore: bumped jazz-react-native-auth-clerk 2024-12-18 14:52:44 +02:00
pax-k
cad54e4018 chore: upgraded nativewind to 4.x 2024-12-18 14:52:31 +02:00
pax-k
c657880a22 chore: small adjustments 2024-12-18 14:51:54 +02:00
pax-k
79b02dee3c chore: added readme for create-jazz-app 2024-12-18 14:51:35 +02:00
pax-k
ee29cb300c chore: changeset 2024-12-18 13:38:20 +02:00
pax-k
36f0ab7571 Merge branch 'main' into create-jazz-app 2024-12-18 13:00:53 +02:00
pax-k
829be3cafa chore: cleanup 2024-12-18 13:00:32 +02:00
Guido D'Orsi
7c322796aa fix: exclude tests from build 2024-12-18 12:00:18 +01:00
pax-k
36d50d96dd feat: create-jazz-app command 2024-12-18 12:59:58 +02:00
Guido D'Orsi
1fd26b9d05 Merge pull request #1038 from garden-co/changeset-release/main
Version Packages
2024-12-18 11:54:35 +01:00
github-actions[bot]
40f161d4d0 Version Packages 2024-12-18 10:52:20 +00:00
Anselm Eickhoff
655a601a8e Merge pull request #991 from garden-co/jazz-91-writer-only-permission-kind
feat: add writeOnly role on groups
2024-12-18 10:50:04 +00:00
Anselm Eickhoff
c7a28e5003 Merge pull request #1029 from garden-co/feat/icon-component
Create Icon component for consistent icons
2024-12-18 10:49:28 +00:00
Anselm Eickhoff
ae429d0a1c Merge pull request #1034 from garden-co/feat/incremental-transactions
perf: incremental computation on transactions
2024-12-18 10:46:51 +00:00
Guido D'Orsi
32834ef9e3 chore: improve doc comment
Co-authored-by: Anselm Eickhoff <anselm.eickhoff@gmail.com>
2024-12-17 12:06:04 +01:00
Trisha Lim
13be2a3235 Use Icon component in onboarding example thumbnail 2024-12-17 09:39:42 +00:00
Trisha Lim
7fa6e4333d Create Icon component for consistent icon sizes 2024-12-17 09:35:44 +00:00
Guido D'Orsi
77dbd4e205 fix: keep the cachedContent if processNewTransactions is supported 2024-12-16 12:50:03 +01:00
Guido D'Orsi
325250272b chore: changeset 2024-12-16 10:09:10 +01:00
Guido D'Orsi
219ba975a5 pref(CoMap): lazy computation of the values when doing time travel 2024-12-14 22:52:24 +01:00
Guido D'Orsi
f5a3394d40 perf: improve incemental computation 2024-12-14 21:25:55 +01:00
Guido D'Orsi
4a5209237f perf(coStream): process transactions incrementally 2024-12-14 21:25:55 +01:00
Guido D'Orsi
d7eb50abc1 perf(coMap): process transactions incrementally 2024-12-14 21:25:55 +01:00
Trisha Lim
b097c38617 Test draft indicator 2024-12-13 18:20:59 +00:00
Trisha Lim
385dfc89ff Add test to CI 2024-12-13 18:08:31 +00:00
Trisha Lim
d89e4c3412 Test order editing 2024-12-13 18:05:12 +00:00
Trisha Lim
bbeee086ce Test order creation 2024-12-13 17:59:05 +00:00
Trisha Lim
718e9418e2 Set up playwright on form example 2024-12-13 17:34:48 +00:00
Guido D'Orsi
ac216b9f2e chore: changeset 2024-12-13 13:19:24 +01:00
Guido D'Orsi
93230df2cb test: add some tests for the writeOnly role and the inheritance 2024-12-13 13:19:24 +01:00
Guido D'Orsi
c13daa140b chore: add comments 2024-12-13 13:19:24 +01:00
Guido D'Orsi
3c9439d0cc chore: rename getCurrentKeyReadId into getCurrentReadKeyId 2024-12-13 13:19:24 +01:00
Guido D'Orsi
d38d4928c7 test: add e2e tests for the writeOnly role 2024-12-13 13:19:24 +01:00
Guido D'Orsi
596901cba6 fix: add writeOnly to createInvite types 2024-12-13 13:19:24 +01:00
Guido D'Orsi
2a1fda3758 feat: add writeOnly role on groups 2024-12-13 13:19:24 +01:00
142 changed files with 3592 additions and 645 deletions

View File

@@ -1,5 +0,0 @@
---
"cojson": patch
---
Remove @opentelemetry/api as a peer dependency and add it as a dependency

View File

@@ -0,0 +1,5 @@
---
"cojson": patch
---
Add an internal API to disable the permission errors logs

View File

@@ -13,7 +13,7 @@ jobs:
continue-on-error: true
strategy:
matrix:
project: ["tests/e2e", "examples/chat", "examples/file-share-svelte", "examples/music-player", "examples/pets", "examples/onboarding"]
project: ["tests/e2e", "examples/chat", "examples/file-share-svelte", "examples/form", "examples/music-player", "examples/pets", "examples/onboarding"]
steps:
- uses: actions/checkout@v3
@@ -45,10 +45,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup .env
run: echo "VITE_WS_PEER=ws://localhost:4200/" >> .env
working-directory: ./${{ matrix.project }}
- name: Pnpm Build
run: pnpm turbo build
working-directory: ./${{ matrix.project }}

View File

@@ -4,6 +4,13 @@ on:
push:
branches:
- main
workflow_dispatch:
inputs:
debug_enabled:
type: boolean
description: "Run tmate session for debugging"
required: false
default: false
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -48,4 +55,11 @@ jobs:
publish: pnpm release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# Enable tmate debugging only if the workflow is manually triggered, debug_enabled is true, and the workflow failed
- name: Setup tmate session for debugging
if: ${{ failure() && github.event_name == 'workflow_dispatch' && inputs.debug_enabled }}
uses: mxschmitt/action-tmate@v3
with:
timeout-minutes: 15

View File

@@ -1,5 +1,13 @@
# jazz-example-book-shelf
## 0.1.33
### Patch Changes
- jazz-react@0.8.41
- jazz-tools@0.8.41
- jazz-browser-media-images@0.8.41
## 0.1.32
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "jazz-example-book-shelf",
"version": "0.1.32",
"version": "0.1.33",
"private": true,
"scripts": {
"dev": "next dev",
@@ -11,9 +11,9 @@
},
"dependencies": {
"clsx": "^2.0.0",
"jazz-browser-media-images": "workspace:0.8.40",
"jazz-react": "workspace:0.8.40",
"jazz-tools": "workspace:0.8.39",
"jazz-browser-media-images": "workspace:0.8.41",
"jazz-react": "workspace:0.8.41",
"jazz-tools": "workspace:0.8.41",
"next": "14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"

View File

@@ -1,5 +1,22 @@
# chat-rn-clerk
## 1.0.33
### Patch Changes
- cdc7f9f: Fixing react-native examples
- Updated dependencies [cdc7f9f]
- jazz-react-native-auth-clerk@0.8.43
## 1.0.32
### Patch Changes
- jazz-react-native@0.8.41
- jazz-react-native-auth-clerk@0.8.41
- jazz-tools@0.8.41
- jazz-react-native-media-images@0.8.41
## 1.0.31
### Patch Changes

View File

@@ -1,3 +1,4 @@
import "../global.css";
import { ClerkLoaded, ClerkProvider } from "@clerk/clerk-expo";
import { useFonts } from "expo-font";
import { Slot } from "expo-router";

View File

@@ -1,7 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["nativewind/babel"],
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,5 +1,6 @@
// Learn more https://docs.expo.dev/guides/monorepos
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const { FileStore } = require("metro-cache");
const path = require("path");
@@ -31,4 +32,5 @@ config.cacheStores = [
}),
];
module.exports = config;
// module.exports = config;
module.exports = withNativeWind(config, { input: "./global.css" });

View File

@@ -1,7 +1,7 @@
{
"name": "chat-rn-clerk",
"main": "index.js",
"version": "1.0.31",
"version": "1.0.33",
"scripts": {
"build": "expo export -p ios",
"start": "expo start",
@@ -45,7 +45,7 @@
"jazz-react-native-auth-clerk": "workspace:*",
"jazz-react-native-media-images": "workspace:*",
"jazz-tools": "workspace:*",
"nativewind": "^2.0.11",
"nativewind": "^4.1.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-native": "~0.76.3",
@@ -70,7 +70,7 @@
"jest": "^29.2.1",
"jest-expo": "~52.0.2",
"react-test-renderer": "18.2.0",
"tailwindcss": "^3.4.9",
"tailwindcss": "^3.4.15",
"typescript": "~5.6.2"
},
"private": true

View File

@@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
// NOTE: Update this to include the paths to all of your component files.
content: [
"./app/**/*.{js,jsx,ts,tsx}",
"./components/**/*.{js,jsx,ts,tsx}",
"./src/**/*.{js,jsx,ts,tsx}",
],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -1,15 +0,0 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{js,jsx,ts,tsx}",
"./components/**/*.{js,jsx,ts,tsx}",
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};
export default config;

View File

@@ -7,5 +7,5 @@
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx"]
"include": ["**/*.ts", "**/*.tsx", "nativewind-env.d.ts"]
}

View File

@@ -1,5 +1,12 @@
# chat-rn
## 1.0.30
### Patch Changes
- jazz-react-native@0.8.41
- jazz-tools@0.8.41
## 1.0.29
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "chat-rn",
"version": "1.0.29",
"version": "1.0.30",
"main": "index.js",
"scripts": {
"build": "expo export -p ios",

View File

@@ -1,5 +1,14 @@
# chat-vue
## 0.0.24
### Patch Changes
- Updated dependencies [ac216b9]
- jazz-browser@0.8.41
- jazz-tools@0.8.41
- jazz-vue@0.8.41
## 0.0.23
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "chat-vue",
"version": "0.0.23",
"version": "0.0.24",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,17 @@
# jazz-example-chat
## 0.0.119
### Patch Changes
- Updated dependencies [3252502]
- Updated dependencies [6370348]
- Updated dependencies [ac216b9]
- cojson@0.8.41
- jazz-react@0.8.41
- jazz-tools@0.8.41
- jazz-browser-media-images@0.8.41
## 0.0.118
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-chat",
"private": true,
"version": "0.0.118",
"version": "0.0.119",
"type": "module",
"scripts": {
"dev": "vite",
@@ -18,11 +18,11 @@
"@radix-ui/react-toast": "^1.1.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cojson": "workspace:0.8.39",
"cojson": "workspace:0.8.41",
"hash-slash": "workspace:0.2.1",
"jazz-browser-media-images": "workspace:0.8.40",
"jazz-react": "workspace:0.8.40",
"jazz-tools": "workspace:0.8.39",
"jazz-browser-media-images": "workspace:0.8.41",
"jazz-react": "workspace:0.8.41",
"jazz-tools": "workspace:0.8.41",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.3.1",

View File

@@ -1,5 +1,13 @@
# minimal-auth-clerk
## 0.0.18
### Patch Changes
- jazz-react@0.8.41
- jazz-react-auth-clerk@0.8.41
- jazz-tools@0.8.41
## 0.0.17
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "clerk",
"private": true,
"version": "0.0.17",
"version": "0.0.18",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,7 +13,7 @@
"dependencies": {
"@clerk/clerk-react": "^5.4.1",
"jazz-react": "workspace:*",
"jazz-react-auth-clerk": "workspace:0.8.40",
"jazz-react-auth-clerk": "workspace:0.8.41",
"jazz-tools": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"

View File

@@ -1,5 +1,12 @@
# file-share-svelte
## 0.0.4
### Patch Changes
- jazz-tools@0.8.41
- jazz-svelte@0.8.41
## 0.0.3
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "file-share-svelte",
"version": "0.0.3",
"version": "0.0.4",
"private": true,
"type": "module",
"scripts": {

View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/test-results/
/playwright-report/

View File

@@ -1,5 +1,13 @@
# form
## 0.0.14
### Patch Changes
- jazz-react@0.8.41
- jazz-tools@0.8.41
- jazz-browser-media-images@0.8.41
## 0.0.13
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "form",
"private": true,
"version": "0.0.13",
"version": "0.0.14",
"type": "module",
"scripts": {
"dev": "vite",
@@ -21,11 +21,13 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@playwright/test": "^1.46.1",
"@tailwindcss/forms": "^0.5.9",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"is-ci": "^3.0.1",
"globals": "^15.11.0",
"postcss": "^8.4.27",
"tailwindcss": "^3.4.9",

View File

@@ -0,0 +1,46 @@
import { defineConfig, devices } from "@playwright/test";
import isCI from "is-ci";
/**
* 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

@@ -22,6 +22,7 @@ export function Orders() {
<h1 className="text-lg pb-2 border-b mb-3">
<strong>Your orders 🧋</strong>
</h1>
{me?.profile?.orders?.length ? (
me?.profile?.orders.map((order) =>
order ? <OrderThumbnail key={order.id} order={order} /> : null,

View File

@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";
test("create and edit an order", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.fillUsername("Alice");
await loginPage.signup();
// start an order
await page.getByRole("link", { name: "Add new order" }).click();
await page.getByLabel("Base tea").selectOption("Oolong");
// test draft indicator
await page.getByRole("link", { name: /Back to all orders/ }).click();
await expect(page.getByText("You have a draft")).toBeVisible();
// fill out the rest of order form
await page.getByRole("link", { name: "Add new order" }).click();
await page.getByLabel("Pearl").check();
await page.getByLabel("Taro").check();
await page.getByLabel("Delivery date").fill("2024-12-21");
await page.getByLabel("With milk?").check();
await page.getByLabel("Special instructions").fill("25% sugar");
await page.getByRole("button", { name: "Submit" }).click();
await page.waitForURL("/");
// the draft indicator should be gone because the order was submitted
await expect(page.getByText("You have a draft")).toHaveCount(0);
// check if order was created correctly
const firstOrder = page.getByRole("link", { name: "Oolong milk tea" });
await expect(firstOrder).toHaveText(/25% sugar/);
await expect(firstOrder).toHaveText(/12\/21\/2024/);
await expect(firstOrder).toHaveText(/with pearl, taro/);
// edit order
await firstOrder.click();
await page.getByLabel("Base tea").selectOption("Jasmine");
await page.getByLabel("Red bean").check();
await page.getByLabel("Brown sugar").check();
await page.getByLabel("Delivery date").fill("2024-12-25");
await page.getByLabel("With milk?").uncheck();
await page.getByLabel("Special instructions").fill("10% sugar");
await page.getByRole("link", { name: /Back to all orders/ }).click();
// check if order was edited correctly
const editedOrder = page.getByRole("link", { name: "Jasmine tea" });
await expect(editedOrder).toHaveText(/10% sugar/);
await expect(editedOrder).toHaveText(/12\/25\/2024/);
await expect(editedOrder).toHaveText(
/with pearl, taro, red bean, brown sugar/,
);
});

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: value,
})
.click();
}
async signup() {
await this.signupButton.click();
}
async expectLoaded() {
await expect(this.signupButton).toBeVisible();
}
}

View File

@@ -1,5 +1,13 @@
# image-upload
## 0.0.16
### Patch Changes
- jazz-react@0.8.41
- jazz-tools@0.8.41
- jazz-browser-media-images@0.8.41
## 0.0.15
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "image-upload",
"private": true,
"version": "0.0.15",
"version": "0.0.16",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,15 @@
# jazz-example-inspector
## 0.0.87
### Patch Changes
- Updated dependencies [3252502]
- Updated dependencies [6370348]
- Updated dependencies [ac216b9]
- cojson@0.8.41
- cojson-transport-ws@0.8.41
## 0.0.86
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-inspector",
"private": true,
"version": "0.0.86",
"version": "0.0.87",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,8 +16,8 @@
"@radix-ui/react-toast": "^1.1.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cojson": "workspace:0.8.39",
"cojson-transport-ws": "workspace:0.8.39",
"cojson": "workspace:0.8.41",
"cojson-transport-ws": "workspace:0.8.41",
"hash-slash": "workspace:0.2.1",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",

View File

@@ -1,5 +1,12 @@
# jazz-example-musicplayer
## 0.0.39
### Patch Changes
- jazz-react@0.8.41
- jazz-tools@0.8.41
## 0.0.38
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-music-player",
"private": true,
"version": "0.0.38",
"version": "0.0.39",
"type": "module",
"scripts": {
"dev": "vite",
@@ -18,8 +18,8 @@
"@radix-ui/react-toast": "^1.1.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "workspace:0.8.40",
"jazz-tools": "workspace:0.8.39",
"jazz-react": "workspace:0.8.41",
"jazz-tools": "workspace:0.8.41",
"lucide-react": "^0.274.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

@@ -1,5 +1,13 @@
# jazz-example-onboarding
## 0.0.20
### Patch Changes
- jazz-react@0.8.41
- jazz-tools@0.8.41
- jazz-browser-media-images@0.8.41
## 0.0.19
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-onboarding",
"private": true,
"version": "0.0.19",
"version": "0.0.20",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,11 @@
# passkey-svelte
## 0.0.8
### Patch Changes
- jazz-svelte@0.8.41
## 0.0.7
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "passkey-svelte",
"version": "0.0.7",
"version": "0.0.8",
"type": "module",
"private": true,
"scripts": {

View File

@@ -1,5 +1,12 @@
# minimal-auth-passkey
## 0.0.17
### Patch Changes
- jazz-react@0.8.41
- jazz-tools@0.8.41
## 0.0.16
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "passkey",
"private": true,
"version": "0.0.16",
"version": "0.0.17",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,12 @@
# jazz-password-manager
## 0.0.38
### Patch Changes
- jazz-react@0.8.41
- jazz-tools@0.8.41
## 0.0.37
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-password-manager",
"private": true,
"version": "0.0.37",
"version": "0.0.38",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,8 +12,8 @@
"clean-install": "rm -rf node_modules pnpm-lock.yaml && pnpm install"
},
"dependencies": {
"jazz-react": "workspace:0.8.40",
"jazz-tools": "workspace:0.8.39",
"jazz-react": "workspace:0.8.41",
"jazz-tools": "workspace:0.8.41",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.41.5",

View File

@@ -1,5 +1,13 @@
# jazz-example-pets
## 0.0.136
### Patch Changes
- jazz-react@0.8.41
- jazz-tools@0.8.41
- jazz-browser-media-images@0.8.41
## 0.0.135
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-pets",
"private": true,
"version": "0.0.135",
"version": "0.0.136",
"type": "module",
"scripts": {
"dev": "vite",
@@ -19,9 +19,9 @@
"@radix-ui/react-toast": "^1.1.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-browser-media-images": "workspace:0.8.40",
"jazz-react": "workspace:0.8.40",
"jazz-tools": "workspace:0.8.39",
"jazz-browser-media-images": "workspace:0.8.41",
"jazz-react": "workspace:0.8.41",
"jazz-tools": "workspace:0.8.41",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.3.1",
@@ -41,7 +41,7 @@
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.20",
"is-ci": "^3.0.1",
"jazz-run": "workspace:0.8.40",
"jazz-run": "workspace:0.8.41",
"postcss": "^8.4.27",
"tailwindcss": "^3.4.9",
"typescript": "~5.6.2",

View File

@@ -1,5 +1,13 @@
# reactions
## 0.0.16
### Patch Changes
- jazz-react@0.8.41
- jazz-tools@0.8.41
- jazz-browser-media-images@0.8.41
## 0.0.15
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "reactions",
"private": true,
"version": "0.0.15",
"version": "0.0.16",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,14 @@
# todo-vue
## 0.0.22
### Patch Changes
- Updated dependencies [ac216b9]
- jazz-browser@0.8.41
- jazz-tools@0.8.41
- jazz-vue@0.8.41
## 0.0.21
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "todo-vue",
"version": "0.0.21",
"version": "0.0.22",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,5 +1,12 @@
# jazz-example-todo
## 0.0.135
### Patch Changes
- jazz-react@0.8.41
- jazz-tools@0.8.41
## 0.0.134
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "jazz-example-todo",
"private": true,
"version": "0.0.134",
"version": "0.0.135",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,8 +16,8 @@
"@radix-ui/react-toast": "^1.1.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"jazz-react": "workspace:0.8.40",
"jazz-tools": "workspace:0.8.39",
"jazz-react": "workspace:0.8.41",
"jazz-tools": "workspace:0.8.41",
"lucide-react": "^0.274.0",
"qrcode": "^1.5.3",
"react": "^18.3.1",

View File

@@ -1,7 +1,7 @@
import { clsx } from "clsx";
import { LucideIcon } from "lucide-react";
import Link from "next/link";
import { forwardRef } from "react";
import { Icon } from "../atoms/Icon";
import { Spinner } from "./Spinner";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
@@ -9,7 +9,7 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
size?: "sm" | "md" | "lg";
href?: string;
newTab?: boolean;
icon?: LucideIcon;
icon?: string;
loading?: boolean;
loadingText?: string;
children?: React.ReactNode;
@@ -17,14 +17,16 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
disabled?: boolean;
}
function ButtonIcon({ icon: Icon, loading }: ButtonProps) {
function ButtonIcon({ icon, loading }: ButtonProps) {
if (!Icon) return null;
const className = "size-5";
if (loading) return <Spinner className={className} />;
return <Icon strokeWidth={1.5} className={className} />;
if (icon) {
return <Icon name={icon} className={className} />;
}
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(

View File

@@ -0,0 +1,138 @@
import {
ArrowDownIcon,
ArrowRightIcon,
BookTextIcon,
BoxIcon,
CheckIcon,
ChevronDown,
ChevronRight,
CodeIcon,
CopyIcon,
FileLock2Icon,
FileTextIcon,
FingerprintIcon,
FolderArchiveIcon,
GaugeIcon,
GlobeIcon,
ImageIcon,
LinkIcon,
LockKeyholeIcon,
LucideIcon,
MailIcon,
MenuIcon,
MessageCircleQuestionIcon,
MonitorSmartphoneIcon,
MoonIcon,
MousePointerSquareDashedIcon,
PencilLineIcon,
ScanFace,
SunIcon,
TrashIcon,
UploadCloudIcon,
UserIcon,
UserPlusIcon,
UsersIcon,
WifiOffIcon,
XIcon,
} from "lucide-react";
const icons = {
addUser: UserPlusIcon,
arrowDown: ArrowDownIcon,
arrowRight: ArrowRightIcon,
auth: UserIcon,
browser: GlobeIcon,
check: CheckIcon,
chevronRight: ChevronRight,
chevronDown: ChevronDown,
close: XIcon,
code: CodeIcon,
copy: CopyIcon,
darkTheme: MoonIcon,
delete: TrashIcon,
devices: MonitorSmartphoneIcon,
docs: BookTextIcon,
encryption: LockKeyholeIcon,
faceId: ScanFace,
file: FileTextIcon,
help: MessageCircleQuestionIcon,
image: ImageIcon,
instant: GaugeIcon,
lightTheme: SunIcon,
link: LinkIcon,
menu: MenuIcon,
newsletter: MailIcon,
offline: WifiOffIcon,
package: BoxIcon,
permissions: FileLock2Icon,
social: UsersIcon,
spatialPresence: MousePointerSquareDashedIcon,
touchId: FingerprintIcon,
upload: UploadCloudIcon,
write: PencilLineIcon,
zip: FolderArchiveIcon,
};
// copied from tailwind line height https://tailwindcss.com/docs/font-size
const sizes = {
xs: 16,
sm: 20,
md: 24,
lg: 28,
xl: 28,
"2xl": 32,
"3xl": 36,
"4xl": 40,
"5xl": 48,
"6xl": 60,
"7xl": 72,
"8xl": 96,
"9xl": 128,
};
const strokeWidths = {
xs: 2,
sm: 2,
md: 1.5,
lg: 1.5,
xl: 1.5,
"2xl": 1.25,
"3xl": 1.25,
"4xl": 1.25,
"5xl": 1,
"6xl": 1,
"7xl": 1,
"8xl": 1,
"9xl": 1,
};
export function Icon({
name,
icon,
size = "md",
className,
...svgProps
}: {
name?: string;
icon?: LucideIcon;
size?: keyof typeof sizes;
className?: string;
} & React.SVGProps<SVGSVGElement>) {
if (!icon && (!name || !icons.hasOwnProperty(name))) {
throw new Error(`Icon not found`);
}
// @ts-ignore
const IconComponent = icons?.hasOwnProperty(name) ? icons[name] : icon;
return (
<IconComponent
aria-hidden="true"
size={sizes[size]}
strokeWidth={strokeWidths[size]}
strokeLinecap="butt"
className={className}
{...svgProps}
/>
);
}

View File

@@ -1,8 +1,8 @@
"use client";
import { clsx } from "clsx";
import { Clipboard } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Icon } from "../atoms/Icon";
// TODO: add tabs feature, and remove CodeExampleTabs
@@ -44,11 +44,12 @@ function CopyButton({ code, size }: { code: string; size?: "sm" | "md" }) {
copied && "-translate-y-1.5 opacity-0",
)}
>
<Clipboard
strokeWidth={1}
<Icon
name="copy"
size="xs"
className={clsx(
size === "sm" ? "h-3 w-3" : "h-4 w-4",
"fill-stone-500/20 stroke-stone-500 transition-colors group-hover/button:stroke-stone-600 dark:group-hover/button:stroke-stone-400",
size === "sm" ? "size-2" : "size-3",
"stroke-stone-500 transition-colors group-hover/button:stroke-stone-600 dark:group-hover/button:stroke-stone-400",
)}
/>
Copy

View File

@@ -1,29 +1,28 @@
import clsx from "clsx";
import { LucideIcon } from "lucide-react";
import { Card } from "../atoms/Card";
import { Icon } from "../atoms/Icon";
import { Prose } from "./Prose";
export function FeatureCard({
label,
icon: Icon,
icon,
explanation,
children,
className,
}: {
label: React.ReactNode;
icon?: LucideIcon;
icon?: string;
explanation?: React.ReactNode;
children?: React.ReactNode;
className?: string;
}) {
return (
<Card className={clsx(className, "p-4")}>
{Icon && (
{icon && (
<Icon
className="size-8 text-blue p-1.5 rounded-lg bg-blue-50 dark:text-blue-500 dark:bg-stone-900 mb-2.5 md:size-10"
strokeWidth={1.5}
strokeLinecap="butt"
size={80}
name={icon}
className="text-blue p-1.5 rounded-lg bg-blue-50 dark:text-blue-500 dark:bg-stone-900 mb-2.5"
size="3xl"
/>
)}
<div className="text-stone-900 font-medium md:text-base dark:text-stone-100 mb-2">

View File

@@ -1,8 +1,8 @@
"use client";
import { clsx } from "clsx";
import { ChevronDownIcon } from "lucide-react";
import { useId } from "react";
import { Icon } from "../atoms/Icon";
export function Select(
props: React.SelectHTMLAttributes<HTMLSelectElement> & { label: string },
@@ -32,9 +32,10 @@ export function Select(
{props.children}
</select>
<ChevronDownIcon
<Icon
name="chevronDown"
className="absolute right-[0.5em] text-stone-400 dark:text-stone-600"
size={16}
size="xs"
/>
</div>
</div>

View File

@@ -1,10 +1,9 @@
"use client";
import clsx from "clsx";
import { MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { UseThemeProps } from "next-themes/dist/types";
import { useEffect, useState } from "react";
import { Icon } from "../atoms/Icon";
export function ThemeToggle({
className,
@@ -31,14 +30,14 @@ export function ThemeToggle({
aria-label={mounted ? `Switch to ${otherTheme} theme` : "Toggle theme"}
onClick={() => setTheme(otherTheme)}
>
<MoonIcon
size={24}
strokeWidth={2}
<Icon
name="darkTheme"
size="lg"
className="size-5 stroke-stone-900 dark:hidden"
/>
<SunIcon
size={24}
strokeWidth={2}
<Icon
name="lightTheme"
size="lg"
className="size-5 hidden stroke-white dark:block"
/>
</button>

View File

@@ -8,7 +8,6 @@ import {
PopoverPanel,
} from "@headlessui/react";
import clsx from "clsx";
import { ChevronDownIcon, MenuIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
@@ -19,12 +18,13 @@ import {
useRef,
useState,
} from "react";
import { Icon } from "../atoms/Icon";
import { BreadCrumb } from "../molecules/Breadcrumb";
import { SocialLinks, SocialLinksProps } from "./SocialLinks";
type NavItemProps = {
href: string;
icon?: ReactNode;
icon?: string;
title: string;
firstOnRight?: boolean;
newTab?: boolean;
@@ -56,7 +56,7 @@ function NavItem({
if (item.icon) {
return (
<NavLinkLogo className="px-3" {...item}>
{icon}
<Icon name={item.icon} />
<span className="sr-only">{title}</span>
</NavLinkLogo>
);
@@ -86,7 +86,7 @@ function NavItem({
)}
>
<span>{title}</span>
<ChevronDownIcon aria-hidden="true" className="size-4" />
<Icon name="chevronDown" size="xs" />
</PopoverButton>
<PopoverPanel
@@ -103,7 +103,13 @@ function NavItem({
as={Link}
key={href}
>
{icon}
{icon && (
<Icon
className="stroke-blue dark:stroke-blue-500 shrink-0"
size="sm"
name={icon}
/>
)}
<div className="grid gap-1.5 mt-px">
<p className="text-sm font-medium text-stone-900 dark:text-white">
{title}
@@ -155,7 +161,7 @@ export function MobileNav({
}}
aria-label="Open menu"
>
<MenuIcon />
<Icon name="menu" />
<BreadCrumb items={items} />
</button>
</div>
@@ -223,10 +229,10 @@ export function MobileNav({
aria-label="Close menu"
>
{menuOpen || searchOpen ? (
<XIcon />
<Icon name="close" />
) : (
<>
<MenuIcon />
<Icon name="menu" />
<BreadCrumb items={items} />
</>
)}

View File

@@ -1,10 +1,10 @@
"use client";
import { CheckIcon, MailIcon } from "lucide-react";
import { useState } from "react";
import { ErrorResponse } from "resend";
import { subscribe } from "../../../actions/resend";
import { Button } from "../atoms/Button";
import { Icon } from "../atoms/Icon";
import { Input } from "../molecules/Input";
export function NewsletterForm() {
@@ -34,7 +34,7 @@ export function NewsletterForm() {
if (state === "success") {
return (
<div className="flex gap-3 items-center">
<CheckIcon className="text-green-500" size={16} />
<Icon name="check" className="text-green-500" />
<p>Thanks for subscribing!</p>
</div>
);
@@ -63,7 +63,7 @@ export function NewsletterForm() {
variant="secondary"
loadingText="Subscribing..."
loading={state === "loading"}
icon={MailIcon}
icon="newsletter"
>
Subscribe
</Button>

View File

@@ -1,6 +1,6 @@
import { packages } from "@/lib/packages";
import { clsx } from "clsx";
import { MessageCircleQuestionIcon, PackageIcon } from "lucide-react";
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import Link from "next/link";
const CardHeading = ({
@@ -64,9 +64,8 @@ export default function Page() {
key={name}
>
<Card className="border shadow-sm">
<PackageIcon
size={25}
strokeWidth={1.5}
<Icon
name="package"
className="text-stone-500 dark:text-stone-400"
/>
<CardHeading className="group-hover:text-blue dark:group-hover:text-blue-600">
@@ -78,9 +77,9 @@ export default function Page() {
))}
<Card className="bg-stone-50 dark:bg-stone-925">
<MessageCircleQuestionIcon
size={25}
strokeWidth={1.5}
<Icon
name="help"
size="md"
className="text-stone-500 dark:text-stone-400"
/>
<CardHeading>

View File

@@ -0,0 +1,127 @@
import { CodeGroup } from "@/components/forMdx";
# Sharing data through Organizations
Organizations are a way to share a set of data between users.
Different apps have different names for this concept, such as "teams" or "workspaces".
We'll use the term Organization.
## Defining the schema for an Organization
Create a CoMap shared by the users of the same organization to act as a root (or "main database") for the shared data within an organization.
For this example, users within an `Organization` will be sharing `Project`s.
<CodeGroup>
```ts
// schema.ts
export class Project extends CoMap {
name = co.string;
}
export class ListOfProjects extends CoList.Of(co.ref(Project)) {}
export class Organization extends CoMap {
name = co.string;
// shared data between users of each organization
projects = co.ref(ListOfProjects);
}
export class ListOfOrganizations extends CoList.Of(co.ref(Organization)) {}
```
</CodeGroup>
Learn more about [defining schemas](/docs/schemas/covalues).
## Adding a list of Organizations to the user's Account
Let's add the list of `Organization`s to the user's Account `root` so they can access them.
<CodeGroup>
```ts
// schema.ts
export class JazzAccountRoot extends CoMap {
organizations = co.ref(ListOfOrganizations);
}
export class JazzAccount extends Account {
root = co.ref(JazzAccountRoot);
async migrate(creationProps?: { name: string }) {
super.migrate(creationProps);
if (!this._refs.root) {
// Using a Group as an owner allows you to give access to other users
const initialOrganizationOwnership = {
owner: Group.create({ owner: this }),
};
const organizations = ListOfOrganizations.create(
[
// Create the first Organization so users can start right away
Organization.create(
{
name: "My organization",
projects: ListOfProjects.create([], initialOrganizationOwnership),
},
initialOrganizationOwnership,
),
],
{ owner: this },
);
this.root = JazzAccountRoot.create(
{ organizations },
{ owner: this },
);
}
}
}
```
</CodeGroup>
This schema now allows users to create `Organization`s and add `Project`s to them.
## Adding other users to an Organization
To give users access to an `Organization`, you can either send them an invite link, or
add their `Account` manually.
### Adding users through invite links
Here's how you can generate an [invite link](/docs/groups/sharing#invite-links).
When the user accepts the invite, add the `Organization` to the user's `organizations` list.
<CodeGroup>
```ts
const onAccept = (organizationId: ID<Organization>) => {
if (me?.root?.organizations) {
Organization.load(organizationId, me, []).then((organization) => {
if (organization) {
// Avoid duplicates
const ids = me.root.organizations.map(
(organization) => organization?.id,
);
if (ids.includes(organizationId)) return;
me.root.organizations.push(organization);
navigate("/organizations/" + organizationId);
}
});
}
};
useAcceptInvite({
invitedObjectSchema: Organization,
onAccept,
});
```
</CodeGroup>
### Adding users through their Account ID
...more on this coming soon

View File

@@ -6,16 +6,6 @@ import { VueLogo } from "@/components/icons/VueLogo";
import { H2 } from "gcmp-design-system/src/app/components/atoms/Headings";
import { GappedGrid } from "gcmp-design-system/src/app/components/molecules/GappedGrid";
import { HeroHeader } from "gcmp-design-system/src/app/components/molecules/HeroHeader";
import {
CloudUploadIcon,
FingerprintIcon,
FolderArchiveIcon,
Icon,
ImageIcon,
LockIcon,
PencilLineIcon,
UserPlusIcon,
} from "lucide-react";
import {
Schema_ts as ImageUploadSchema,
@@ -29,6 +19,7 @@ import { ExampleCard } from "@/components/examples/ExampleCard";
import { ExampleDemo } from "@/components/examples/ExampleDemo";
import { SvelteLogo } from "@/components/icons/SvelteLogo";
import { Example, features, tech } from "@/lib/example";
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
const MockButton = ({ children }: { children: React.ReactNode }) => (
<p className="bg-blue-100 text-blue-800 py-1 p-2 rounded-full font-medium text-center text-xs">
@@ -81,16 +72,16 @@ const OnboardingIllustration = () => (
<div className="flex h-full flex-col justify-center text-sm dark:bg-transparent">
<div className="mx-auto grid gap-3">
{[
{ icon: UserPlusIcon, text: "Add new employee" },
{ icon: "addUser", text: "Add new employee" },
{
icon: PencilLineIcon,
icon: "write",
text: "Invite employee to fill in their profile",
},
{ icon: LockIcon, text: "Get confirmation from admin" },
].map(({ text, icon: Icon }, index) => (
{ icon: "permissions", text: "Get confirmation from admin" },
].map(({ text, icon }, index) => (
<div className="flex items-center gap-2">
<span className="text-xs text-green-800 bg-green-100 leading-none font-medium text-center p-1.5 block rounded-full dark:bg-green-800 dark:text-green-200">
<Icon strokeWidth={2} size={15} />
<Icon name={icon} size="xs" />
</span>
{text}
</div>
@@ -102,9 +93,9 @@ const OnboardingIllustration = () => (
const MusicIllustration = () => (
<div className="flex flex-col items-center justify-center h-full p-8">
<div className="p-3 w-[12rem] h-[8rem] border border-dashed border-blue dark:border-blue-500 rounded-lg flex gap-2 flex-col items-center justify-center">
<CloudUploadIcon
size={40}
strokeWidth={1.5}
<Icon
name="upload"
size="4xl"
className="stroke-blue mx-auto dark:stroke-blue-500"
/>
<p className="whitespace-nowrap text-stone-900 dark:text-white">
@@ -117,9 +108,9 @@ const MusicIllustration = () => (
const ImageUploadIllustration = () => (
<div className="flex flex-col items-center justify-center h-full p-8">
<div className="p-3 w-[12rem] h-[8rem] border border-dashed border-blue dark:border-blue-500 rounded-lg flex gap-2 flex-col items-center justify-center">
<ImageIcon
size={40}
strokeWidth={1.5}
<Icon
name="upload"
size="4xl"
className="stroke-blue mx-auto dark:stroke-blue-500"
/>
<p className="whitespace-nowrap text-stone-900 dark:text-white">
@@ -245,9 +236,9 @@ const FileShareIllustration = () => (
<p>This file was shared with you.</p>
<div className="p-3 w-full border rounded-lg flex justify-between gap-5">
<div className="flex items-center gap-2">
<FolderArchiveIcon
size={24}
strokeWidth={1.5}
<Icon
name="zip"
size="xl"
className="stroke-blue dark:stroke-blue-500"
/>
<p className="whitespace-nowrap text-stone-900 dark:text-white">
@@ -263,11 +254,7 @@ const FileShareIllustration = () => (
const PasskeyIllustration = () => (
<div className="flex bg-stone-100 h-full flex-col items-center justify-center dark:bg-transparent">
<div className="p-4 flex flex-col items-center gap-3 rounded-md shadow-xl shadow-stone-400/20 bg-white dark:shadow-none">
<FingerprintIcon
size={36}
strokeWidth={0.75}
className="stroke-red-600"
/>
<Icon name="touchId" size="3xl" className="stroke-red-600" />
<p className="text-xs dark:text-stone-900">Continue with Touch ID</p>
</div>
</div>

View File

@@ -1,12 +1,9 @@
import { SideNav } from "@/components/SideNav";
import { SideNavHeader } from "@/components/SideNavHeader";
import { SideNavItem } from "@/components/SideNavItem";
import { docNavigationItems } from "@/lib/docNavigationItems";
import { packages } from "@/lib/packages";
import { clsx } from "clsx";
import { ChevronRight, PackageIcon } from "lucide-react";
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import Link from "next/link";
import { twMerge } from "tailwind-merge";
import { requestProject } from "./requestProject";
export function ApiNav({ className }: { className?: string }) {
@@ -37,7 +34,7 @@ export async function PackageNavItem({
className="mb-1 flex gap-2 items-center"
href={`/api-reference/${packageName}`}
>
<PackageIcon size={15} strokeWidth={1.5} />
<Icon name="package" size="xs" />
{packageName}
</SideNavItem>
{project.categories?.map((category) => {
@@ -50,7 +47,11 @@ export async function PackageNavItem({
<summary className="pl-[13px] py-1 cursor-pointer flex gap-2 items-center justify-between hover:text-stone-800 dark:hover:text-stone-200 [&::-webkit-details-marker]:hidden">
{category.title}
<ChevronRight className="w-4 h-4 text-stone-300 group-open:rotate-90 transition-transform dark:text-stone-800" />
<Icon
name="chevronRight"
size="sm"
className="text-stone-300 group-open:rotate-90 transition-transform dark:text-stone-800"
/>
</summary>
<div className="pl-6">
{category.children.map(

View File

@@ -1,4 +1,4 @@
import { PackageIcon, Type } from "lucide-react";
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import {
CommentDisplayPart,
DeclarationReflection,
@@ -30,7 +30,7 @@ export async function PackageDocs({
return (
<>
<h2 className="flex items-center gap-2">
<code>{packageName}</code> <PackageIcon />
<code>{packageName}</code> <Icon name="package" size="md" />
</h2>
{project.categories?.map((category) => {
return (

View File

@@ -1,4 +1,4 @@
import { LinkIcon } from "lucide-react";
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import Link from "next/link";
import { ReactNode } from "react";
import { getHighlighter } from "shiki";
@@ -79,7 +79,7 @@ export function ClassOrInterface({
href={"#" + name}
className="inline-flex items-center gap-2 lg:-ml-[22px]"
>
<LinkIcon size={14} className="hidden lg:inline" />
<Icon name="link" size="xs" className="hidden lg:inline" />
<h3 className="text-lg lg:text-xl">
<Highlight>
{(isInterface ? "interface " : "class ") + name + typeParameters}

View File

@@ -2,9 +2,9 @@
import { Card } from "gcmp-design-system/src/app/components/atoms/Card";
import { H3 } from "gcmp-design-system/src/app/components/atoms/Headings";
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import { GappedGrid } from "gcmp-design-system/src/app/components/molecules/GappedGrid";
import { SectionHeader } from "gcmp-design-system/src/app/components/molecules/SectionHeader";
import { CheckIcon, CopyIcon } from "lucide-react";
import Link from "next/link";
import QRCode from "qrcode";
import {
@@ -179,7 +179,11 @@ export function ChatDemoSection() {
className="text-blue dark:text-blue-400"
onClick={copyUrl}
>
{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
{copied ? (
<Icon name="check" size="xs" />
) : (
<Icon name="copy" size="xs" />
)}
<span className="sr-only">Copy URL</span>
</button>
</div>

View File

@@ -1,7 +1,7 @@
import { Card } from "gcmp-design-system/src/app/components/atoms/Card";
import { H3 } from "gcmp-design-system/src/app/components/atoms/Headings";
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import { Prose } from "gcmp-design-system/src/app/components/molecules/Prose";
import { LockKeyholeIcon } from "lucide-react";
const randomChars = [
"SFPOHVKNPDKETOMQLMJKX#QDI=TFFFMRJDSJ",
@@ -48,13 +48,19 @@ function Illustration() {
)}
</div>
<LockKeyholeIcon
strokeWidth={1.5}
strokeLinecap="butt"
size={80}
className="z-30 size-8 text-blue p-1.5 rounded-lg bg-blue-50 dark:text-blue-500 dark:bg-stone-900 md:size-10"
<Icon
name="encryption"
size="3xl"
className="z-30 text-blue p-1.5 rounded-lg bg-blue-50 dark:text-blue-500 dark:bg-stone-900"
/>
{/*<LockKeyholeIcon*/}
{/* strokeWidth={1.5}*/}
{/* strokeLinecap="butt"*/}
{/* size={80}*/}
{/* className="z-30 size-8 text-blue p-1.5 rounded-lg bg-blue-50 dark:text-blue-500 dark:bg-stone-900 md:size-10"*/}
{/*/>*/}
<div className="w-20 h-full bg-gradient-to-r from-white to-transparent absolute top-0 left-0 z-10 dark:from-stone-925"></div>
<div className="hidden md:block h-20 w-full bg-gradient-to-b from-white to-transparent absolute top-0 left-0 z-10 dark:from-stone-925"></div>
<div className="h-20 w-full bg-gradient-to-t from-white to-transparent absolute bottom-0 left-0 z-10 dark:from-stone-925"></div>

View File

@@ -3,24 +3,14 @@ import { ClerkLogo } from "@/components/icons/ClerkLogo";
import { Button } from "gcmp-design-system/src/app/components/atoms/Button";
import { Card } from "gcmp-design-system/src/app/components/atoms/Card";
import { H3 } from "gcmp-design-system/src/app/components/atoms/Headings";
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import { Prose } from "gcmp-design-system/src/app/components/molecules/Prose";
import { SectionHeader } from "gcmp-design-system/src/app/components/molecules/SectionHeader";
import {
CheckIcon,
FileTextIcon,
FingerprintIcon,
ImageIcon,
ScanFace,
TrashIcon,
UploadCloudIcon,
UserIcon,
} from "lucide-react";
import Link from "next/link";
const features = [
{
title: "File uploads",
icon: UploadCloudIcon,
description: (
<>
Just use <code>{`<input type="file"/>`}</code>, and easily convert from
@@ -37,22 +27,21 @@ const features = [
</pre>
<div className="w-full bg-white rounded-md py-3 px-3 flex gap-4 items-center border rounded-xl shadow-lg shadow-stone-500/10 dark:bg-stone-925">
<FileTextIcon
size={32}
strokeWidth={1}
<Icon
size="2xl"
name="file"
className="text-blue dark:text-blue-500"
/>
<div className="text-2xl flex-1 text-blue dark:text-blue-500">
file.pdf
</div>
<TrashIcon size={32} strokeWidth={1} className="text-stone-500" />
<Icon size="2xl" name="delete" className="text-stone-500" />
</div>
</div>
),
},
{
title: "Progressive image loading",
icon: ImageIcon,
description: (
<>
Using Jazz&apos;s <code>ImageDefinition</code> component, you get
@@ -80,7 +69,6 @@ const features = [
},
{
title: "Server workers",
icon: ImageIcon,
description: (
<>
Expose an HTTP API that mutates Jazz state. Or subscribe to Jazz state
@@ -91,7 +79,6 @@ const features = [
},
{
title: "Authentication",
icon: UserIcon,
description: (
<>
Plug and play different kinds of auth like Passkeys (Touch ID, Face ID),
@@ -100,9 +87,9 @@ const features = [
),
illustration: (
<div className="flex gap-4 justify-center text-black dark:text-white">
<ScanFace className="h-16 w-auto" strokeWidth={1} />
<Icon size="5xl" name="faceId" className="h-16 w-auto" />
<ClerkLogo className="h-16 py-0.5 w-auto" />
<FingerprintIcon className="h-16 w-auto" strokeWidth={1} />
<Icon size="5xl" name="touchId" className="h-16 w-auto" />
</div>
),
},
@@ -124,7 +111,7 @@ export function FeaturesSection() {
/>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-4 lg:gap-8">
{features.map(({ title, icon: Icon, description, illustration }) => (
{features.map(({ title, description, illustration }) => (
<Card key={title} className="col-span-2 overflow-hidden">
<div className="h-48 flex w-full items-center justify-center">
{illustration}
@@ -183,7 +170,7 @@ export function FeaturesSection() {
className="flex items-center gap-1.5 whitespace-nowrap"
>
<span className="text-blue p-1 rounded-full bg-blue-50 dark:text-blue-500 dark:bg-white/10">
<CheckIcon size={12} strokeWidth={3} />
<Icon name="check" size="xs" />
</span>
{feature}
</li>

View File

@@ -1,48 +1,39 @@
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import { Prose } from "gcmp-design-system/src/app/components/molecules/Prose";
import {
FileLock2Icon,
GaugeIcon,
LockKeyholeIcon,
MonitorSmartphoneIcon,
MousePointerSquareDashedIcon,
UploadCloudIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
import Link from "next/link";
const features = [
{
title: "Instant updates",
icon: GaugeIcon,
icon: "instant",
},
{
title: "Real-time sync",
icon: MonitorSmartphoneIcon,
icon: "devices",
},
{
title: "Multiplayer",
icon: MousePointerSquareDashedIcon,
icon: "spatialPresence",
},
{
title: "File uploads",
icon: UploadCloudIcon,
icon: "upload",
},
{
title: "Social features",
icon: UsersIcon,
icon: "social",
},
{
title: "Permissions",
icon: FileLock2Icon,
icon: "permissions",
},
{
title: "E2E encryption",
icon: LockKeyholeIcon,
icon: "encryption",
},
{
title: "Authentication",
icon: UserIcon,
icon: "auth",
},
];
@@ -74,13 +65,13 @@ export function HeroSection() {
</Prose>
<div className="grid grid-cols-2 gap-2 max-w-3xl sm:grid-cols-4 sm:gap-4">
{features.map(({ title, icon: Icon }) => (
{features.map(({ title, icon }) => (
<div
key={title}
className="flex text-xs sm:text-sm gap-2 items-center"
>
<span className="text-blue p-1.5 rounded-lg bg-blue-50 dark:text-blue-500 dark:bg-stone-900">
<Icon size={16} />
<Icon size="xs" name={icon} />
</span>
<p>{title}</p>
</div>

View File

@@ -1,18 +1,12 @@
import { FeatureCard } from "gcmp-design-system/src/app/components/molecules/FeatureCard";
import { GappedGrid } from "gcmp-design-system/src/app/components/molecules/GappedGrid";
import { SectionHeader } from "gcmp-design-system/src/app/components/molecules/SectionHeader";
import {
GaugeIcon,
MonitorSmartphoneIcon,
MousePointerSquareDashedIcon,
WifiOffIcon,
} from "lucide-react";
export function LocalFirstFeaturesSection() {
const features = [
{
title: "Offline-first",
icon: WifiOffIcon,
icon: "offline",
description: (
<>
Your app works seamlessly offline or on sketchy connections. When
@@ -22,7 +16,7 @@ export function LocalFirstFeaturesSection() {
},
{
title: "Instant updates",
icon: GaugeIcon,
icon: "instant",
description: (
<>
Since you&apos;re working with local state, your UI updates instantly.
@@ -32,7 +26,7 @@ export function LocalFirstFeaturesSection() {
},
{
title: "Real-time sync",
icon: MonitorSmartphoneIcon,
icon: "devices",
description: (
<>
Every device with the same account will always have everything in
@@ -42,7 +36,7 @@ export function LocalFirstFeaturesSection() {
},
{
title: "Multiplayer",
icon: MousePointerSquareDashedIcon,
icon: "spatialPresence",
description: (
<>
Adding multiplayer is as easy as sharing synced data with other users.
@@ -66,10 +60,10 @@ export function LocalFirstFeaturesSection() {
}
/>
<GappedGrid cols={4}>
{features.map(({ title, icon: Icon, description }) => (
{features.map(({ title, icon, description }) => (
<FeatureCard
label={title}
icon={Icon}
icon={icon}
explanation={description}
key={title}
></FeatureCard>

View File

@@ -1,8 +1,8 @@
import { DiagramAfterJazz } from "@/components/DiagramAfterJazz";
import { DiagramBeforeJazz } from "@/components/DiagramBeforeJazz";
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import { Prose } from "gcmp-design-system/src/app/components/molecules/Prose";
import { SectionHeader } from "gcmp-design-system/src/app/components/molecules/SectionHeader";
import { ArrowDownIcon, ArrowRightIcon } from "lucide-react";
export default function ProblemStatementSection() {
return (
@@ -17,12 +17,12 @@ export default function ProblemStatementSection() {
<div className="flex flex-col bg-stone-50 relative gap-3 p-4 pb-8 md:p-8 md:gap-5 border-b sm:border-b-0 sm:border-r dark:bg-transparent dark:border-stone-900">
<span className="hidden absolute top-0 -right-4 md:-right-6 sm:flex items-center h-full">
<span className="p-1 md:p-3 bg-stone-200 rounded-full dark:bg-stone-900 dark:text-white">
<ArrowRightIcon size={24} />
<Icon name="arrowRight" />
</span>
</span>
<span className="sm:hidden w-full absolute -bottom-6 flex justify-center left-0">
<span className="p-3 bg-stone-200 rounded-full dark:bg-stone-900 dark:text-white">
<ArrowDownIcon size={24} />
<Icon name="arrowDown" />
</span>
</span>
<Prose>

View File

@@ -6,7 +6,7 @@ import { RustLogo } from "@/components/icons/RustLogo";
import { SvelteLogo } from "@/components/icons/SvelteLogo";
import { SwiftLogo } from "@/components/icons/SwiftLogo";
import { VueLogo } from "@/components/icons/VueLogo";
import { GlobeIcon } from "lucide-react";
import { Icon } from "gcmp-design-system/src/app/components/atoms/Icon";
import React from "react";
export function SupportedEnvironmentsSection() {
@@ -14,8 +14,9 @@ export function SupportedEnvironmentsSection() {
{
name: "Browser (vanilla JS)",
icon: (
<GlobeIcon
strokeWidth={1}
<Icon
name="browser"
size="3xl"
className="text-stone-900 dark:text-white"
height="1em"
width="1em"

View File

@@ -2,7 +2,6 @@ import { ThemeToggle } from "@/components/ThemeToggle";
import { socials } from "@/lib/socials";
import { JazzLogo } from "gcmp-design-system/src/app/components/atoms/logos/JazzLogo";
import { Nav } from "gcmp-design-system/src/app/components/organisms/Nav";
import { BookTextIcon, BoxIcon, CodeIcon } from "lucide-react";
import { DocNav } from "./docs/nav";
export function JazzNav() {
@@ -17,36 +16,21 @@ export function JazzNav() {
href: "/docs",
items: [
{
icon: (
<BookTextIcon
className="size-5 stroke-blue dark:stroke-blue-500 shrink-0"
strokeWidth={1.5}
/>
),
icon: "docs",
title: "Documentation",
href: "/docs",
description:
"Get started with using Jazz by learning the core concepts, and going through guides.",
},
{
icon: (
<CodeIcon
className="size-5 stroke-blue dark:stroke-blue-500 shrink-0"
strokeWidth={1.5}
/>
),
icon: "code",
title: "Example apps",
href: "/examples",
description:
"Demo and source code for example apps built with Jazz.",
},
{
icon: (
<BoxIcon
className="size-5 stroke-blue dark:stroke-blue-500 shrink-0"
strokeWidth={1.5}
/>
),
icon: "package",
title: "API reference",
href: "/api-reference",
description:

View File

@@ -162,6 +162,16 @@ export const docNavigationItems = [
},
],
},
{
name: "Design patterns",
items: [
{
name: "Organization/Team",
href: "/docs/design-patterns/organization",
done: 80,
},
],
},
{
name: "Resources",
items: [

View File

@@ -30,7 +30,7 @@
"format-and-lint:fix": "biome check . --write",
"changeset": "changeset",
"changeset-version": "changeset version && pnpm i --no-frozen-lockfile",
"release": "pnpm changeset publish && git push --follow-tags",
"release": "turbo run build --filter='./packages/*' && pnpm changeset publish && git push --follow-tags",
"clean": "rm -rf ./packages/*/dist && rm -rf ./packages/*/node_modules && rm -rf ./examples/*/node_modules && rm -rf ./examples/*/dist"
},
"version": "0.0.0",

View File

@@ -1,5 +1,15 @@
# cojson-storage-indexeddb
## 0.8.41
### Patch Changes
- Updated dependencies [3252502]
- Updated dependencies [6370348]
- Updated dependencies [ac216b9]
- cojson@0.8.41
- cojson-storage@0.8.41
## 0.8.40
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage-indexeddb",
"version": "0.8.40",
"version": "0.8.41",
"main": "dist/index.js",
"type": "module",
"types": "src/index.ts",

View File

@@ -1,5 +1,15 @@
# cojson-storage-sqlite
## 0.8.41
### Patch Changes
- Updated dependencies [3252502]
- Updated dependencies [6370348]
- Updated dependencies [ac216b9]
- cojson@0.8.41
- cojson-storage@0.8.41
## 0.8.40
### Patch Changes

View File

@@ -1,13 +1,13 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.8.40",
"version": "0.8.41",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.7.0",
"cojson": "workspace:0.8.39",
"cojson": "workspace:0.8.41",
"cojson-storage": "workspace:*"
},
"devDependencies": {

View File

@@ -1,5 +1,14 @@
# cojson-storage
## 0.8.41
### Patch Changes
- Updated dependencies [3252502]
- Updated dependencies [6370348]
- Updated dependencies [ac216b9]
- cojson@0.8.41
## 0.8.40
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage",
"version": "0.8.40",
"version": "0.8.41",
"main": "dist/index.js",
"type": "module",
"types": "src/index.ts",

View File

@@ -1,5 +1,14 @@
# cojson-transport-nodejs-ws
## 0.8.41
### Patch Changes
- Updated dependencies [3252502]
- Updated dependencies [6370348]
- Updated dependencies [ac216b9]
- cojson@0.8.41
## 0.8.39
### Patch Changes

View File

@@ -1,12 +1,12 @@
{
"name": "cojson-transport-ws",
"type": "module",
"version": "0.8.39",
"version": "0.8.41",
"main": "dist/index.js",
"types": "src/index.ts",
"license": "MIT",
"dependencies": {
"cojson": "workspace:0.8.39",
"cojson": "workspace:0.8.41",
"typescript": "~5.6.2"
},
"scripts": {

View File

@@ -1,5 +1,13 @@
# cojson
## 0.8.41
### Patch Changes
- 3252502: Optimize the transactions processing on CoMap and CoStream
- 6370348: Remove @opentelemetry/api as a peer dependency and add it as a dependency
- ac216b9: Add a new writeOnly role, to limit access only to their own changes. Useful to push objects into lists of moderated content.
## 0.8.39
### Patch Changes

View File

@@ -19,7 +19,7 @@
},
"type": "module",
"license": "MIT",
"version": "0.8.39",
"version": "0.8.41",
"devDependencies": {
"@opentelemetry/sdk-metrics": "^1.29.0",
"@types/jest": "^29.5.3",

View File

@@ -443,7 +443,16 @@ export class CoValueCore {
signatureAfter: signatureAfter,
});
this._cachedContent = undefined;
if (
this._cachedContent &&
"processNewTransactions" in this._cachedContent &&
typeof this._cachedContent.processNewTransactions === "function"
) {
this._cachedContent.processNewTransactions();
} else {
this._cachedContent = undefined;
}
this._cachedKnownState = undefined;
this._cachedDependentOn = undefined;
this._cachedNewContentSinceEmpty = undefined;
@@ -616,14 +625,19 @@ export class CoValueCore {
return newContent;
}
getValidSortedTransactions(options?: {
getValidTransactions(options?: {
ignorePrivateTransactions: boolean;
knownTransactions?: CoValueKnownState["sessions"];
}): DecryptedTransaction[] {
const validTransactions = determineValidTransactions(this);
const allTransactions: DecryptedTransaction[] = [];
for (const { txID, tx } of validTransactions) {
if (options?.knownTransactions?.[txID.sessionID]! >= txID.txIndex) {
continue;
}
if (tx.privacy === "trusting") {
allTransactions.push({
txID,
@@ -670,21 +684,35 @@ export class CoValueCore {
});
}
allTransactions.sort(
(a, b) =>
a.madeAt - b.madeAt ||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
a.txID.txIndex - b.txID.txIndex,
);
return allTransactions;
}
getValidSortedTransactions(options?: {
ignorePrivateTransactions: boolean;
}): DecryptedTransaction[] {
const allTransactions = this.getValidTransactions(options);
allTransactions.sort(this.compareTransactions);
return allTransactions;
}
compareTransactions(
a: Pick<DecryptedTransaction, "madeAt" | "txID">,
b: Pick<DecryptedTransaction, "madeAt" | "txID">,
) {
return (
a.madeAt - b.madeAt ||
(a.txID.sessionID < b.txID.sessionID ? -1 : 1) ||
a.txID.txIndex - b.txID.txIndex
);
}
getCurrentReadKey(): { secret: KeySecret | undefined; id: KeyID } {
if (this.header.ruleset.type === "group") {
const content = expectGroup(this.getCurrentContent());
const currentKeyId = content.get("readKey");
const currentKeyId = content.getCurrentReadKeyId();
if (!currentKeyId) {
throw new Error("No readKey set");

View File

@@ -1,7 +1,8 @@
import { CoID, RawCoValue } from "../coValue.js";
import { CoValueCore, DecryptedTransaction } from "../coValueCore.js";
import { CoValueCore } from "../coValueCore.js";
import { AgentID, TransactionID } from "../ids.js";
import { JsonObject, JsonValue } from "../jsonValue.js";
import { CoValueKnownState } from "../sync.js";
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
import { isCoValue } from "../typeUtils/isCoValue.js";
import { RawAccountID } from "./account.js";
@@ -46,14 +47,14 @@ export class RawCoMapView<
/** @internal */
latestTxMadeAt: number;
/** @internal */
cachedOps?: {
ops: {
[Key in keyof Shape & string]?: MapOp<Key, Shape[Key]>[];
};
/** @internal */
validSortedTransactions?: DecryptedTransaction[];
knownTransactions: CoValueKnownState["sessions"];
/** @internal */
options?: { ignorePrivateTransactions: boolean; atTime?: number };
ignorePrivateTransactions: boolean;
/** @internal */
atTimeFilter?: number = undefined;
/** @category 6. Meta */
@@ -64,123 +65,81 @@ export class RawCoMapView<
core: CoValueCore,
options?: {
ignorePrivateTransactions: boolean;
atTime?: number;
validSortedTransactions?: DecryptedTransaction[];
cachedOps?: {
[Key in keyof Shape & string]?: MapOp<Key, Shape[Key]>[];
};
},
) {
this.id = core.id as CoID<this>;
this.core = core;
this.latest = {};
this.latestTxMadeAt = 0;
this.options = options;
this.cachedOps = options?.cachedOps;
this.validSortedTransactions = options?.validSortedTransactions;
this.ignorePrivateTransactions =
options?.ignorePrivateTransactions ?? false;
this.ops = {};
this.latest = {};
this.knownTransactions = {};
this.processLatestTransactions();
this.processNewTransactions();
}
/** @internal */
private getValidSortedTransactions() {
if (this.validSortedTransactions) {
return this.validSortedTransactions;
processNewTransactions() {
if (this.isTimeTravelEntity()) {
throw new Error("Cannot process transactions on a time travel entity");
}
const validSortedTransactions = this.core.getValidSortedTransactions({
ignorePrivateTransactions:
this.options?.ignorePrivateTransactions ?? false,
const { ops } = this;
const changedEntries = new Map<
keyof typeof ops,
NonNullable<(typeof ops)[keyof typeof ops]>
>();
const nextValidTransactions = this.core.getValidTransactions({
ignorePrivateTransactions: this.ignorePrivateTransactions,
knownTransactions: this.knownTransactions,
});
this.validSortedTransactions = validSortedTransactions;
return validSortedTransactions;
}
private resetCachedValues() {
this.validSortedTransactions = undefined;
this.cachedOps = undefined;
}
private processLatestTransactions() {
// Reset all internal state and cached values
this.latest = {};
this.latestTxMadeAt = 0;
const { latest } = this;
const atTimeFilter = this.options?.atTime;
for (const { txID, changes, madeAt } of this.getValidSortedTransactions()) {
if (atTimeFilter && madeAt > atTimeFilter) {
continue;
}
if (madeAt > this.latestTxMadeAt) {
this.latestTxMadeAt = madeAt;
}
for (const { txID, changes, madeAt } of nextValidTransactions) {
for (let changeIdx = 0; changeIdx < changes.length; changeIdx++) {
const change = changes[changeIdx] as MapOpPayload<
keyof Shape & string,
Shape[keyof Shape & string]
>;
const entry = latest[change.key];
if (!entry) {
latest[change.key] = {
txID,
madeAt,
changeIdx,
change,
};
} else if (madeAt >= entry.madeAt) {
entry.txID = txID;
entry.madeAt = madeAt;
entry.changeIdx = changeIdx;
entry.change = change;
}
}
}
}
revalidateTransactions() {
this.resetCachedValues();
this.processLatestTransactions();
}
private getOps() {
if (this.cachedOps) {
return this.cachedOps;
}
const ops: {
[Key in keyof Shape & string]?: MapOp<Key, Shape[Key]>[];
} = {};
for (const { txID, changes, madeAt } of this.getValidSortedTransactions()) {
for (let changeIdx = 0; changeIdx < changes.length; changeIdx++) {
const change = changes[changeIdx] as MapOpPayload<
keyof Shape & string,
Shape[keyof Shape & string]
>;
let entries = ops[change.key];
if (!entries) {
entries = [];
ops[change.key] = entries;
}
entries.push({
const entry = {
txID,
madeAt,
changeIdx,
change,
});
};
if (madeAt > this.latestTxMadeAt) {
this.latestTxMadeAt = madeAt;
}
const entries = ops[change.key];
if (!entries) {
const entries = [entry];
ops[change.key] = entries;
changedEntries.set(change.key, entries);
} else {
entries.push(entry);
changedEntries.set(change.key, entries);
}
this.knownTransactions[txID.sessionID] = Math.max(
this.knownTransactions[txID.sessionID] ?? 0,
txID.txIndex,
);
}
}
this.cachedOps = ops;
for (const entries of changedEntries.values()) {
entries.sort(this.core.compareTransactions);
}
return ops;
for (const [key, entries] of changedEntries.entries()) {
this.latest[key] = entries[entries.length - 1];
}
}
isTimeTravelEntity() {
return Boolean(this.atTimeFilter);
}
/** @category 6. Meta */
@@ -198,14 +157,11 @@ export class RawCoMapView<
if (time >= this.latestTxMadeAt) {
return this;
} else {
const clone = new RawCoMapView(this.core, {
ignorePrivateTransactions:
this.options?.ignorePrivateTransactions ?? false,
atTime: time,
cachedOps: this.cachedOps,
validSortedTransactions: this.validSortedTransactions,
});
Object.setPrototypeOf(clone, this);
const clone = Object.create(this) as RawCoMapView<Shape, Meta>;
clone.atTimeFilter = time;
clone.latest = {};
return clone as this;
}
}
@@ -218,12 +174,12 @@ export class RawCoMapView<
return undefined;
}
const atTimeFilter = this.options?.atTime;
const atTimeFilter = this.atTimeFilter;
if (atTimeFilter) {
return this.getOps()[key]?.filter((op) => op.madeAt <= atTimeFilter);
return this.ops[key]?.filter((op) => op.madeAt <= atTimeFilter);
} else {
return this.getOps()[key];
return this.ops[key];
}
}
@@ -232,36 +188,64 @@ export class RawCoMapView<
*
* @category 1. Reading */
keys<K extends keyof Shape & string = keyof Shape & string>(): K[] {
return (Object.keys(this.latest) as K[]).filter((key) => {
const latestChange = this.latest[key];
return (Object.keys(this.ops) as K[]).filter((key) => {
const entry = this.getRaw(key);
if (!latestChange) {
if (entry === undefined) {
return false;
}
if (latestChange.change.op === "del") {
if (entry.change.op === "del") {
return false;
} else {
return true;
}
return true;
});
}
getRaw<K extends keyof Shape & string>(key: K) {
let latestChange = this.latest[key];
if (latestChange === undefined) {
const entries = this.ops[key];
// Time travel values are lazily computed
if (entries && !(key in this.latest)) {
const atTimeFilter = this.atTimeFilter;
if (!atTimeFilter) {
latestChange = entries[entries.length - 1];
} else {
latestChange = entries.findLast((op) => op.madeAt <= atTimeFilter);
}
this.latest[key] = latestChange;
}
if (latestChange === undefined) {
return undefined;
}
}
return latestChange;
}
/**
* Returns the current value for the given key.
*
* @category 1. Reading
**/
get<K extends keyof Shape & string>(key: K): Shape[K] | undefined {
const latestChange = this.latest[key];
if (!latestChange) {
const entry = this.getRaw(key);
if (entry === undefined) {
return undefined;
}
if (latestChange.change.op === "del") {
if (entry.change.op === "del") {
return undefined;
} else {
return latestChange.change.value as Shape[K];
return entry.change.value as Shape[K];
}
}
@@ -273,7 +257,7 @@ export class RawCoMapView<
[K in keyof Shape & string]: Shape[K];
}> = {};
for (const key of Object.keys(this.latest) as (keyof Shape & string)[]) {
for (const key of Object.keys(this.ops) as (keyof Shape & string)[]) {
const value = this.get(key);
if (value !== undefined) {
object[key] = value;
@@ -294,9 +278,9 @@ export class RawCoMapView<
/** @category 5. Edit history */
nthEditAt<K extends keyof Shape & string>(key: K, n: number) {
const ops = this.getOps()[key];
const ops = this.ops[key];
const atTimeFilter = this.options?.atTime;
const atTimeFilter = this.atTimeFilter;
const entry = ops?.[n];
if (!entry) {
@@ -321,24 +305,31 @@ export class RawCoMapView<
value?: Shape[K];
}
| undefined {
const ops = this.timeFilteredOps(key);
const lastEntry = ops?.[ops.length - 1];
const entry = this.getRaw(key);
if (!lastEntry) {
if (!entry) {
return undefined;
}
return operationToEditEntry(lastEntry);
return operationToEditEntry(entry);
}
/** @category 5. Edit history */
*editsAt<K extends keyof Shape & string>(key: K) {
const ops = this.timeFilteredOps(key);
if (!ops) {
const entries = this.ops[key];
if (!entries) {
return;
}
for (const entry of ops) {
const atTimeFilter = this.atTimeFilter;
for (const entry of entries) {
// Entries are sorted by madeAt
if (atTimeFilter && entry.madeAt > atTimeFilter) {
return;
}
yield operationToEditEntry(entry);
}
}
@@ -374,6 +365,10 @@ export class RawCoMap<
value: Shape[K],
privacy: "private" | "trusting" = "private",
): void {
if (this.isTimeTravelEntity()) {
throw new Error("Cannot set value on a time travel entity");
}
this.core.makeTransaction(
[
{
@@ -385,7 +380,7 @@ export class RawCoMap<
privacy,
);
this.revalidateTransactions();
this.processNewTransactions();
}
/** Delete the given key (setting it to undefined).
@@ -400,6 +395,10 @@ export class RawCoMap<
key: keyof Shape & string,
privacy: "private" | "trusting" = "private",
) {
if (this.isTimeTravelEntity()) {
throw new Error("Cannot delete value on a time travel entity");
}
this.core.makeTransaction(
[
{
@@ -410,7 +409,7 @@ export class RawCoMap<
privacy,
);
this.revalidateTransactions();
this.processNewTransactions();
}
}

View File

@@ -3,6 +3,7 @@ import { CoID, RawCoValue } from "../coValue.js";
import { CoValueCore } from "../coValueCore.js";
import { AgentID, SessionID, TransactionID } from "../ids.js";
import { JsonObject, JsonValue } from "../jsonValue.js";
import { CoValueKnownState } from "../sync.js";
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
import { isAccountID } from "../typeUtils/isAccountID.js";
import { isCoValue } from "../typeUtils/isCoValue.js";
@@ -52,13 +53,16 @@ export class RawCoStreamView<
items: {
[key: SessionID]: CoStreamItem<Item>[];
};
/** @internal */
knownTransactions: CoValueKnownState["sessions"];
readonly _item!: Item;
constructor(core: CoValueCore) {
this.id = core.id as CoID<this>;
this.core = core;
this.items = {};
this.fillFromCoValue();
this.knownTransactions = {};
this.processNewTransactions();
}
get headerMeta(): Meta {
@@ -75,14 +79,13 @@ export class RawCoStreamView<
}
/** @internal */
protected fillFromCoValue() {
this.items = {};
protected processNewTransactions() {
const changeEntries = new Set<CoStreamItem<Item>[]>();
for (const {
txID,
madeAt,
changes,
} of this.core.getValidSortedTransactions()) {
for (const { txID, madeAt, changes } of this.core.getValidTransactions({
ignorePrivateTransactions: false,
knownTransactions: this.knownTransactions,
})) {
for (const changeUntyped of changes) {
const change = changeUntyped as Item;
let entries = this.items[txID.sessionID];
@@ -91,7 +94,21 @@ export class RawCoStreamView<
this.items[txID.sessionID] = entries;
}
entries.push({ value: change, madeAt, tx: txID });
changeEntries.add(entries);
}
this.knownTransactions[txID.sessionID] = Math.max(
this.knownTransactions[txID.sessionID] ?? 0,
txID.txIndex,
);
}
for (const entries of changeEntries) {
entries.sort(
(a, b) =>
a.madeAt - b.madeAt ||
(a.tx.sessionID < b.tx.sessionID ? -1 : 1) ||
a.tx.txIndex - b.tx.txIndex,
);
}
}
@@ -255,7 +272,7 @@ export class RawCoStream<
{
push(item: Item, privacy: "private" | "trusting" = "private"): void {
this.core.makeTransaction([isCoValue(item) ? item.id : item], privacy);
this.fillFromCoValue();
this.processNewTransactions();
}
}
@@ -343,7 +360,7 @@ export class RawBinaryCoStream<
): void {
this.core.makeTransaction([item], privacy);
if (updateView) {
this.fillFromCoValue();
this.processNewTransactions();
}
}

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<
@@ -93,7 +94,7 @@ export class RawGroup<
}
| undefined = roleHere && { role: roleHere, via: undefined };
const parentGroups = this.getParentGroups(this.options?.atTime);
const parentGroups = this.getParentGroups(this.atTimeFilter);
for (const parentGroup of parentGroups) {
const roleInParent = parentGroup.roleOfInternal(accountID);
@@ -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 specifically for them and also reveal it to everyone else with a reader or higher-capability role (but crucially not to other writer-only members)
* 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;
}
getCurrentReadKeyId() {
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,60 @@ 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,
);
}
/**
* If there are some writeOnly members we need to rotate their keys
* and reveal them to the other non-writeOnly members
*/
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({
@@ -326,8 +484,11 @@ export class RawGroup<
this.set("readKey", newReadKey.id, "trusting");
// when we rotate our readKey (because someone got kicked out), we also need to (recursively)
// rotate the readKeys of all child groups (so they are kicked out there as well)
/**
* The new read key needs to be revealed to the parent groups
*
* This way the members from the parent groups can still have access to this group
*/
for (const parent of parentGroups) {
const { id: parentReadKeyID, secret: parentReadKeySecret } =
parent.core.getCurrentReadKey();
@@ -426,7 +587,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);
@@ -558,13 +719,20 @@ function isMorePermissiveAndShouldInherit(
}
if (roleInParent === "writer") {
return !roleInChild || roleInChild === "reader";
return (
!roleInChild || roleInChild === "reader" || roleInChild === "writeOnly"
);
}
if (roleInParent === "reader") {
return !roleInChild;
}
// writeOnly can't be inherited
if (roleInParent === "writeOnly") {
return false;
}
return false;
}

View File

@@ -53,6 +53,7 @@ import type { AgentSecret } from "./crypto/crypto.js";
import type { AgentID, RawCoID, SessionID } from "./ids.js";
import type { JsonValue } from "./jsonValue.js";
import type * as Media from "./media.js";
import { disablePermissionErrors } from "./permissions.js";
import type {
IncomingSyncStream,
OutgoingSyncQueue,
@@ -91,6 +92,7 @@ export const cojsonInternals = {
getPriorityFromHeader,
getGroupDependentKeyList,
getGroupDependentKey,
disablePermissionErrors,
};
export {

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,18 +22,33 @@ 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 };
let logPermissionErrors = true;
export function disablePermissionErrors() {
logPermissionErrors = false;
}
function logPermissionError(...args: unknown[]) {
if (logPermissionErrors === false) {
return;
}
console.warn(...args);
}
export function determineValidTransactions(
coValue: CoValueCore,
): { txID: TransactionID; tx: Transaction }[] {
@@ -81,7 +96,8 @@ export function determineValidTransactions(
if (
transactorRoleAtTxTime !== "admin" &&
transactorRoleAtTxTime !== "writer"
transactorRoleAtTxTime !== "writer" &&
transactorRoleAtTxTime !== "writeOnly"
) {
return;
}
@@ -177,6 +193,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);
@@ -189,7 +208,9 @@ function determineValidTransactionsForGroup(
});
continue;
} else {
console.warn("Only admins can make private transactions in groups");
logPermissionError(
"Only admins can make private transactions in groups",
);
continue;
}
}
@@ -199,7 +220,7 @@ function determineValidTransactionsForGroup(
try {
changes = parseJSON(tx.changes);
} catch (e) {
console.warn(
logPermissionError(
coValue.id,
"Invalid JSON in transaction",
e,
@@ -221,18 +242,18 @@ function determineValidTransactionsForGroup(
| MapOpPayload<`child_${CoID<RawGroup>}`, CoID<RawGroup>>;
if (changes.length !== 1) {
console.warn("Group transaction must have exactly one change");
logPermissionError("Group transaction must have exactly one change");
continue;
}
if (change.op !== "set") {
console.warn("Group transaction must set a role or readKey");
logPermissionError("Group transaction must set a role or readKey");
continue;
}
if (change.key === "readKey") {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set readKeys");
logPermissionError("Only admins can set readKeys");
continue;
}
@@ -240,7 +261,7 @@ function determineValidTransactionsForGroup(
continue;
} else if (change.key === "profile") {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set profile");
logPermissionError("Only admins can set profile");
continue;
}
@@ -254,19 +275,36 @@ 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");
logPermissionError("Only admins can reveal keys");
continue;
}
// TODO: check validity of agents who the key is revealed to?
/**
* We don't want to give the ability to invite members to override
* key revelations, otherwise they could hide a key revelation to any user
* blocking them from accessing the group.
*/
if (
keyRevelations.has(change.key) &&
memberState[transactor] !== "admin"
) {
logPermissionError(
"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)) {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set parent extensions");
logPermissionError("Only admins can set parent extensions");
continue;
}
resolveMemberStateFromParentReference(coValue, memberState, change.key);
@@ -274,9 +312,37 @@ function determineValidTransactionsForGroup(
continue;
} else if (isChildExtension(change.key)) {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set child extensions");
logPermissionError("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"
) {
logPermissionError("Only admins can set writeKeys");
continue;
}
/**
* writeOnlyInvite need to be able to set writeKeys because every new writeOnly
* member comes with their own write key.
*
* We don't want to give the ability to invite members to override
* write keys, otherwise they could hide a write key to other writeOnly users
* blocking them from accessing the group.ß
*/
if (writeKeys.has(change.key) && memberState[transactor] !== "admin") {
logPermissionError(
"Write key already exists and can't be overridden by invite",
);
continue;
}
writeKeys.add(change.key);
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
}
@@ -288,12 +354,14 @@ 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");
logPermissionError("Group transaction must set a valid role");
continue;
}
@@ -305,7 +373,9 @@ function determineValidTransactionsForGroup(
change.value === "revoked"
)
) {
console.warn("Everyone can only be set to reader, writer or revoked");
logPermissionError(
"Everyone can only be set to reader, writer or revoked",
);
continue;
}
@@ -323,26 +393,31 @@ function determineValidTransactionsForGroup(
affectedMember !== transactor &&
assignedRole !== "admin"
) {
console.warn("Admins can only demote themselves.");
logPermissionError("Admins can only demote themselves.");
continue;
}
} else if (memberState[transactor] === "adminInvite") {
if (change.value !== "admin") {
console.warn("AdminInvites can only create admins.");
logPermissionError("AdminInvites can only create admins.");
continue;
}
} else if (memberState[transactor] === "writerInvite") {
if (change.value !== "writer") {
console.warn("WriterInvites can only create writers.");
logPermissionError("WriterInvites can only create writers.");
continue;
}
} else if (memberState[transactor] === "readerInvite") {
if (change.value !== "reader") {
console.warn("ReaderInvites can only create reader.");
logPermissionError("ReaderInvites can only create reader.");
continue;
}
} else if (memberState[transactor] === "writeOnlyInvite") {
if (change.value !== "writeOnly") {
logPermissionError("WriteOnlyInvites can only create writeOnly.");
continue;
}
} else {
console.warn(
logPermissionError(
"Group transaction must be made by current admin or invite",
);
continue;
@@ -377,6 +452,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

@@ -75,10 +75,10 @@ test("Can get CoMap entry values at different points in time", () => {
expect(content.atTime(beforeB).get("hello")).toEqual("A");
expect(content.atTime(beforeC).get("hello")).toEqual("B");
const ops = content.timeFilteredOps("hello");
const ops = content.ops["hello"]!;
expect(content.atTime(beforeC).lastEditAt("hello")).toEqual(
operationToEditEntry(ops![1]!),
operationToEditEntry(ops[1]!),
);
expect(content.atTime(beforeC).nthEditAt("hello", 0)).toEqual(
operationToEditEntry(ops![0]!),

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,349 @@ 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("writeOnly roles are not inherited", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
const group = node1.node.createGroup();
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writeOnly",
);
const childGroup = node1.node.createGroup();
childGroup.extend(group);
expect(childGroup.roleOf(node2.accountID)).toEqual(undefined);
});
test("writeOnly roles are not overridded by reader roles", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
const group = node1.node.createGroup();
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"reader",
);
const childGroup = node1.node.createGroup();
childGroup.extend(group);
childGroup.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writeOnly",
);
expect(childGroup.roleOf(node2.accountID)).toEqual("writeOnly");
});
test("writeOnly roles are overridded by writer roles", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
const group = node1.node.createGroup();
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writer",
);
const childGroup = node1.node.createGroup();
childGroup.extend(group);
childGroup.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writeOnly",
);
expect(childGroup.roleOf(node2.accountID)).toEqual("writer");
});
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");
});
test("inherited writer roles should work correctly", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
const group = node1.node.createGroup();
group.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writer",
);
const childGroup = node1.node.createGroup();
childGroup.extend(group);
childGroup.addMember(
await loadCoValueOrFail(node1.node, node2.accountID),
"writeOnly",
);
const map = childGroup.createMap();
map.set("test", "Written from the admin");
await map.core.waitForSync();
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
// The writer role should be able to see the edits from the admin
expect(mapOnNode2.get("test")).toEqual("Written from the admin");
});
});

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

@@ -67,17 +67,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",
@@ -105,12 +99,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,

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

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

Some files were not shown because too many files have changed in this diff Show More