Compare commits

..

146 Commits

Author SHA1 Message Date
Matteo Manchi
688316f199 Merge pull request #2793 from garden-co/fix/load-as-upsertUnique
Explicit loadAs in CoList.upsertUnique to use it without loaded context
2025-08-21 18:02:00 +02:00
Matteo Manchi
65332631d2 chore: refactor runWithoutActiveAccount to handle promises 2025-08-21 17:51:48 +02:00
Matteo Manchi
bb9d837236 fix(jazz-tools/tools): explicit loadAs in CoList.upsertUnique to use it without loaded context 2025-08-21 16:35:06 +02:00
Matteo Manchi
fd186f769e test(jazz-tools/tools): add tests for CoMap.upsertUnique without an active account 2025-08-21 16:29:10 +02:00
Guido D'Orsi
97bf06fb9f Merge pull request #2792 from garden-co/feat/remove-wee-alloc
Remove wee_alloc from the code, update benchmarks to target 0.17.9 (last one before SessionLog)
2025-08-21 10:10:33 +02:00
Guido D'Orsi
85740f01ff Merge pull request #2788 from garden-co/feat/skip-signerID
feat: skip the agent resolve
2025-08-21 09:51:52 +02:00
Guido D'Orsi
842b7fa05a chore: update the bench to target 0.17.9 2025-08-21 09:50:59 +02:00
Guido D'Orsi
9decbb4d5b chore: remove wee_alloc 2025-08-21 09:49:25 +02:00
Guido D'Orsi
e301ad63ae chore: changeset 2025-08-20 23:36:15 +02:00
Guido D'Orsi
f1d0c4244b chore: remove useless check 2025-08-20 23:35:19 +02:00
Guido D'Orsi
4afe200553 fix: type error and console log 2025-08-20 21:18:32 +02:00
Guido D'Orsi
a06bc8f868 fix: skipVerify on WASMCrypto 2025-08-20 21:16:47 +02:00
Justin Rosenthal
c805d132fd Correct error message 2025-08-20 12:15:22 -07:00
Justin Rosenthal
33936b8fe3 Add test for missing signerID in PureJSCrypto 2025-08-20 12:13:08 -07:00
Guido D'Orsi
23e7d07ab9 Merge pull request #2786 from garden-co/fix/self-downgrade-writeonly
fix: make it possible for an admin to self-downgrade to writeOnly
2025-08-20 21:05:43 +02:00
Guido D'Orsi
22944b5025 feat: skip the agent resolve 2025-08-20 21:03:23 +02:00
NicoR
257ded37a7 chore: add changeset 2025-08-20 15:45:22 -03:00
NicoR
a6f7b0c64e docs: add TS docs to user-facing roles 2025-08-20 15:43:13 -03:00
NicoR
bbf9b1b1ca test: downgrading self & other accounts to reader & writeOnly 2025-08-20 15:43:13 -03:00
NicoR
0ec9906357 fix: prevent admin from downgrade other admins to writeOnly 2025-08-20 15:43:12 -03:00
Guido D'Orsi
91aec2536c fix: make it possible for an admin to self-downgrade to writeOnly 2025-08-20 15:43:12 -03:00
Guido D'Orsi
d0ae8ef8cd Merge pull request #2787 from garden-co/chore/simplfy-tryadd-params
chore: remove unused params from tryAddTransactions
2025-08-20 20:06:21 +02:00
Guido D'Orsi
6dab1e01c1 chore: remove unused params from tryAddTransactions 2025-08-20 19:12:48 +02:00
Guido D'Orsi
6adc931b7f chore: update bench titles 2025-08-20 17:12:12 +02:00
Guido D'Orsi
b2165f3758 chore: version Cargo.lock 2025-08-20 17:12:12 +02:00
Guido D'Orsi
14f0d557e8 Merge pull request #2784 from garden-co/changeset-release/main
Version Packages
2025-08-20 17:08:56 +02:00
github-actions[bot]
42ac056b99 Version Packages 2025-08-20 15:06:58 +00:00
Guido D'Orsi
0aef1705a2 chore: changeset 2025-08-20 17:03:10 +02:00
Guido D'Orsi
15f09aefeb Merge pull request #2783 from garden-co/changeset-release/main
Version Packages
2025-08-20 17:01:12 +02:00
github-actions[bot]
472142e926 Version Packages 2025-08-20 14:58:27 +00:00
Guido D'Orsi
3fca60bc23 Merge pull request #2617 from garden-co/anselm-gco-634-implement-current-session-logic-in-rust-and-use-as-wasm
Implement current session logic in Rust and use as WASM
2025-08-20 16:54:55 +02:00
Guido D'Orsi
c87e5abba6 Merge pull request #2755 from garden-co/fix/catalog-cja
fix: catalog replacement for `create-jazz-app`
2025-08-20 16:54:16 +02:00
Guido D'Orsi
c55297cdc5 chore: changeset 2025-08-20 16:39:48 +02:00
Brad Anderson
07db808253 fix: don't bump/control create-jazz-app version 2025-08-20 08:54:40 -04:00
Guido D'Orsi
657ac7344a Update .changeset/config.json 2025-08-20 14:51:53 +02:00
Guido D'Orsi
6061cad555 remove filter on pre-release label 2025-08-20 14:51:06 +02:00
Guido D'Orsi
4c2e60ab51 Merge pull request #2776 from garden-co/changeset-release/main
Version Packages
2025-08-20 13:25:30 +02:00
github-actions[bot]
1830171930 Version Packages 2025-08-20 10:59:08 +00:00
Guido D'Orsi
6574090402 Merge pull request #2777 from garden-co/fix/load-as-upsertUnique
Explicit loadAs in upsertUnique to use it without loaded context
2025-08-20 12:55:18 +02:00
Guido D'Orsi
a6f65472a7 Merge pull request #2779 from ccssmnn/add-fallback-components
Add Fallback properties to JazzProvider and ClerkProvider
2025-08-20 12:54:16 +02:00
Guido D'Orsi
109952aa6a Merge pull request #2781 from garden-co/gio/setup-codeowners
chore: setup codeowners
2025-08-20 12:50:38 +02:00
Giordano Ricci
556bdf977b more tweaking 2025-08-20 11:41:31 +01:00
Giordano Ricci
0213b4e5b9 add ui 2025-08-20 11:31:54 +01:00
Giordano Ricci
01c195756c add docs 2025-08-20 11:27:17 +01:00
Giordano Ricci
9c69917d24 add @garden-co/framework as owner of all packages 2025-08-20 11:03:18 +01:00
Giordano Ricci
b4d68f0a32 wip: setup codeowners 2025-08-20 10:59:36 +01:00
Guido D'Orsi
fbc3839777 chore: simplify the fallback on the react provider 2025-08-20 11:55:45 +02:00
Guido D'Orsi
296464b282 Merge pull request #2753 from garden-co/GCO-642-clarify-docs-adding-group-members-by-id
Closes #GCO-642 - Clarify docs for adding group members by id
2025-08-20 11:50:03 +02:00
Guido D'Orsi
2e73d09ab9 perf: optimize hashing computation 2025-08-20 10:52:37 +02:00
Carl Assmann
c463654970 no need to add fallback to clerk, bc it extends and forwards jazzproviderprops 2025-08-19 23:23:19 +02:00
Carl Assmann
debc052bdc add fallback to clerk example 2025-08-19 23:12:12 +02:00
Carl Assmann
3c7846153d add fallback properties to JazzProvider and ClerkProvider 2025-08-19 23:00:06 +02:00
Meg Culotta
e410fedda7 Merge pull request #2736 from garden-co/#GCO-691-Adds-node-20-troubleshooting-docs
Closes GCO-691, GCO-731 - Add setup troubleshooting page
2025-08-19 14:40:47 -05:00
Brad Anderson
94172c9972 chore: PR feedback, move to own workflow 2025-08-19 13:11:41 -04:00
Matteo Manchi
52ea0c7a9b fix(jazz-tools/tools): explicit loadAs in upsertUnique to use it without loaded context 2025-08-19 18:21:31 +02:00
Guido D'Orsi
4d3d99504b Merge remote-tracking branch 'origin/main' into anselm-gco-634-implement-current-session-logic-in-rust-and-use-as-wasm 2025-08-19 18:03:32 +02:00
Guido D'Orsi
b91c93caed Merge pull request #2769 from garden-co/justin-gco-741-skip-session-transaction-verification-in-core-node
Ref GCO-741: Add option for disabling transaction verification to SyncManager
2025-08-19 18:03:02 +02:00
Guido D'Orsi
ae8e77d216 chore: add build:dev command 2025-08-19 18:01:15 +02:00
Guido D'Orsi
d36291e9e2 test: add crypto high level tests 2025-08-19 18:00:59 +02:00
Justin Rosenthal
7586c3bac5 Add changeset 2025-08-19 08:52:44 -07:00
Justin Rosenthal
9b96cd4a65 Craft an invalid transaction in unit test 2025-08-19 08:50:43 -07:00
Justin Rosenthal
2e66ea8e56 Revert signatures to positional args 2025-08-19 08:32:38 -07:00
Guido D'Orsi
336cc1f0fe Merge pull request #2773 from garden-co/changeset-release/main
Version Packages
2025-08-19 16:27:36 +02:00
github-actions[bot]
cc2ca5c23c Version Packages 2025-08-19 14:26:38 +00:00
Guido D'Orsi
3664385113 Merge pull request #2775 from garden-co/fix/comap-coprofile-validate-input
fix: prevent passing CoValue schemas to `co.map` and `co.profile`
2025-08-19 16:22:45 +02:00
Guido D'Orsi
2b2ecdaf3d Merge pull request #2771 from garden-co/feat/rich-text-prosemirror-fix
fix(prosemirror): fix RangeError triggered when creating invalid HTML
2025-08-19 16:21:39 +02:00
Guido D'Orsi
506491aebe Merge pull request #2774 from garden-co/feat/unified-crypto
feat: unify the wasm modules
2025-08-19 16:20:42 +02:00
Guido D'Orsi
6dbb05320a fix(prosemirror): fix RangeError triggered when creating invalid HTML 2025-08-19 16:15:12 +02:00
Margaret Culotta
0160a188fa Remove strict casting from example 2025-08-19 09:06:58 -05:00
NicoR
ac3e694f4e fix: prevent passing CoValue schemas to co.map and co.profile 2025-08-19 11:05:54 -03:00
Guido D'Orsi
d70e4a9773 feat: unify the wasm modules 2025-08-19 16:04:38 +02:00
Margaret Culotta
a7dca75955 Clean up unnecessary content per PR feedback 2025-08-19 08:29:24 -05:00
Guido D'Orsi
143156cd6a Merge pull request #2772 from garden-co/gio/add-missing-export
fix: add missing export
2025-08-19 14:31:57 +02:00
Giordano Ricci
1a182f07de add changeset 2025-08-19 13:30:30 +01:00
Giordano Ricci
7e7e7ebb51 fix: add missing export 2025-08-19 13:28:45 +01:00
Guido D'Orsi
0966a90f3d Merge pull request #2768 from garden-co/changeset-release/main
Version Packages
2025-08-19 08:44:03 +02:00
Justin Rosenthal
76f142b70d Add ability to disable verification in SyncManager 2025-08-18 14:52:58 -07:00
github-actions[bot]
cd2f0846db Version Packages 2025-08-18 20:33:16 +00:00
Guido D'Orsi
c2e411d056 Merge pull request #2759 from 0x100101/feat/sync-server-host-opt
Add host option to the jazz-run sync command
2025-08-18 22:31:01 +02:00
Guido D'Orsi
70cdb1100e Merge pull request #2762 from garden-co/feat/optimize-crypto
perf: switch to decryptNextTransactionChangesJson
2025-08-18 22:26:28 +02:00
Justin Rosenthal
0167153da2 Use named parameters in tryAddTransactions() signatures 2025-08-18 13:19:26 -07:00
Guido D'Orsi
e4a4d0decc chore: update the bench 2025-08-18 20:17:43 +02:00
Guido D'Orsi
be5211d088 Merge pull request #2765 from garden-co/changeset-release/main
Version Packages
2025-08-18 20:00:59 +02:00
Guido D'Orsi
dd7b30b5d8 chore: fix broken tests, remove expectedNewHashAfter 2025-08-18 19:58:38 +02:00
github-actions[bot]
747f73d168 Version Packages 2025-08-18 17:48:48 +00:00
Guido D'Orsi
7501702f7b Merge pull request #2761 from garden-co/fix/GCO-726
fix(jazz-tools/media): get resized file's id without triggering shallow load
2025-08-18 19:44:23 +02:00
Guido D'Orsi
16fb9fab5f Merge pull request #2764 from garden-co/fix/create-from-json-without-active-account
fix: create CoValues from JSON without an active account
2025-08-18 19:43:39 +02:00
NicoR
82de51c93d chore: add changeset 2025-08-18 13:49:25 -03:00
NicoR
5d96991981 fix: create CoValues from JSON without an active account 2025-08-18 13:42:28 -03:00
Matteo Manchi
694b168fb4 fix(jazz-tools/media): get resized file's id without triggering shallow load 2025-08-18 17:54:48 +02:00
0x100101
feaa69ebdd Add patch file 2025-08-18 10:04:04 -05:00
Guido D'Orsi
384ebf7f92 perf: switch to decryptNextTransactionChangesJson 2025-08-18 16:37:23 +02:00
Guido D'Orsi
f5acd5c8a3 Merge pull request #2760 from garden-co/feat/optimize-crypto
perf: optimize PureJSCrypto SessionLog
2025-08-18 16:07:40 +02:00
Guido D'Orsi
d7df996fdc perf: cache agentId 2025-08-18 15:07:30 +02:00
Guido D'Orsi
820718ebb2 perf: optimize SessionLog for PureJSCrypto 2025-08-18 14:41:51 +02:00
Guido D'Orsi
344206a7a6 fix: fix content import benchmark 2025-08-18 14:16:40 +02:00
Guido D'Orsi
ca51fe2296 fix: use WASMCrypto in the bench 2025-08-18 12:25:09 +02:00
Guido D'Orsi
e40a4f2f76 feat: add a bench to compare perf with the latest published version of jazz 2025-08-18 11:42:39 +02:00
0x100101
d5fa172b17 Update docs 2025-08-17 20:46:12 -05:00
0x100101
96de15593b Add and update tests. Tweak sync server return. 2025-08-17 20:32:22 -05:00
0x100101
5ba03ebc70 Add host option to startSyncServerCommand command 2025-08-17 16:10:04 -05:00
Guido D'Orsi
4609cebed6 Merge pull request #2757 from garden-co/feat/music-player-welcome
fix: avatar permissions
2025-08-16 18:12:54 +02:00
Guido D'Orsi
06d21b9529 fix: avatar permissions 2025-08-16 18:09:24 +02:00
Guido D'Orsi
f3426beaf5 Merge pull request #2756 from garden-co/feat/music-player-welcome
feat(music): show the welcome screen, add playlist members list
2025-08-16 17:47:53 +02:00
Guido D'Orsi
8b3e038a98 feat(music): show the welcome screen, add playlist members list 2025-08-16 17:36:15 +02:00
Brad Anderson
e794ddbd3d fix: include workspace file for catalog replacement, add CI tests 2025-08-16 10:18:13 -04:00
Brad Anderson
436f9393b3 Revert "fix: remove catalog: deps"
This reverts commit 7dd128962d.
2025-08-16 09:32:20 -04:00
Guido D'Orsi
4002d6afb9 Merge pull request #2754 from garden-co/fix/replace-catalog
fix: remove catalog: deps
2025-08-16 11:59:49 +02:00
Guido D'Orsi
7dd128962d fix: remove catalog: deps 2025-08-16 11:53:44 +02:00
Margaret Culotta
d8ae47c4d1 Clarify docs for adding group members by id 2025-08-15 15:01:38 -05:00
Guido D'Orsi
8fb1748433 Merge pull request #2750 from garden-co/changeset-release/main
Version Packages
2025-08-15 20:36:35 +02:00
github-actions[bot]
c8644bf678 Version Packages 2025-08-15 16:30:37 +00:00
Guido D'Orsi
269ee94338 test: skip flaky e2e test 2025-08-15 18:26:41 +02:00
Guido D'Orsi
dae80eeba8 Merge pull request #2751 from garden-co/feat/unmount
fix: remove unnecessary content sent as dependency
2025-08-15 18:25:51 +02:00
Guido D'Orsi
ce54667b4d Merge pull request #2752 from garden-co/more-unique-static-methods
Implement/expose loadUnique and upsertUnique on co.list and co.record
2025-08-15 18:25:33 +02:00
Anselm
5963658e28 Implement/expose loadUnique and upsertUnique on co.list and co.record 2025-08-15 17:20:48 +01:00
Guido D'Orsi
71c1411bbd fix: remove unnecessary content sent as dependency 2025-08-15 18:05:42 +02:00
Guido D'Orsi
71b221dc79 Merge pull request #2749 from garden-co/feat/unmount
feat: make the unmount function detach the CoValue from the localNode
2025-08-15 17:48:01 +02:00
Guido D'Orsi
2d11d448dc feat: make the unmount function detach the CoValue from the localNode 2025-08-15 17:41:18 +02:00
Margaret Culotta
92c0048984 add setup troubleshooting page 2025-08-14 10:32:22 -05:00
Guido D'Orsi
8e3bb4b4d9 Merge remote-tracking branch 'origin/main' into anselm-gco-634-implement-current-session-logic-in-rust-and-use-as-wasm 2025-08-14 12:14:27 +02:00
Guido D'Orsi
37d1bbf00a chore: clean deps 2025-08-14 12:03:38 +02:00
Guido D'Orsi
3cd472f47a Merge pull request #2734 from garden-co/feat/merge-main
Feat/merge main
2025-08-14 11:30:51 +02:00
Guido D'Orsi
d453709d94 chore(biome): ignore crates 2025-08-14 11:29:43 +02:00
Guido D'Orsi
2ba972f444 Merge remote-tracking branch 'origin/main' into feat/merge-main 2025-08-13 20:28:57 +02:00
Guido D'Orsi
7865455cb1 chore: extract the sessions management into a SessionMap class 2025-08-13 18:33:18 +02:00
Guido D'Orsi
4d909ea4cc chore: revert type changes 2025-08-13 18:32:43 +02:00
Guido D'Orsi
62f79df20a fix: fix the export path from the wasm .d.ts 2025-08-13 17:03:10 +02:00
Guido D'Orsi
8fd3c4c96c feat: get the cojson-core-wasm build working 2025-08-13 16:52:48 +02:00
Anselm
626775caa8 Implement session logic for PureJSCrypto 2025-07-28 13:19:26 -07:00
Anselm
e51c4d4b5b Make SessionLogImpl part of CryptoProvider 2025-07-25 11:28:23 -07:00
Brad Anderson
3eff28a896 Merge branch 'main' into anselm-gco-634-implement-current-session-logic-in-rust-and-use-as-wasm 2025-07-23 13:18:17 -04:00
Anselm
7d7a810bba Fix content updates after makeTransaction 2025-07-23 13:10:44 +01:00
Anselm
13e7e80482 Merge branch 'main' into anselm-gco-634-implement-current-session-logic-in-rust-and-use-as-wasm 2025-07-18 17:24:49 +01:00
Anselm
d491b66abd Fix remaining tests 2025-07-18 16:45:46 +01:00
Anselm
9f9c235e4b Fix remaining tests with expectedNewHash 2025-07-15 17:25:50 +01:00
Anselm
a3e6ff1ae7 Use proper ephemeral signer ID 2025-07-15 17:12:39 +01:00
Anselm
d01e2080d1 Save inbetween signatures again 2025-07-15 16:35:38 +01:00
Anselm
da86337d13 Remove stored hash and debug logs 2025-07-15 11:39:52 +01:00
Anselm
c4df2a2189 Most tests pass except intermediate known states 2025-07-13 15:01:34 +01:00
Anselm
cf606c7c2f Make tests pass 2025-07-10 15:08:18 +01:00
Anselm
63a03b4139 State: all inputs byte-identical, but signature verification fails 2025-07-09 20:26:00 +01:00
Anselm
e47e18b84d Introduce thiserror 2025-07-09 18:52:28 +01:00
Anselm
8f5a7a091a Setup cojson-core-wasm wrapper and start writing tests 2025-07-09 17:44:46 +01:00
Anselm
e04cec6092 Fix unrelated build errors 2025-07-09 17:23:27 +01:00
Anselm
8868032376 Add initial Rust crates for lzy and cojson-core 2025-07-07 17:31:12 +01:00
164 changed files with 10895 additions and 977 deletions

View File

@@ -6,6 +6,7 @@
"fixed": [
[
"cojson",
"cojson-core-wasm",
"cojson-storage-indexeddb",
"cojson-storage-sqlite",
"cojson-transport-ws",

View File

@@ -0,0 +1,5 @@
---
"jazz-tools": patch
---
Explicit loadAs in CoList.upsertUnique to use it without loaded context

View File

@@ -0,0 +1,8 @@
---
"cojson": patch
---
Fix admin permission downgrade to writeOnly
- Allow admin to self-downgrade to writeOnly
- Prevent admin from downgrading other admins to writeOnly

View File

@@ -0,0 +1,5 @@
---
"cojson": patch
---
Skip agent resolution when skipVerify is true

8
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,8 @@
./packages @garden-co/framework
./tests @garden-co/framework
./packages/quint-ui @garden-co/ui
./homepage @garden-co/ui
./homepage/homepage/content/docs @garden-co/docs
./starters @garden-co/docs
./examples @garden-co/docs @garden-co/ui

77
.github/workflows/create-jazz-app.yml vendored Normal file
View File

@@ -0,0 +1,77 @@
name: Test `create-jazz-app` Distribution
concurrency:
# For pushes, this lets concurrent runs happen, so each push gets a result.
# But for other events (e.g. PRs), we can cancel the previous runs.
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: true
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'packages/create-jazz-app/**'
jobs:
test-create-jazz-app-distribution:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Source Code
uses: ./.github/actions/source-code/
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Build create-jazz-app
run: pnpm build
working-directory: packages/create-jazz-app
- name: Pack create-jazz-app
run: pnpm pack
working-directory: packages/create-jazz-app
- name: Create test directory
run: mkdir -p /tmp/test-create-jazz-app
- name: Initialize test package
run: |
cd /tmp/test-create-jazz-app
bun init -y
- name: Install packed create-jazz-app
run: |
cd /tmp/test-create-jazz-app
bun install ${{ github.workspace }}/packages/create-jazz-app/create-jazz-app-*.tgz
- name: Test basic functionality
run: |
cd /tmp/test-create-jazz-app
bunx create-jazz-app --help
- name: Create test project and validate catalog resolution
run: |
cd /tmp/test-create-jazz-app
mkdir test-project
cd test-project
echo -e "\n\n\n\n\n\n\n\n" | bunx create-jazz-app . --framework react --starter react-passkey-auth --package-manager bun --git false
- name: Validate no unresolved catalog references
run: |
cd /tmp/test-create-jazz-app/test-project
# Check for unresolved catalog: references in package.json
if grep -r "catalog:" package.json; then
echo "❌ Found unresolved catalog: references in generated project"
exit 1
fi
# Check for unresolved workspace: references
if grep -r "workspace:" package.json; then
echo "❌ Found unresolved workspace: references in generated project"
exit 1
fi
echo "✅ All catalog and workspace references resolved successfully"

171
bench/comap.create.bench.ts Normal file
View File

@@ -0,0 +1,171 @@
import { describe, bench } from "vitest";
import * as tools from "jazz-tools";
import * as toolsLatest from "jazz-tools-latest";
import { WasmCrypto } from "cojson/crypto/WasmCrypto";
import { WasmCrypto as WasmCryptoLatest } from "cojson-latest/crypto/WasmCrypto";
import { PureJSCrypto } from "cojson/crypto/PureJSCrypto";
import { PureJSCrypto as PureJSCryptoLatest } from "cojson-latest/crypto/PureJSCrypto";
const sampleReactions = ["👍", "❤️", "😄", "🎉"];
const sampleHiddenIn = ["user1", "user2", "user3"];
// Define the schemas based on the provided Message schema
async function createSchema(
tools: typeof toolsLatest,
WasmCrypto: typeof WasmCryptoLatest,
) {
const Embed = tools.co.map({
url: tools.z.string(),
title: tools.z.string().optional(),
description: tools.z.string().optional(),
image: tools.z.string().optional(),
});
const Message = tools.co.map({
content: tools.z.string(),
createdAt: tools.z.date(),
updatedAt: tools.z.date(),
hiddenIn: tools.co.list(tools.z.string()),
replyTo: tools.z.string().optional(),
reactions: tools.co.list(tools.z.string()),
softDeleted: tools.z.boolean().optional(),
embeds: tools.co.optional(tools.co.list(Embed)),
author: tools.z.string().optional(),
threadId: tools.z.string().optional(),
});
const ctx = await tools.createJazzContextForNewAccount({
creationProps: {
name: "Test Account",
},
// @ts-expect-error
crypto: await WasmCrypto.create(),
});
return {
Message,
sampleReactions,
sampleHiddenIn,
Group: tools.Group,
account: ctx.account,
};
}
const PUREJS = false;
// @ts-expect-error
const schema = await createSchema(tools, PUREJS ? PureJSCrypto : WasmCrypto);
const schemaLatest = await createSchema(
toolsLatest,
// @ts-expect-error
PUREJS ? PureJSCryptoLatest : WasmCryptoLatest,
);
const message = schema.Message.create(
{
content: "A".repeat(1024),
createdAt: new Date(),
updatedAt: new Date(),
hiddenIn: sampleHiddenIn,
reactions: sampleReactions,
author: "user123",
},
schema.Group.create(schema.account).makePublic(),
);
const content = await tools.exportCoValue(schema.Message, message.id, {
// @ts-expect-error
loadAs: schema.account,
});
tools.importContentPieces(content ?? [], schema.account as any);
toolsLatest.importContentPieces(content ?? [], schemaLatest.account);
schema.account._raw.core.node.internalDeleteCoValue(message.id as any);
schemaLatest.account._raw.core.node.internalDeleteCoValue(message.id as any);
describe("Message.create", () => {
bench(
"current version",
() => {
schema.Message.create(
{
content: "A".repeat(1024),
createdAt: new Date(),
updatedAt: new Date(),
hiddenIn: sampleHiddenIn,
reactions: sampleReactions,
author: "user123",
},
schema.Group.create(schema.account),
);
},
{ iterations: 1000 },
);
bench(
"Jazz 0.17.9",
() => {
schemaLatest.Message.create(
{
content: "A".repeat(1024),
createdAt: new Date(),
updatedAt: new Date(),
hiddenIn: sampleHiddenIn,
reactions: sampleReactions,
author: "user123",
},
schemaLatest.Group.create(schemaLatest.account),
);
},
{ iterations: 1000 },
);
});
describe("Message import", () => {
bench(
"current version",
() => {
tools.importContentPieces(content ?? [], schema.account as any);
schema.account._raw.core.node.internalDeleteCoValue(message.id as any);
},
{ iterations: 5000 },
);
bench(
"Jazz 0.17.9",
() => {
toolsLatest.importContentPieces(content ?? [], schemaLatest.account);
schemaLatest.account._raw.core.node.internalDeleteCoValue(
message.id as any,
);
},
{ iterations: 5000 },
);
});
describe("import+ decrypt", () => {
bench(
"current version",
() => {
tools.importContentPieces(content ?? [], schema.account as any);
const node = schema.account._raw.core.node;
node.expectCoValueLoaded(message.id as any).getCurrentContent();
node.internalDeleteCoValue(message.id as any);
},
{ iterations: 5000 },
);
bench(
"Jazz 0.17.9",
() => {
toolsLatest.importContentPieces(content ?? [], schemaLatest.account);
const node = schemaLatest.account._raw.core.node;
node.expectCoValueLoaded(message.id as any).getCurrentContent();
node.internalDeleteCoValue(message.id as any);
},
{ iterations: 5000 },
);
});

14
bench/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "jazz-tools-benchmark",
"private": true,
"type": "module",
"dependencies": {
"cojson": "workspace:*",
"jazz-tools": "workspace:*",
"cojson-latest": "npm:cojson@0.17.9",
"jazz-tools-latest": "npm:jazz-tools@0.17.9"
},
"scripts": {
"bench": "vitest bench"
}
}

7
bench/vitest.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineProject } from "vitest/config";
export default defineProject({
test: {
name: "bench",
},
});

View File

@@ -9,6 +9,7 @@
"ignoreUnknown": false,
"includes": [
"**",
"!crates/**",
"!**/jazz-tools.json",
"!**/ios/**",
"!**/android/**",

8
crates/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Rust
/target
# Test artifacts
lzy/compressed_66k.lzy
# OS generated files
.DS_Store

1164
crates/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

7
crates/Cargo.toml Normal file
View File

@@ -0,0 +1,7 @@
[workspace]
resolver = "2"
members = [
"lzy",
"cojson-core",
"cojson-core-wasm",
]

View File

@@ -0,0 +1,3 @@
# cojson-core-wasm
## 0.17.10

View File

@@ -0,0 +1,29 @@
[package]
name = "cojson-core-wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
cojson-core = { path = "../cojson-core" }
wasm-bindgen = "0.2"
console_error_panic_hook = { version = "0.1.7", optional = true }
ed25519-dalek = { version = "2.2.0", default-features = false, features = ["rand_core"] }
serde_json = "1.0"
serde-wasm-bindgen = "0.6"
serde = { version = "1.0", features = ["derive"] }
js-sys = "0.3"
getrandom = { version = "0.2", features = ["js"] }
thiserror = "1.0"
hex = "0.4"
blake3 = "1.5"
x25519-dalek = { version = "2.0", features = ["getrandom", "static_secrets"] }
crypto_secretbox = { version = "0.1.1", features = ["getrandom"] }
salsa20 = "0.10.2"
rand = "0.8"
bs58 = "0.5"
[features]
default = ["console_error_panic_hook"]

View File

@@ -0,0 +1,26 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
mkdirSync("./public", { recursive: true });
const wasm = readFileSync("./pkg/cojson_core_wasm_bg.wasm");
writeFileSync(
"./public/cojson_core_wasm.wasm.js",
`export const data = "data:application/wasm;base64,${wasm.toString("base64")}";`,
);
writeFileSync(
"./public/cojson_core_wasm.wasm.d.ts",
"export const data: string;",
);
const glueJs = readFileSync("./pkg/cojson_core_wasm.js", "utf8").replace(
"module_or_path = new URL('cojson_core_wasm_bg.wasm', import.meta.url);",
"throw new Error();",
);
writeFileSync("./public/cojson_core_wasm.js", glueJs);
writeFileSync(
"./public/cojson_core_wasm.d.ts",
readFileSync("./pkg/cojson_core_wasm.d.ts", "utf8"),
);

3
crates/cojson-core-wasm/index.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
export * from "./public/cojson_core_wasm.js";
export async function initialize(): Promise<void>;

View File

@@ -0,0 +1,8 @@
export * from "./public/cojson_core_wasm.js";
import __wbg_init from "./public/cojson_core_wasm.js";
import { data } from "./public/cojson_core_wasm.wasm.js";
export async function initialize() {
return await __wbg_init({ module_or_path: data });
}

View File

@@ -0,0 +1,22 @@
{
"name": "cojson-core-wasm",
"type": "module",
"version": "0.17.10",
"files": [
"public/cojson_core_wasm.js",
"public/cojson_core_wasm.d.ts",
"public/cojson_core_wasm.wasm.js",
"public/cojson_core_wasm.wasm.d.ts",
"index.js",
"index.d.ts"
],
"main": "index.js",
"types": "index.d.ts",
"scripts": {
"build:wasm": "wasm-pack build --release --target web && node build.js",
"build:dev": "wasm-pack build --dev --target web && node build.js"
},
"devDependencies": {
"wasm-pack": "^0.13.1"
}
}

View File

@@ -0,0 +1,291 @@
/* tslint:disable */
/* eslint-disable */
/**
* WASM-exposed function for XSalsa20 encryption without authentication.
* - `key`: 32-byte key for encryption
* - `nonce_material`: Raw bytes used to generate a 24-byte nonce via BLAKE3
* - `plaintext`: Raw bytes to encrypt
* Returns the encrypted bytes or throws a JsError if encryption fails.
* Note: This function does not provide authentication. Use encrypt_xsalsa20_poly1305 for authenticated encryption.
*/
export function encrypt_xsalsa20(key: Uint8Array, nonce_material: Uint8Array, plaintext: Uint8Array): Uint8Array;
/**
* WASM-exposed function for XSalsa20 decryption without authentication.
* - `key`: 32-byte key for decryption (must match encryption key)
* - `nonce_material`: Raw bytes used to generate a 24-byte nonce (must match encryption)
* - `ciphertext`: Encrypted bytes to decrypt
* Returns the decrypted bytes or throws a JsError if decryption fails.
* Note: This function does not provide authentication. Use decrypt_xsalsa20_poly1305 for authenticated decryption.
*/
export function decrypt_xsalsa20(key: Uint8Array, nonce_material: Uint8Array, ciphertext: Uint8Array): Uint8Array;
/**
* Generate a new Ed25519 signing key using secure random number generation.
* Returns 32 bytes of raw key material suitable for use with other Ed25519 functions.
*/
export function new_ed25519_signing_key(): Uint8Array;
/**
* WASM-exposed function to derive an Ed25519 verifying key from a signing key.
* - `signing_key`: 32 bytes of signing key material
* Returns 32 bytes of verifying key material or throws JsError if key is invalid.
*/
export function ed25519_verifying_key(signing_key: Uint8Array): Uint8Array;
/**
* WASM-exposed function to sign a message using Ed25519.
* - `signing_key`: 32 bytes of signing key material
* - `message`: Raw bytes to sign
* Returns 64 bytes of signature material or throws JsError if signing fails.
*/
export function ed25519_sign(signing_key: Uint8Array, message: Uint8Array): Uint8Array;
/**
* WASM-exposed function to verify an Ed25519 signature.
* - `verifying_key`: 32 bytes of verifying key material
* - `message`: Raw bytes that were signed
* - `signature`: 64 bytes of signature material
* Returns true if signature is valid, false otherwise, or throws JsError if verification fails.
*/
export function ed25519_verify(verifying_key: Uint8Array, message: Uint8Array, signature: Uint8Array): boolean;
/**
* WASM-exposed function to validate and copy Ed25519 signing key bytes.
* - `bytes`: 32 bytes of signing key material to validate
* Returns the same 32 bytes if valid or throws JsError if invalid.
*/
export function ed25519_signing_key_from_bytes(bytes: Uint8Array): Uint8Array;
/**
* WASM-exposed function to derive the public key from an Ed25519 signing key.
* - `signing_key`: 32 bytes of signing key material
* Returns 32 bytes of public key material or throws JsError if key is invalid.
*/
export function ed25519_signing_key_to_public(signing_key: Uint8Array): Uint8Array;
/**
* WASM-exposed function to sign a message with an Ed25519 signing key.
* - `signing_key`: 32 bytes of signing key material
* - `message`: Raw bytes to sign
* Returns 64 bytes of signature material or throws JsError if signing fails.
*/
export function ed25519_signing_key_sign(signing_key: Uint8Array, message: Uint8Array): Uint8Array;
/**
* WASM-exposed function to validate and copy Ed25519 verifying key bytes.
* - `bytes`: 32 bytes of verifying key material to validate
* Returns the same 32 bytes if valid or throws JsError if invalid.
*/
export function ed25519_verifying_key_from_bytes(bytes: Uint8Array): Uint8Array;
/**
* WASM-exposed function to validate and copy Ed25519 signature bytes.
* - `bytes`: 64 bytes of signature material to validate
* Returns the same 64 bytes if valid or throws JsError if invalid.
*/
export function ed25519_signature_from_bytes(bytes: Uint8Array): Uint8Array;
/**
* WASM-exposed function to sign a message using Ed25519.
* - `message`: Raw bytes to sign
* - `secret`: Raw Ed25519 signing key bytes
* Returns base58-encoded signature with "signature_z" prefix or throws JsError if signing fails.
*/
export function sign(message: Uint8Array, secret: Uint8Array): string;
/**
* WASM-exposed function to verify an Ed25519 signature.
* - `signature`: Raw signature bytes
* - `message`: Raw bytes that were signed
* - `id`: Raw Ed25519 verifying key bytes
* Returns true if signature is valid, false otherwise, or throws JsError if verification fails.
*/
export function verify(signature: Uint8Array, message: Uint8Array, id: Uint8Array): boolean;
/**
* WASM-exposed function to derive a signer ID from a signing key.
* - `secret`: Raw Ed25519 signing key bytes
* Returns base58-encoded verifying key with "signer_z" prefix or throws JsError if derivation fails.
*/
export function get_signer_id(secret: Uint8Array): string;
/**
* Generate a 24-byte nonce from input material using BLAKE3.
* - `nonce_material`: Raw bytes to derive the nonce from
* Returns 24 bytes suitable for use as a nonce in cryptographic operations.
* This function is deterministic - the same input will produce the same nonce.
*/
export function generate_nonce(nonce_material: Uint8Array): Uint8Array;
/**
* Hash data once using BLAKE3.
* - `data`: Raw bytes to hash
* Returns 32 bytes of hash output.
* This is the simplest way to compute a BLAKE3 hash of a single piece of data.
*/
export function blake3_hash_once(data: Uint8Array): Uint8Array;
/**
* Hash data once using BLAKE3 with a context prefix.
* - `data`: Raw bytes to hash
* - `context`: Context bytes to prefix to the data
* Returns 32 bytes of hash output.
* This is useful for domain separation - the same data hashed with different contexts will produce different outputs.
*/
export function blake3_hash_once_with_context(data: Uint8Array, context: Uint8Array): Uint8Array;
/**
* Get an empty BLAKE3 state for incremental hashing.
* Returns a new Blake3Hasher instance for incremental hashing.
*/
export function blake3_empty_state(): Blake3Hasher;
/**
* Update a BLAKE3 state with new data for incremental hashing.
* - `state`: Current Blake3Hasher instance
* - `data`: New data to incorporate into the hash
* Returns the updated Blake3Hasher.
*/
export function blake3_update_state(state: Blake3Hasher, data: Uint8Array): void;
/**
* Get the final hash from a BLAKE3 state.
* - `state`: The Blake3Hasher to finalize
* Returns 32 bytes of hash output.
* This finalizes an incremental hashing operation.
*/
export function blake3_digest_for_state(state: Blake3Hasher): Uint8Array;
/**
* Generate a new X25519 private key using secure random number generation.
* Returns 32 bytes of raw key material suitable for use with other X25519 functions.
* This key can be reused for multiple Diffie-Hellman exchanges.
*/
export function new_x25519_private_key(): Uint8Array;
/**
* WASM-exposed function to derive an X25519 public key from a private key.
* - `private_key`: 32 bytes of private key material
* Returns 32 bytes of public key material or throws JsError if key is invalid.
*/
export function x25519_public_key(private_key: Uint8Array): Uint8Array;
/**
* WASM-exposed function to perform X25519 Diffie-Hellman key exchange.
* - `private_key`: 32 bytes of private key material
* - `public_key`: 32 bytes of public key material
* Returns 32 bytes of shared secret material or throws JsError if key exchange fails.
*/
export function x25519_diffie_hellman(private_key: Uint8Array, public_key: Uint8Array): Uint8Array;
/**
* WASM-exposed function to derive a sealer ID from a sealer secret.
* - `secret`: Raw bytes of the sealer secret
* Returns a base58-encoded sealer ID with "sealer_z" prefix or throws JsError if derivation fails.
*/
export function get_sealer_id(secret: Uint8Array): string;
/**
* WASM-exposed function for sealing a message using X25519 + XSalsa20-Poly1305.
* Provides authenticated encryption with perfect forward secrecy.
* - `message`: Raw bytes to seal
* - `sender_secret`: Base58-encoded sender's private key with "sealerSecret_z" prefix
* - `recipient_id`: Base58-encoded recipient's public key with "sealer_z" prefix
* - `nonce_material`: Raw bytes used to generate the nonce
* Returns sealed bytes or throws JsError if sealing fails.
*/
export function seal(message: Uint8Array, sender_secret: string, recipient_id: string, nonce_material: Uint8Array): Uint8Array;
/**
* WASM-exposed function for unsealing a message using X25519 + XSalsa20-Poly1305.
* Provides authenticated decryption with perfect forward secrecy.
* - `sealed_message`: The sealed bytes to decrypt
* - `recipient_secret`: Base58-encoded recipient's private key with "sealerSecret_z" prefix
* - `sender_id`: Base58-encoded sender's public key with "sealer_z" prefix
* - `nonce_material`: Raw bytes used to generate the nonce (must match sealing)
* Returns unsealed bytes or throws JsError if unsealing fails.
*/
export function unseal(sealed_message: Uint8Array, recipient_secret: string, sender_id: string, nonce_material: Uint8Array): Uint8Array;
/**
* WASM-exposed function to encrypt bytes with a key secret and nonce material.
* - `value`: The raw bytes to encrypt
* - `key_secret`: A base58-encoded key secret with "keySecret_z" prefix
* - `nonce_material`: Raw bytes used to generate the nonce
* Returns the encrypted bytes or throws a JsError if encryption fails.
*/
export function encrypt(value: Uint8Array, key_secret: string, nonce_material: Uint8Array): Uint8Array;
/**
* WASM-exposed function to decrypt bytes with a key secret and nonce material.
* - `ciphertext`: The encrypted bytes to decrypt
* - `key_secret`: A base58-encoded key secret with "keySecret_z" prefix
* - `nonce_material`: Raw bytes used to generate the nonce (must match encryption)
* Returns the decrypted bytes or throws a JsError if decryption fails.
*/
export function decrypt(ciphertext: Uint8Array, key_secret: string, nonce_material: Uint8Array): Uint8Array;
export class Blake3Hasher {
free(): void;
constructor();
update(data: Uint8Array): void;
finalize(): Uint8Array;
clone(): Blake3Hasher;
}
export class SessionLog {
free(): void;
constructor(co_id: string, session_id: string, signer_id?: string | null);
clone(): SessionLog;
tryAdd(transactions_json: string[], new_signature_str: string, skip_verify: boolean): void;
addNewPrivateTransaction(changes_json: string, signer_secret: string, encryption_key: string, key_id: string, made_at: number): string;
addNewTrustingTransaction(changes_json: string, signer_secret: string, made_at: number): string;
decryptNextTransactionChangesJson(tx_index: number, encryption_key: string): string;
}
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly decrypt_xsalsa20: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number, number];
readonly encrypt_xsalsa20: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number, number];
readonly __wbg_sessionlog_free: (a: number, b: number) => void;
readonly sessionlog_new: (a: number, b: number, c: number, d: number, e: number, f: number) => number;
readonly sessionlog_clone: (a: number) => number;
readonly sessionlog_tryAdd: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
readonly sessionlog_addNewPrivateTransaction: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number) => [number, number, number, number];
readonly sessionlog_addNewTrustingTransaction: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number, number];
readonly sessionlog_decryptNextTransactionChangesJson: (a: number, b: number, c: number, d: number) => [number, number, number, number];
readonly new_ed25519_signing_key: () => [number, number];
readonly ed25519_sign: (a: number, b: number, c: number, d: number) => [number, number, number, number];
readonly ed25519_verify: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
readonly ed25519_signing_key_from_bytes: (a: number, b: number) => [number, number, number, number];
readonly ed25519_signing_key_to_public: (a: number, b: number) => [number, number, number, number];
readonly ed25519_verifying_key_from_bytes: (a: number, b: number) => [number, number, number, number];
readonly ed25519_signature_from_bytes: (a: number, b: number) => [number, number, number, number];
readonly ed25519_verifying_key: (a: number, b: number) => [number, number, number, number];
readonly ed25519_signing_key_sign: (a: number, b: number, c: number, d: number) => [number, number, number, number];
readonly sign: (a: number, b: number, c: number, d: number) => [number, number, number, number];
readonly verify: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number];
readonly get_signer_id: (a: number, b: number) => [number, number, number, number];
readonly generate_nonce: (a: number, b: number) => [number, number];
readonly blake3_hash_once: (a: number, b: number) => [number, number];
readonly blake3_hash_once_with_context: (a: number, b: number, c: number, d: number) => [number, number];
readonly __wbg_blake3hasher_free: (a: number, b: number) => void;
readonly blake3hasher_finalize: (a: number) => [number, number];
readonly blake3hasher_clone: (a: number) => number;
readonly blake3_empty_state: () => number;
readonly blake3_update_state: (a: number, b: number, c: number) => void;
readonly blake3_digest_for_state: (a: number) => [number, number];
readonly blake3hasher_update: (a: number, b: number, c: number) => void;
readonly blake3hasher_new: () => number;
readonly new_x25519_private_key: () => [number, number];
readonly x25519_public_key: (a: number, b: number) => [number, number, number, number];
readonly x25519_diffie_hellman: (a: number, b: number, c: number, d: number) => [number, number, number, number];
readonly get_sealer_id: (a: number, b: number) => [number, number, number, number];
readonly seal: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number, number, number];
readonly unseal: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number, number, number];
readonly encrypt: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number, number];
readonly decrypt: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number, number];
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_exn_store: (a: number) => void;
readonly __externref_table_alloc: () => number;
readonly __wbindgen_export_4: WebAssembly.Table;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export const data: string;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,240 @@
use crate::error::CryptoError;
use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
use rand::rngs::OsRng;
use wasm_bindgen::prelude::*;
/// Generate a new Ed25519 signing key using secure random number generation.
/// Returns 32 bytes of raw key material suitable for use with other Ed25519 functions.
#[wasm_bindgen]
pub fn new_ed25519_signing_key() -> Box<[u8]> {
let mut rng = OsRng;
let signing_key = SigningKey::generate(&mut rng);
signing_key.to_bytes().into()
}
/// Internal function to derive an Ed25519 verifying key from a signing key.
/// Takes 32 bytes of signing key material and returns 32 bytes of verifying key material.
/// Returns CryptoError if the key length is invalid.
pub(crate) fn ed25519_verifying_key_internal(signing_key: &[u8]) -> Result<Box<[u8]>, CryptoError> {
let key_bytes: [u8; 32] = signing_key
.try_into()
.map_err(|_| CryptoError::InvalidKeyLength(32, signing_key.len()))?;
let signing_key = SigningKey::from_bytes(&key_bytes);
Ok(signing_key.verifying_key().to_bytes().into())
}
/// WASM-exposed function to derive an Ed25519 verifying key from a signing key.
/// - `signing_key`: 32 bytes of signing key material
/// Returns 32 bytes of verifying key material or throws JsError if key is invalid.
#[wasm_bindgen]
pub fn ed25519_verifying_key(signing_key: &[u8]) -> Result<Box<[u8]>, JsError> {
ed25519_verifying_key_internal(signing_key).map_err(|e| JsError::new(&e.to_string()))
}
/// Internal function to sign a message using Ed25519.
/// Takes 32 bytes of signing key material and arbitrary message bytes.
/// Returns 64 bytes of signature material or CryptoError if key is invalid.
pub(crate) fn ed25519_sign_internal(
signing_key: &[u8],
message: &[u8],
) -> Result<[u8; 64], CryptoError> {
let key_bytes: [u8; 32] = signing_key
.try_into()
.map_err(|_| CryptoError::InvalidKeyLength(32, signing_key.len()))?;
let signing_key = SigningKey::from_bytes(&key_bytes);
Ok(signing_key.sign(message).to_bytes())
}
/// WASM-exposed function to sign a message using Ed25519.
/// - `signing_key`: 32 bytes of signing key material
/// - `message`: Raw bytes to sign
/// Returns 64 bytes of signature material or throws JsError if signing fails.
#[wasm_bindgen]
pub fn ed25519_sign(signing_key: &[u8], message: &[u8]) -> Result<Box<[u8]>, JsError> {
Ok(ed25519_sign_internal(signing_key, message)?.into())
}
/// Internal function to verify an Ed25519 signature.
/// - `verifying_key`: 32 bytes of verifying key material
/// - `message`: Raw bytes that were signed
/// - `signature`: 64 bytes of signature material
/// Returns true if signature is valid, false otherwise, or CryptoError if key/signature format is invalid.
pub(crate) fn ed25519_verify_internal(
verifying_key: &[u8],
message: &[u8],
signature: &[u8],
) -> Result<bool, CryptoError> {
let key_bytes: [u8; 32] = verifying_key
.try_into()
.map_err(|_| CryptoError::InvalidKeyLength(32, verifying_key.len()))?;
let verifying_key = VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| CryptoError::InvalidVerifyingKey(e.to_string()))?;
let sig_bytes: [u8; 64] = signature
.try_into()
.map_err(|_| CryptoError::InvalidSignatureLength)?;
let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
Ok(verifying_key.verify(message, &signature).is_ok())
}
/// WASM-exposed function to verify an Ed25519 signature.
/// - `verifying_key`: 32 bytes of verifying key material
/// - `message`: Raw bytes that were signed
/// - `signature`: 64 bytes of signature material
/// Returns true if signature is valid, false otherwise, or throws JsError if verification fails.
#[wasm_bindgen]
pub fn ed25519_verify(
verifying_key: &[u8],
message: &[u8],
signature: &[u8],
) -> Result<bool, JsError> {
ed25519_verify_internal(verifying_key, message, signature)
.map_err(|e| JsError::new(&e.to_string()))
}
/// WASM-exposed function to validate and copy Ed25519 signing key bytes.
/// - `bytes`: 32 bytes of signing key material to validate
/// Returns the same 32 bytes if valid or throws JsError if invalid.
#[wasm_bindgen]
pub fn ed25519_signing_key_from_bytes(bytes: &[u8]) -> Result<Box<[u8]>, JsError> {
let key_bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| JsError::new("Invalid signing key length"))?;
Ok(key_bytes.into())
}
/// WASM-exposed function to derive the public key from an Ed25519 signing key.
/// - `signing_key`: 32 bytes of signing key material
/// Returns 32 bytes of public key material or throws JsError if key is invalid.
#[wasm_bindgen]
pub fn ed25519_signing_key_to_public(signing_key: &[u8]) -> Result<Box<[u8]>, JsError> {
ed25519_verifying_key_internal(signing_key).map_err(|e| JsError::new(&e.to_string()))
}
/// WASM-exposed function to sign a message with an Ed25519 signing key.
/// - `signing_key`: 32 bytes of signing key material
/// - `message`: Raw bytes to sign
/// Returns 64 bytes of signature material or throws JsError if signing fails.
#[wasm_bindgen]
pub fn ed25519_signing_key_sign(signing_key: &[u8], message: &[u8]) -> Result<Box<[u8]>, JsError> {
Ok(ed25519_sign_internal(signing_key, message)?.into())
}
/// WASM-exposed function to validate and copy Ed25519 verifying key bytes.
/// - `bytes`: 32 bytes of verifying key material to validate
/// Returns the same 32 bytes if valid or throws JsError if invalid.
#[wasm_bindgen]
pub fn ed25519_verifying_key_from_bytes(bytes: &[u8]) -> Result<Box<[u8]>, JsError> {
let key_bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| JsError::new("Invalid verifying key length"))?;
Ok(key_bytes.into())
}
/// WASM-exposed function to validate and copy Ed25519 signature bytes.
/// - `bytes`: 64 bytes of signature material to validate
/// Returns the same 64 bytes if valid or throws JsError if invalid.
#[wasm_bindgen]
pub fn ed25519_signature_from_bytes(bytes: &[u8]) -> Result<Box<[u8]>, JsError> {
let sig_bytes: [u8; 64] = bytes
.try_into()
.map_err(|_| JsError::new("Invalid signature length"))?;
Ok(sig_bytes.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ed25519_key_generation_and_signing() {
// Test key generation
let signing_key = new_ed25519_signing_key();
assert_eq!(signing_key.len(), 32, "Signing key should be 32 bytes");
// Test verifying key derivation
let verifying_key = ed25519_verifying_key_internal(&signing_key).unwrap();
assert_eq!(verifying_key.len(), 32, "Verifying key should be 32 bytes");
// Test that different signing keys produce different verifying keys
let signing_key2 = new_ed25519_signing_key();
let verifying_key2 = ed25519_verifying_key_internal(&signing_key2).unwrap();
assert_ne!(
verifying_key, verifying_key2,
"Different signing keys should produce different verifying keys"
);
// Test signing and verification
let message = b"Test message";
let signature = ed25519_sign_internal(&signing_key, message).unwrap();
assert_eq!(signature.len(), 64, "Signature should be 64 bytes");
// Test successful verification
let verification_result =
ed25519_verify_internal(&verifying_key, message, &signature).unwrap();
assert!(
verification_result,
"Valid signature should verify successfully"
);
// Test verification with wrong message
let wrong_message = b"Wrong message";
let wrong_verification =
ed25519_verify_internal(&verifying_key, wrong_message, &signature).unwrap();
assert!(
!wrong_verification,
"Signature should not verify with wrong message"
);
// Test verification with wrong key
let wrong_verification =
ed25519_verify_internal(&verifying_key2, message, &signature).unwrap();
assert!(
!wrong_verification,
"Signature should not verify with wrong key"
);
// Test verification with tampered signature
let mut tampered_signature = signature.clone();
tampered_signature[0] ^= 1;
let wrong_verification =
ed25519_verify_internal(&verifying_key, message, &tampered_signature).unwrap();
assert!(!wrong_verification, "Tampered signature should not verify");
}
#[test]
fn test_ed25519_error_cases() {
// Test invalid signing key length
let invalid_signing_key = vec![0u8; 31]; // Too short
let result = ed25519_verifying_key_internal(&invalid_signing_key);
assert!(result.is_err());
let result = ed25519_sign_internal(&invalid_signing_key, b"test");
assert!(result.is_err());
// Test invalid verifying key length
let invalid_verifying_key = vec![0u8; 31]; // Too short
let valid_signing_key = new_ed25519_signing_key();
let valid_signature = ed25519_sign_internal(&valid_signing_key, b"test").unwrap();
let result = ed25519_verify_internal(&invalid_verifying_key, b"test", &valid_signature);
assert!(result.is_err());
// Test invalid signature length
let valid_verifying_key = ed25519_verifying_key_internal(&valid_signing_key).unwrap();
let invalid_signature = vec![0u8; 63]; // Too short
let result = ed25519_verify_internal(&valid_verifying_key, b"test", &invalid_signature);
assert!(result.is_err());
// Test with too long keys
let too_long_key = vec![0u8; 33]; // Too long
let result = ed25519_verifying_key_internal(&too_long_key);
assert!(result.is_err());
let result = ed25519_sign_internal(&too_long_key, b"test");
assert!(result.is_err());
// Test with too long signature
let too_long_signature = vec![0u8; 65]; // Too long
let result = ed25519_verify_internal(&valid_verifying_key, b"test", &too_long_signature);
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,113 @@
use crate::error::CryptoError;
use crate::hash::blake3::generate_nonce;
use bs58;
use wasm_bindgen::prelude::*;
/// Internal function to encrypt bytes with a key secret and nonce material.
/// Takes a base58-encoded key secret with "keySecret_z" prefix and raw nonce material.
/// Returns the encrypted bytes or a CryptoError if the key format is invalid.
pub fn encrypt_internal(
plaintext: &[u8],
key_secret: &str,
nonce_material: &[u8],
) -> Result<Box<[u8]>, CryptoError> {
// Decode the base58 key secret (removing the "keySecret_z" prefix)
let key_secret = key_secret
.strip_prefix("keySecret_z")
.ok_or(CryptoError::InvalidPrefix("key secret", "keySecret_z"))?;
let key = bs58::decode(key_secret)
.into_vec()
.map_err(|e| CryptoError::Base58Error(e.to_string()))?;
// Generate nonce from nonce material
let nonce = generate_nonce(nonce_material);
// Encrypt using XSalsa20
Ok(super::xsalsa20::encrypt_xsalsa20_raw_internal(&key, &nonce, plaintext)?.into())
}
/// Internal function to decrypt bytes with a key secret and nonce material.
/// Takes a base58-encoded key secret with "keySecret_z" prefix and raw nonce material.
/// Returns the decrypted bytes or a CryptoError if the key format is invalid.
pub fn decrypt_internal(
ciphertext: &[u8],
key_secret: &str,
nonce_material: &[u8],
) -> Result<Box<[u8]>, CryptoError> {
// Decode the base58 key secret (removing the "keySecret_z" prefix)
let key_secret = key_secret
.strip_prefix("keySecret_z")
.ok_or(CryptoError::InvalidPrefix("key secret", "keySecret_z"))?;
let key = bs58::decode(key_secret)
.into_vec()
.map_err(|e| CryptoError::Base58Error(e.to_string()))?;
// Generate nonce from nonce material
let nonce = generate_nonce(nonce_material);
// Decrypt using XSalsa20
Ok(super::xsalsa20::decrypt_xsalsa20_raw_internal(&key, &nonce, ciphertext)?.into())
}
/// WASM-exposed function to encrypt bytes with a key secret and nonce material.
/// - `value`: The raw bytes to encrypt
/// - `key_secret`: A base58-encoded key secret with "keySecret_z" prefix
/// - `nonce_material`: Raw bytes used to generate the nonce
/// Returns the encrypted bytes or throws a JsError if encryption fails.
#[wasm_bindgen(js_name = encrypt)]
pub fn encrypt(
value: &[u8],
key_secret: &str,
nonce_material: &[u8],
) -> Result<Box<[u8]>, JsError> {
encrypt_internal(value, key_secret, nonce_material).map_err(|e| JsError::new(&e.to_string()))
}
/// WASM-exposed function to decrypt bytes with a key secret and nonce material.
/// - `ciphertext`: The encrypted bytes to decrypt
/// - `key_secret`: A base58-encoded key secret with "keySecret_z" prefix
/// - `nonce_material`: Raw bytes used to generate the nonce (must match encryption)
/// Returns the decrypted bytes or throws a JsError if decryption fails.
#[wasm_bindgen(js_name = decrypt)]
pub fn decrypt(
ciphertext: &[u8],
key_secret: &str,
nonce_material: &[u8],
) -> Result<Box<[u8]>, JsError> {
Ok(decrypt_internal(ciphertext, key_secret, nonce_material)?.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypt_decrypt() {
// Test data
let plaintext = b"Hello, World!";
let key_secret = "keySecret_z11111111111111111111111111111111"; // Example base58 encoded key
let nonce_material = b"test_nonce_material";
// Test encryption
let ciphertext = encrypt_internal(plaintext, key_secret, nonce_material).unwrap();
assert!(!ciphertext.is_empty());
// Test decryption
let decrypted = decrypt_internal(&ciphertext, key_secret, nonce_material).unwrap();
assert_eq!(&*decrypted, plaintext);
}
#[test]
fn test_invalid_key_secret() {
let plaintext = b"test";
let nonce_material = b"nonce";
// Test with invalid key secret format
let result = encrypt_internal(plaintext, "invalid_key", nonce_material);
assert!(result.is_err());
// Test with invalid base58 encoding
let result = encrypt_internal(plaintext, "keySecret_z!!!!", nonce_material);
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,200 @@
use crate::crypto::x25519::x25519_diffie_hellman_internal;
use crate::crypto::xsalsa20::{decrypt_xsalsa20_poly1305, encrypt_xsalsa20_poly1305};
use crate::error::CryptoError;
use crate::hash::blake3::generate_nonce;
use bs58;
use wasm_bindgen::prelude::*;
/// Internal function to seal a message using X25519 + XSalsa20-Poly1305.
/// - `message`: Raw bytes to seal
/// - `sender_secret`: Base58-encoded sender's private key with "sealerSecret_z" prefix
/// - `recipient_id`: Base58-encoded recipient's public key with "sealer_z" prefix
/// - `nonce_material`: Raw bytes used to generate the nonce
/// Returns sealed bytes or CryptoError if key formats are invalid.
///
/// The sealing process:
/// 1. Decode base58 keys and validate prefixes
/// 2. Generate shared secret using X25519 key exchange
/// 3. Generate nonce from nonce material using BLAKE3
/// 4. Encrypt message using XSalsa20-Poly1305 with the shared secret
pub fn seal_internal(
message: &[u8],
sender_secret: &str,
recipient_id: &str,
nonce_material: &[u8],
) -> Result<Vec<u8>, CryptoError> {
// Decode the base58 sender secret (removing the "sealerSecret_z" prefix)
let sender_secret =
sender_secret
.strip_prefix("sealerSecret_z")
.ok_or(CryptoError::InvalidPrefix(
"sealer secret",
"sealerSecret_z",
))?;
let sender_private_key = bs58::decode(sender_secret)
.into_vec()
.map_err(|e| CryptoError::Base58Error(e.to_string()))?;
// Decode the base58 recipient ID (removing the "sealer_z" prefix)
let recipient_id = recipient_id
.strip_prefix("sealer_z")
.ok_or(CryptoError::InvalidPrefix("sealer ID", "sealer_z"))?;
let recipient_public_key = bs58::decode(recipient_id)
.into_vec()
.map_err(|e| CryptoError::Base58Error(e.to_string()))?;
let nonce = generate_nonce(nonce_material);
// Generate shared secret using X25519
let shared_secret = x25519_diffie_hellman_internal(&sender_private_key, &recipient_public_key)?;
// Encrypt message using XSalsa20-Poly1305
Ok(encrypt_xsalsa20_poly1305(&shared_secret, &nonce, message)?.into())
}
/// Internal function to unseal a message using X25519 + XSalsa20-Poly1305.
/// - `sealed_message`: The sealed bytes to decrypt
/// - `recipient_secret`: Base58-encoded recipient's private key with "sealerSecret_z" prefix
/// - `sender_id`: Base58-encoded sender's public key with "sealer_z" prefix
/// - `nonce_material`: Raw bytes used to generate the nonce (must match sealing)
/// Returns unsealed bytes or CryptoError if key formats are invalid or authentication fails.
///
/// The unsealing process:
/// 1. Decode base58 keys and validate prefixes
/// 2. Generate shared secret using X25519 key exchange
/// 3. Generate nonce from nonce material using BLAKE3
/// 4. Decrypt and authenticate message using XSalsa20-Poly1305 with the shared secret
fn unseal_internal(
sealed_message: &[u8],
recipient_secret: &str,
sender_id: &str,
nonce_material: &[u8],
) -> Result<Box<[u8]>, CryptoError> {
// Decode the base58 recipient secret (removing the "sealerSecret_z" prefix)
let recipient_secret =
recipient_secret
.strip_prefix("sealerSecret_z")
.ok_or(CryptoError::InvalidPrefix(
"sealer secret",
"sealerSecret_z",
))?;
let recipient_private_key = bs58::decode(recipient_secret)
.into_vec()
.map_err(|e| CryptoError::Base58Error(e.to_string()))?;
// Decode the base58 sender ID (removing the "sealer_z" prefix)
let sender_id = sender_id
.strip_prefix("sealer_z")
.ok_or(CryptoError::InvalidPrefix("sealer ID", "sealer_z"))?;
let sender_public_key = bs58::decode(sender_id)
.into_vec()
.map_err(|e| CryptoError::Base58Error(e.to_string()))?;
let nonce = generate_nonce(nonce_material);
// Generate shared secret using X25519
let shared_secret = x25519_diffie_hellman_internal(&recipient_private_key, &sender_public_key)?;
// Decrypt message using XSalsa20-Poly1305
Ok(decrypt_xsalsa20_poly1305(&shared_secret, &nonce, sealed_message)?.into())
}
/// WASM-exposed function for sealing a message using X25519 + XSalsa20-Poly1305.
/// Provides authenticated encryption with perfect forward secrecy.
/// - `message`: Raw bytes to seal
/// - `sender_secret`: Base58-encoded sender's private key with "sealerSecret_z" prefix
/// - `recipient_id`: Base58-encoded recipient's public key with "sealer_z" prefix
/// - `nonce_material`: Raw bytes used to generate the nonce
/// Returns sealed bytes or throws JsError if sealing fails.
#[wasm_bindgen(js_name = seal)]
pub fn seal(
message: &[u8],
sender_secret: &str,
recipient_id: &str,
nonce_material: &[u8],
) -> Result<Box<[u8]>, JsError> {
Ok(seal_internal(message, sender_secret, recipient_id, nonce_material)?.into())
}
/// WASM-exposed function for unsealing a message using X25519 + XSalsa20-Poly1305.
/// Provides authenticated decryption with perfect forward secrecy.
/// - `sealed_message`: The sealed bytes to decrypt
/// - `recipient_secret`: Base58-encoded recipient's private key with "sealerSecret_z" prefix
/// - `sender_id`: Base58-encoded sender's public key with "sealer_z" prefix
/// - `nonce_material`: Raw bytes used to generate the nonce (must match sealing)
/// Returns unsealed bytes or throws JsError if unsealing fails.
#[wasm_bindgen(js_name = unseal)]
pub fn unseal(
sealed_message: &[u8],
recipient_secret: &str,
sender_id: &str,
nonce_material: &[u8],
) -> Result<Box<[u8]>, JsError> {
Ok(unseal_internal(sealed_message, recipient_secret, sender_id, nonce_material)?.into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::x25519::{new_x25519_private_key, x25519_public_key_internal};
#[test]
fn test_seal_unseal() {
// Generate real keys
let sender_private = new_x25519_private_key();
let sender_public = x25519_public_key_internal(&sender_private).unwrap();
// Encode keys with proper prefixes
let sender_secret = format!(
"sealerSecret_z{}",
bs58::encode(&sender_private).into_string()
);
let recipient_id = format!("sealer_z{}", bs58::encode(&sender_public).into_string());
// Test data
let message = b"Secret message";
let nonce_material = b"test_nonce_material";
// Test sealing
let sealed = seal_internal(message, &sender_secret, &recipient_id, nonce_material).unwrap();
assert!(!sealed.is_empty());
// Test unsealing (using same keys since it's a test)
let unsealed =
unseal_internal(&sealed, &sender_secret, &recipient_id, nonce_material).unwrap();
assert_eq!(&*unsealed, message);
}
#[test]
fn test_invalid_keys() {
let message = b"test";
let nonce_material = b"nonce";
// Test with invalid sender secret format
let result = seal_internal(
message,
"invalid_key",
"sealer_z22222222222222222222222222222222",
nonce_material,
);
assert!(result.is_err());
// Test with invalid recipient ID format
let result = seal_internal(
message,
"sealerSecret_z11111111111111111111111111111111",
"invalid_key",
nonce_material,
);
assert!(result.is_err());
// Test with invalid base58 encoding
let result = seal_internal(
message,
"sealerSecret_z!!!!",
"sealer_z22222222222222222222222222222222",
nonce_material,
);
assert!(result.is_err());
}
}

View File

@@ -0,0 +1,184 @@
use crate::crypto::ed25519::{
ed25519_sign_internal, ed25519_verify_internal, ed25519_verifying_key_internal,
};
use crate::error::CryptoError;
use bs58;
use wasm_bindgen::prelude::*;
/// Internal function to sign a message using Ed25519.
/// - `message`: Raw bytes to sign
/// - `secret`: Base58-encoded signing key with "signerSecret_z" prefix
/// Returns base58-encoded signature with "signature_z" prefix or error string.
pub fn sign_internal(message: &[u8], secret: &str) -> Result<String, CryptoError> {
let secret_bytes = bs58::decode(secret.strip_prefix("signerSecret_z").ok_or(
CryptoError::InvalidPrefix("signer secret", "signerSecret_z"),
)?)
.into_vec()
.map_err(|e| CryptoError::Base58Error(e.to_string()))?;
let signature = ed25519_sign_internal(&secret_bytes, message)
.map_err(|e| CryptoError::InvalidVerifyingKey(e.to_string()))?;
Ok(format!(
"signature_z{}",
bs58::encode(signature).into_string()
))
}
/// Internal function to verify an Ed25519 signature.
/// - `signature`: Base58-encoded signature with "signature_z" prefix
/// - `message`: Raw bytes that were signed
/// - `id`: Base58-encoded verifying key with "signer_z" prefix
/// Returns true if signature is valid, false otherwise, or error string if formats are invalid.
pub fn verify_internal(signature: &str, message: &[u8], id: &str) -> Result<bool, CryptoError> {
let signature_bytes = bs58::decode(
signature
.strip_prefix("signature_z")
.ok_or(CryptoError::InvalidPrefix("signature_z", "signature"))?,
)
.into_vec()
.map_err(|e| CryptoError::Base58Error(e.to_string()))?;
let verifying_key = bs58::decode(
id.strip_prefix("signer_z")
.ok_or(CryptoError::InvalidPrefix("signer_z", "signer ID"))?,
)
.into_vec()
.map_err(|e| CryptoError::Base58Error(e.to_string()))?;
ed25519_verify_internal(&verifying_key, message, &signature_bytes)
.map_err(|e| CryptoError::InvalidVerifyingKey(e.to_string()))
}
/// Internal function to derive a signer ID from a signing key.
/// - `secret`: Base58-encoded signing key with "signerSecret_z" prefix
/// Returns base58-encoded verifying key with "signer_z" prefix or error string.
pub fn get_signer_id_internal(secret: &str) -> Result<String, CryptoError> {
let secret_bytes = bs58::decode(secret.strip_prefix("signerSecret_z").ok_or(
CryptoError::InvalidPrefix("signerSecret_z", "signer secret"),
)?)
.into_vec()
.map_err(|e| CryptoError::Base58Error(e.to_string()))?;
let verifying_key = ed25519_verifying_key_internal(&secret_bytes)
.map_err(|e| CryptoError::InvalidVerifyingKey(e.to_string()))?;
Ok(format!(
"signer_z{}",
bs58::encode(verifying_key).into_string()
))
}
/// WASM-exposed function to sign a message using Ed25519.
/// - `message`: Raw bytes to sign
/// - `secret`: Raw Ed25519 signing key bytes
/// Returns base58-encoded signature with "signature_z" prefix or throws JsError if signing fails.
#[wasm_bindgen(js_name = sign)]
pub fn sign(message: &[u8], secret: &[u8]) -> Result<String, JsError> {
let secret_str = std::str::from_utf8(secret)
.map_err(|e| JsError::new(&format!("Invalid UTF-8 in secret: {:?}", e)))?;
sign_internal(message, secret_str).map_err(|e| JsError::new(&e.to_string()))
}
/// WASM-exposed function to verify an Ed25519 signature.
/// - `signature`: Raw signature bytes
/// - `message`: Raw bytes that were signed
/// - `id`: Raw Ed25519 verifying key bytes
/// Returns true if signature is valid, false otherwise, or throws JsError if verification fails.
#[wasm_bindgen(js_name = verify)]
pub fn verify(signature: &[u8], message: &[u8], id: &[u8]) -> Result<bool, JsError> {
let signature_str = std::str::from_utf8(signature)
.map_err(|e| JsError::new(&format!("Invalid UTF-8 in signature: {:?}", e)))?;
let id_str = std::str::from_utf8(id)
.map_err(|e| JsError::new(&format!("Invalid UTF-8 in id: {:?}", e)))?;
verify_internal(signature_str, message, id_str).map_err(|e| JsError::new(&e.to_string()))
}
/// WASM-exposed function to derive a signer ID from a signing key.
/// - `secret`: Raw Ed25519 signing key bytes
/// Returns base58-encoded verifying key with "signer_z" prefix or throws JsError if derivation fails.
#[wasm_bindgen(js_name = get_signer_id)]
pub fn get_signer_id(secret: &[u8]) -> Result<String, JsError> {
let secret_str = std::str::from_utf8(secret)
.map_err(|e| JsError::new(&format!("Invalid UTF-8 in secret: {:?}", e)))?;
get_signer_id_internal(secret_str).map_err(|e| JsError::new(&e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::ed25519::new_ed25519_signing_key;
#[test]
fn test_sign_and_verify() {
let message = b"hello world";
// Create a test signing key
let signing_key = new_ed25519_signing_key();
let secret = format!("signerSecret_z{}", bs58::encode(&signing_key).into_string());
// Sign the message
let signature = sign_internal(message, &secret).unwrap();
// Get the public key for verification
let secret_bytes = bs58::decode(secret.strip_prefix("signerSecret_z").unwrap())
.into_vec()
.unwrap();
let verifying_key = ed25519_verifying_key_internal(&secret_bytes).unwrap();
let signer_id = format!("signer_z{}", bs58::encode(&verifying_key).into_string());
// Verify the signature
assert!(verify_internal(&signature, message, &signer_id).unwrap());
}
#[test]
fn test_invalid_inputs() {
let message = b"hello world";
// Test invalid base58 in secret
let result = sign_internal(message, "signerSecret_z!!!invalid!!!");
assert!(matches!(result, Err(CryptoError::Base58Error(_))));
// Test invalid signature format
let result = verify_internal("not_a_signature", message, "signer_z123");
assert!(matches!(
result,
Err(CryptoError::InvalidPrefix("signature_z", "signature"))
));
// Test invalid signer ID format
let result = verify_internal("signature_z123", message, "not_a_signer");
assert!(matches!(
result,
Err(CryptoError::InvalidPrefix("signer_z", "signer ID"))
));
}
#[test]
fn test_get_signer_id() {
// Create a test signing key
let signing_key = new_ed25519_signing_key();
let secret = format!("signerSecret_z{}", bs58::encode(&signing_key).into_string());
// Get signer ID
let signer_id = get_signer_id_internal(&secret).unwrap();
assert!(signer_id.starts_with("signer_z"));
// Test that same secret produces same ID
let signer_id2 = get_signer_id_internal(&secret).unwrap();
assert_eq!(signer_id, signer_id2);
// Test invalid secret format
let result = get_signer_id_internal("invalid_secret");
assert!(matches!(
result,
Err(CryptoError::InvalidPrefix(
"signerSecret_z",
"signer secret"
))
));
// Test invalid base58
let result = get_signer_id_internal("signerSecret_z!!!invalid!!!");
assert!(matches!(result, Err(CryptoError::Base58Error(_))));
}
}

View File

@@ -0,0 +1,168 @@
use crate::error::CryptoError;
use bs58;
use wasm_bindgen::prelude::*;
use x25519_dalek::{PublicKey, StaticSecret};
/// Generate a new X25519 private key using secure random number generation.
/// Returns 32 bytes of raw key material suitable for use with other X25519 functions.
/// This key can be reused for multiple Diffie-Hellman exchanges.
#[wasm_bindgen]
pub fn new_x25519_private_key() -> Vec<u8> {
let secret = StaticSecret::random();
secret.to_bytes().to_vec()
}
/// Internal function to derive an X25519 public key from a private key.
/// Takes 32 bytes of private key material and returns 32 bytes of public key material.
/// Returns CryptoError if the key length is invalid.
pub(crate) fn x25519_public_key_internal(private_key: &[u8]) -> Result<[u8; 32], CryptoError> {
let bytes: [u8; 32] = private_key
.try_into()
.map_err(|_| CryptoError::InvalidKeyLength(32, private_key.len()))?;
let secret = StaticSecret::from(bytes);
Ok(PublicKey::from(&secret).to_bytes())
}
/// WASM-exposed function to derive an X25519 public key from a private key.
/// - `private_key`: 32 bytes of private key material
/// Returns 32 bytes of public key material or throws JsError if key is invalid.
#[wasm_bindgen]
pub fn x25519_public_key(private_key: &[u8]) -> Result<Vec<u8>, JsError> {
Ok(x25519_public_key_internal(private_key)?.to_vec())
}
/// Internal function to perform X25519 Diffie-Hellman key exchange.
/// Takes 32 bytes each of private and public key material.
/// Returns 32 bytes of shared secret material or CryptoError if key lengths are invalid.
pub(crate) fn x25519_diffie_hellman_internal(
private_key: &[u8],
public_key: &[u8],
) -> Result<[u8; 32], CryptoError> {
let private_bytes: [u8; 32] = private_key
.try_into()
.map_err(|_| CryptoError::InvalidKeyLength(32, private_key.len()))?;
let public_bytes: [u8; 32] = public_key
.try_into()
.map_err(|_| CryptoError::InvalidKeyLength(32, public_key.len()))?;
let secret = StaticSecret::from(private_bytes);
let public = PublicKey::from(public_bytes);
Ok(secret.diffie_hellman(&public).to_bytes())
}
/// WASM-exposed function to perform X25519 Diffie-Hellman key exchange.
/// - `private_key`: 32 bytes of private key material
/// - `public_key`: 32 bytes of public key material
/// Returns 32 bytes of shared secret material or throws JsError if key exchange fails.
#[wasm_bindgen]
pub fn x25519_diffie_hellman(private_key: &[u8], public_key: &[u8]) -> Result<Vec<u8>, JsError> {
Ok(x25519_diffie_hellman_internal(private_key, public_key)?.to_vec())
}
/// Internal function to derive a sealer ID from a sealer secret.
/// Takes a base58-encoded sealer secret with "sealerSecret_z" prefix.
/// Returns a base58-encoded sealer ID with "sealer_z" prefix or error string if format is invalid.
pub fn get_sealer_id_internal(secret: &str) -> Result<String, CryptoError> {
let private_bytes = bs58::decode(secret.strip_prefix("sealerSecret_z").ok_or(
CryptoError::InvalidPrefix("sealerSecret_z", "sealer secret"),
)?)
.into_vec()
.map_err(|e| CryptoError::Base58Error(e.to_string()))?;
let public_bytes = x25519_public_key_internal(&private_bytes)
.map_err(|e| CryptoError::InvalidPublicKey(e.to_string()))?;
Ok(format!(
"sealer_z{}",
bs58::encode(public_bytes).into_string()
))
}
/// WASM-exposed function to derive a sealer ID from a sealer secret.
/// - `secret`: Raw bytes of the sealer secret
/// Returns a base58-encoded sealer ID with "sealer_z" prefix or throws JsError if derivation fails.
#[wasm_bindgen]
pub fn get_sealer_id(secret: &[u8]) -> Result<String, JsError> {
let secret_str = std::str::from_utf8(secret)
.map_err(|e| JsError::new(&format!("Invalid UTF-8 in secret: {:?}", e)))?;
get_sealer_id_internal(secret_str).map_err(|e| JsError::new(&e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_x25519_key_generation() {
// Test that we get the correct length keys
let private_key = new_x25519_private_key();
assert_eq!(private_key.len(), 32);
// Test that public key generation works and produces correct length
let public_key = x25519_public_key_internal(&private_key).unwrap();
assert_eq!(public_key.len(), 32);
// Test that different private keys produce different public keys
let private_key2 = new_x25519_private_key();
let public_key2 = x25519_public_key_internal(&private_key2).unwrap();
assert_ne!(public_key, public_key2);
}
#[test]
fn test_x25519_key_exchange() {
// Generate sender's keypair
let sender_private = new_x25519_private_key();
let sender_public = x25519_public_key_internal(&sender_private).unwrap();
// Generate recipient's keypair
let recipient_private = new_x25519_private_key();
let recipient_public = x25519_public_key_internal(&recipient_private).unwrap();
// Test properties we expect from the shared secret
let shared_secret1 =
x25519_diffie_hellman_internal(&sender_private, &recipient_public).unwrap();
let shared_secret2 =
x25519_diffie_hellman_internal(&recipient_private, &sender_public).unwrap();
// Both sides should arrive at the same shared secret
assert_eq!(shared_secret1, shared_secret2);
// Shared secret should be 32 bytes
assert_eq!(shared_secret1.len(), 32);
// Different recipient should produce different shared secret
let other_recipient_private = new_x25519_private_key();
let other_recipient_public = x25519_public_key_internal(&other_recipient_private).unwrap();
let different_shared_secret =
x25519_diffie_hellman_internal(&sender_private, &other_recipient_public).unwrap();
assert_ne!(shared_secret1, different_shared_secret);
}
#[test]
fn test_get_sealer_id() {
// Create a test private key
let private_key = new_x25519_private_key();
let secret = format!("sealerSecret_z{}", bs58::encode(&private_key).into_string());
// Get sealer ID
let sealer_id = get_sealer_id_internal(&secret).unwrap();
assert!(sealer_id.starts_with("sealer_z"));
// Test that same secret produces same ID
let sealer_id2 = get_sealer_id_internal(&secret).unwrap();
assert_eq!(sealer_id, sealer_id2);
// Test invalid secret format
let result = get_sealer_id_internal("invalid_secret");
assert!(matches!(
result,
Err(CryptoError::InvalidPrefix(
"sealerSecret_z",
"sealer secret"
))
));
// Test invalid base58
let result = get_sealer_id_internal("sealerSecret_z!!!invalid!!!");
assert!(matches!(result, Err(CryptoError::Base58Error(_))));
}
}

View File

@@ -0,0 +1,256 @@
use crate::error::CryptoError;
use crate::hash::blake3::generate_nonce;
use crypto_secretbox::{
aead::{Aead, KeyInit},
XSalsa20Poly1305,
};
use salsa20::cipher::{KeyIvInit, StreamCipher};
use salsa20::XSalsa20;
use wasm_bindgen::prelude::*;
/// WASM-exposed function for XSalsa20 encryption without authentication.
/// - `key`: 32-byte key for encryption
/// - `nonce_material`: Raw bytes used to generate a 24-byte nonce via BLAKE3
/// - `plaintext`: Raw bytes to encrypt
/// Returns the encrypted bytes or throws a JsError if encryption fails.
/// Note: This function does not provide authentication. Use encrypt_xsalsa20_poly1305 for authenticated encryption.
#[wasm_bindgen]
pub fn encrypt_xsalsa20(
key: &[u8],
nonce_material: &[u8],
plaintext: &[u8],
) -> Result<Box<[u8]>, JsError> {
let nonce = generate_nonce(nonce_material);
Ok(encrypt_xsalsa20_raw_internal(key, &nonce, plaintext)?.into())
}
/// WASM-exposed function for XSalsa20 decryption without authentication.
/// - `key`: 32-byte key for decryption (must match encryption key)
/// - `nonce_material`: Raw bytes used to generate a 24-byte nonce (must match encryption)
/// - `ciphertext`: Encrypted bytes to decrypt
/// Returns the decrypted bytes or throws a JsError if decryption fails.
/// Note: This function does not provide authentication. Use decrypt_xsalsa20_poly1305 for authenticated decryption.
#[wasm_bindgen]
pub fn decrypt_xsalsa20(
key: &[u8],
nonce_material: &[u8],
ciphertext: &[u8],
) -> Result<Box<[u8]>, JsError> {
let nonce = generate_nonce(nonce_material);
Ok(decrypt_xsalsa20_raw_internal(key, &nonce, ciphertext)?.into())
}
/// Internal function for raw XSalsa20 encryption without nonce generation.
/// Takes a 32-byte key and 24-byte nonce directly.
/// Returns encrypted bytes or CryptoError if key/nonce lengths are invalid.
pub fn encrypt_xsalsa20_raw_internal(
key: &[u8],
nonce: &[u8],
plaintext: &[u8],
) -> Result<Box<[u8]>, CryptoError> {
// Key must be 32 bytes
let key_bytes: [u8; 32] = key
.try_into()
.map_err(|_| CryptoError::InvalidKeyLength(32, key.len()))?;
// Nonce must be 24 bytes
let nonce_bytes: [u8; 24] = nonce
.try_into()
.map_err(|_| CryptoError::InvalidNonceLength)?;
// Create cipher instance and encrypt
let mut cipher = XSalsa20::new_from_slices(&key_bytes, &nonce_bytes)
.map_err(|_| CryptoError::CipherError)?;
let mut buffer = plaintext.to_vec();
cipher.apply_keystream(&mut buffer);
Ok(buffer.into_boxed_slice())
}
/// Internal function for raw XSalsa20 decryption without nonce generation.
/// Takes a 32-byte key and 24-byte nonce directly.
/// Returns decrypted bytes or CryptoError if key/nonce lengths are invalid.
pub fn decrypt_xsalsa20_raw_internal(
key: &[u8],
nonce: &[u8],
ciphertext: &[u8],
) -> Result<Box<[u8]>, CryptoError> {
// Key must be 32 bytes
let key_bytes: [u8; 32] = key
.try_into()
.map_err(|_| CryptoError::InvalidKeyLength(32, key.len()))?;
// Nonce must be 24 bytes
let nonce_bytes: [u8; 24] = nonce
.try_into()
.map_err(|_| CryptoError::InvalidNonceLength)?;
// Create cipher instance and decrypt (XSalsa20 is symmetric)
let mut cipher = XSalsa20::new_from_slices(&key_bytes, &nonce_bytes)
.map_err(|_| CryptoError::CipherError)?;
let mut buffer = ciphertext.to_vec();
cipher.apply_keystream(&mut buffer);
Ok(buffer.into_boxed_slice())
}
/// XSalsa20-Poly1305 encryption
pub fn encrypt_xsalsa20_poly1305(
key: &[u8],
nonce: &[u8],
plaintext: &[u8],
) -> Result<Box<[u8]>, CryptoError> {
// Key must be 32 bytes
let key_bytes: [u8; 32] = key
.try_into()
.map_err(|_| CryptoError::InvalidKeyLength(32, key.len()))?;
// Nonce must be 24 bytes
let nonce_bytes: [u8; 24] = nonce
.try_into()
.map_err(|_| CryptoError::InvalidNonceLength)?;
// Create cipher instance
let cipher = XSalsa20Poly1305::new(&key_bytes.into());
// Encrypt the plaintext
cipher
.encrypt(&nonce_bytes.into(), plaintext)
.map(|v| v.into_boxed_slice())
.map_err(|_| CryptoError::WrongTag)
}
/// XSalsa20-Poly1305 decryption
pub fn decrypt_xsalsa20_poly1305(
key: &[u8],
nonce: &[u8],
ciphertext: &[u8],
) -> Result<Box<[u8]>, CryptoError> {
// Key must be 32 bytes
let key_bytes: [u8; 32] = key
.try_into()
.map_err(|_| CryptoError::InvalidKeyLength(32, key.len()))?;
// Nonce must be 24 bytes
let nonce_bytes: [u8; 24] = nonce
.try_into()
.map_err(|_| CryptoError::InvalidNonceLength)?;
// Create cipher instance
let cipher = XSalsa20Poly1305::new(&key_bytes.into());
// Decrypt the ciphertext
cipher
.decrypt(&nonce_bytes.into(), ciphertext)
.map(|v| v.into_boxed_slice())
.map_err(|_| CryptoError::WrongTag)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xsalsa20() {
// Test vectors
let key = [0u8; 32]; // All zeros key
let nonce = [0u8; 24]; // All zeros nonce
let plaintext = b"Hello, World!";
// Test encryption
let ciphertext = encrypt_xsalsa20_raw_internal(&key, &nonce, plaintext).unwrap();
assert_ne!(&*ciphertext, plaintext); // Ciphertext should be different from plaintext
// Test decryption
let decrypted = decrypt_xsalsa20_raw_internal(&key, &nonce, &ciphertext).unwrap();
assert_eq!(&*decrypted, plaintext);
// Test that different nonce produces different ciphertext
let nonce2 = [1u8; 24];
let ciphertext2 = encrypt_xsalsa20_raw_internal(&key, &nonce2, plaintext).unwrap();
assert_ne!(ciphertext, ciphertext2);
// Test that different key produces different ciphertext
let key2 = [1u8; 32];
let ciphertext3 = encrypt_xsalsa20_raw_internal(&key2, &nonce, plaintext).unwrap();
assert_ne!(ciphertext, ciphertext3);
// Test invalid key length
assert!(encrypt_xsalsa20_raw_internal(&key[..31], &nonce, plaintext).is_err());
assert!(decrypt_xsalsa20_raw_internal(&key[..31], &nonce, &ciphertext).is_err());
// Test invalid nonce length
assert!(encrypt_xsalsa20_raw_internal(&key, &nonce[..23], plaintext).is_err());
assert!(decrypt_xsalsa20_raw_internal(&key, &nonce[..23], &ciphertext).is_err());
}
#[test]
fn test_xsalsa20_error_handling() {
let key = [0u8; 32];
let nonce = [0u8; 24];
let plaintext = b"test message";
// Test encryption with invalid key length
let invalid_key = vec![0u8; 31]; // Too short
let result = encrypt_xsalsa20_raw_internal(&invalid_key, &nonce, plaintext);
assert!(result.is_err());
// Test with too long key
let too_long_key = vec![0u8; 33]; // Too long
let result = encrypt_xsalsa20_raw_internal(&too_long_key, &nonce, plaintext);
assert!(result.is_err());
// Test decryption with invalid key length
let ciphertext = encrypt_xsalsa20_raw_internal(&key, &nonce, plaintext).unwrap();
let result = decrypt_xsalsa20_raw_internal(&invalid_key, &nonce, &ciphertext);
assert!(result.is_err());
// Test decryption with too long key
let result = decrypt_xsalsa20_raw_internal(&too_long_key, &nonce, &ciphertext);
assert!(result.is_err());
// Test with invalid nonce length
let invalid_nonce = vec![0u8; 23]; // Too short
let result = encrypt_xsalsa20_raw_internal(&key, &invalid_nonce, plaintext);
assert!(result.is_err());
let result = decrypt_xsalsa20_raw_internal(&key, &invalid_nonce, &ciphertext);
assert!(result.is_err());
// Test with too long nonce
let too_long_nonce = vec![0u8; 25]; // Too long
let result = encrypt_xsalsa20_raw_internal(&key, &too_long_nonce, plaintext);
assert!(result.is_err());
let result = decrypt_xsalsa20_raw_internal(&key, &too_long_nonce, &ciphertext);
assert!(result.is_err());
}
#[test]
fn test_xsalsa20_poly1305() {
let key = [0u8; 32]; // All zeros key
let nonce = [0u8; 24]; // All zeros nonce
let plaintext = b"Hello, World!";
// Test encryption
let ciphertext = encrypt_xsalsa20_poly1305(&key, &nonce, plaintext).unwrap();
assert!(ciphertext.len() > plaintext.len()); // Should include authentication tag
// Test decryption
let decrypted = decrypt_xsalsa20_poly1305(&key, &nonce, &ciphertext).unwrap();
assert_eq!(&*decrypted, plaintext);
// Test that different nonce produces different ciphertext
let nonce2 = [1u8; 24];
let ciphertext2 = encrypt_xsalsa20_poly1305(&key, &nonce2, plaintext).unwrap();
assert_ne!(ciphertext, ciphertext2);
// Test that different key produces different ciphertext
let key2 = [1u8; 32];
let ciphertext3 = encrypt_xsalsa20_poly1305(&key2, &nonce, plaintext).unwrap();
assert_ne!(ciphertext, ciphertext3);
// Test that decryption fails with wrong key
assert!(decrypt_xsalsa20_poly1305(&key2, &nonce, &ciphertext).is_err());
// Test that decryption fails with wrong nonce
assert!(decrypt_xsalsa20_poly1305(&key, &nonce2, &ciphertext).is_err());
// Test that decryption fails with tampered ciphertext
let mut tampered = ciphertext.clone();
tampered[0] ^= 1;
assert!(decrypt_xsalsa20_poly1305(&key, &nonce, &tampered).is_err());
}
}

View File

@@ -0,0 +1,43 @@
use std::fmt;
#[derive(Debug)]
pub enum CryptoError {
InvalidKeyLength(usize, usize),
InvalidNonceLength,
InvalidSealerSecretFormat,
InvalidSignatureLength,
InvalidVerifyingKey(String),
InvalidPublicKey(String),
WrongTag,
CipherError,
InvalidPrefix(&'static str, &'static str),
Base58Error(String),
}
impl fmt::Display for CryptoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CryptoError::InvalidKeyLength(expected, actual) => {
write!(f, "Invalid key length (expected {expected}, got {actual})")
}
CryptoError::InvalidNonceLength => write!(f, "Invalid nonce length"),
CryptoError::InvalidSealerSecretFormat => {
write!(
f,
"Invalid sealer secret format: must start with 'sealerSecret_z'"
)
}
CryptoError::InvalidSignatureLength => write!(f, "Invalid signature length"),
CryptoError::InvalidVerifyingKey(e) => write!(f, "Invalid verifying key: {}", e),
CryptoError::InvalidPublicKey(e) => write!(f, "Invalid public key: {}", e),
CryptoError::WrongTag => write!(f, "Wrong tag"),
CryptoError::CipherError => write!(f, "Failed to create cipher"),
CryptoError::InvalidPrefix(prefix, field) => {
write!(f, "Invalid {} format: must start with '{}'", field, prefix)
}
CryptoError::Base58Error(e) => write!(f, "Invalid base58: {}", e),
}
}
}
impl std::error::Error for CryptoError {}

View File

@@ -0,0 +1,218 @@
use wasm_bindgen::prelude::*;
/// Generate a 24-byte nonce from input material using BLAKE3.
/// - `nonce_material`: Raw bytes to derive the nonce from
/// Returns 24 bytes suitable for use as a nonce in cryptographic operations.
/// This function is deterministic - the same input will produce the same nonce.
#[wasm_bindgen]
pub fn generate_nonce(nonce_material: &[u8]) -> Box<[u8]> {
let mut hasher = blake3::Hasher::new();
hasher.update(nonce_material);
hasher.finalize().as_bytes()[..24].into()
}
/// Hash data once using BLAKE3.
/// - `data`: Raw bytes to hash
/// Returns 32 bytes of hash output.
/// This is the simplest way to compute a BLAKE3 hash of a single piece of data.
#[wasm_bindgen]
pub fn blake3_hash_once(data: &[u8]) -> Box<[u8]> {
let mut hasher = blake3::Hasher::new();
hasher.update(data);
hasher.finalize().as_bytes().to_vec().into_boxed_slice()
}
/// Hash data once using BLAKE3 with a context prefix.
/// - `data`: Raw bytes to hash
/// - `context`: Context bytes to prefix to the data
/// Returns 32 bytes of hash output.
/// This is useful for domain separation - the same data hashed with different contexts will produce different outputs.
#[wasm_bindgen]
pub fn blake3_hash_once_with_context(data: &[u8], context: &[u8]) -> Box<[u8]> {
let mut hasher = blake3::Hasher::new();
hasher.update(context);
hasher.update(data);
hasher.finalize().as_bytes().to_vec().into_boxed_slice()
}
#[wasm_bindgen]
pub struct Blake3Hasher(blake3::Hasher);
#[wasm_bindgen]
impl Blake3Hasher {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
Blake3Hasher(blake3::Hasher::new())
}
pub fn update(&mut self, data: &[u8]) {
self.0.update(data);
}
pub fn finalize(&self) -> Box<[u8]> {
self.0.finalize().as_bytes().to_vec().into_boxed_slice()
}
pub fn clone(&self) -> Self {
// The blake3::Hasher type implements Clone
Blake3Hasher(self.0.clone())
}
}
/// Get an empty BLAKE3 state for incremental hashing.
/// Returns a new Blake3Hasher instance for incremental hashing.
#[wasm_bindgen]
pub fn blake3_empty_state() -> Blake3Hasher {
Blake3Hasher::new()
}
/// Update a BLAKE3 state with new data for incremental hashing.
/// - `state`: Current Blake3Hasher instance
/// - `data`: New data to incorporate into the hash
/// Returns the updated Blake3Hasher.
#[wasm_bindgen]
pub fn blake3_update_state(state: &mut Blake3Hasher, data: &[u8]) {
state.update(data);
}
/// Get the final hash from a BLAKE3 state.
/// - `state`: The Blake3Hasher to finalize
/// Returns 32 bytes of hash output.
/// This finalizes an incremental hashing operation.
#[wasm_bindgen]
pub fn blake3_digest_for_state(state: Blake3Hasher) -> Box<[u8]> {
state.finalize()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nonce_generation() {
let input = b"test input";
let nonce = generate_nonce(input);
assert_eq!(nonce.len(), 24);
// Same input should produce same nonce
let nonce2 = generate_nonce(input);
assert_eq!(nonce, nonce2);
// Different input should produce different nonce
let nonce3 = generate_nonce(b"different input");
assert_ne!(nonce, nonce3);
}
#[test]
fn test_blake3_hash_once() {
let input = b"test input";
let hash = blake3_hash_once(input);
// BLAKE3 produces 32-byte hashes
assert_eq!(hash.len(), 32);
// Same input should produce same hash
let hash2 = blake3_hash_once(input);
assert_eq!(hash, hash2);
// Different input should produce different hash
let hash3 = blake3_hash_once(b"different input");
assert_ne!(hash, hash3);
}
#[test]
fn test_blake3_hash_once_with_context() {
let input = b"test input";
let context = b"test context";
let hash = blake3_hash_once_with_context(input, context);
// BLAKE3 produces 32-byte hashes
assert_eq!(hash.len(), 32);
// Same input and context should produce same hash
let hash2 = blake3_hash_once_with_context(input, context);
assert_eq!(hash, hash2);
// Different input should produce different hash
let hash3 = blake3_hash_once_with_context(b"different input", context);
assert_ne!(hash, hash3);
// Different context should produce different hash
let hash4 = blake3_hash_once_with_context(input, b"different context");
assert_ne!(hash, hash4);
// Hash with context should be different from hash without context
let hash_no_context = blake3_hash_once(input);
assert_ne!(hash, hash_no_context);
}
#[test]
fn test_blake3_incremental() {
// Initial state
let mut state = blake3_empty_state();
// First update with [1,2,3,4,5]
let data1 = &[1u8, 2, 3, 4, 5];
blake3_update_state(&mut state, data1);
// Check that this matches a direct hash
let direct_hash = blake3_hash_once(data1);
let state_hash = state.finalize();
assert_eq!(
state_hash, direct_hash,
"First update should match direct hash"
);
// Create new state for second test
let mut state = blake3_empty_state();
blake3_update_state(&mut state, data1);
// Verify the exact expected hash from the TypeScript test for the first update
let expected_first_hash = [
2, 79, 103, 192, 66, 90, 61, 192, 47, 186, 245, 140, 185, 61, 229, 19, 46, 61, 117,
197, 25, 250, 160, 186, 218, 33, 73, 29, 136, 201, 112, 87,
]
.to_vec()
.into_boxed_slice();
assert_eq!(
state.finalize(),
expected_first_hash,
"First update should match expected hash"
);
// Test with two updates
let mut state = blake3_empty_state();
let data1 = &[1u8, 2, 3, 4, 5];
let data2 = &[6u8, 7, 8, 9, 10];
blake3_update_state(&mut state, data1);
blake3_update_state(&mut state, data2);
// Compare with a single hash of all data
let mut all_data = Vec::new();
all_data.extend_from_slice(data1);
all_data.extend_from_slice(data2);
let direct_hash_all = blake3_hash_once(&all_data);
assert_eq!(
state.finalize(),
direct_hash_all,
"Final state should match direct hash of all data"
);
// Test final hash matches expected value
let mut state = blake3_empty_state();
blake3_update_state(&mut state, data1);
blake3_update_state(&mut state, data2);
let expected_final_hash = [
165, 131, 141, 69, 2, 69, 39, 236, 196, 244, 180, 213, 147, 124, 222, 39, 68, 223, 54,
176, 242, 97, 200, 101, 204, 79, 21, 233, 56, 51, 1, 199,
]
.to_vec()
.into_boxed_slice();
assert_eq!(
state.finalize(),
expected_final_hash,
"Final state should match expected hash"
);
}
}

View File

@@ -0,0 +1,165 @@
use cojson_core::{
CoID, CoJsonCoreError, KeyID, KeySecret, SessionID, SessionLogInternal, Signature, SignerID, SignerSecret, TransactionMode
};
use serde_json::value::RawValue;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use wasm_bindgen::prelude::*;
mod error;
pub use error::CryptoError;
pub mod hash {
pub mod blake3;
pub use blake3::*;
}
pub mod crypto {
pub mod ed25519;
pub mod encrypt;
pub mod seal;
pub mod sign;
pub mod x25519;
pub mod xsalsa20;
pub use ed25519::*;
pub use encrypt::*;
pub use seal::*;
pub use sign::*;
pub use x25519::*;
pub use xsalsa20::*;
}
#[derive(Error, Debug)]
pub enum CojsonCoreWasmError {
#[error(transparent)]
CoJson(#[from] CoJsonCoreError),
#[error(transparent)]
Serde(#[from] serde_json::Error),
#[error(transparent)]
SerdeWasmBindgen(#[from] serde_wasm_bindgen::Error),
#[error("JsValue Error: {0:?}")]
Js(JsValue),
}
impl From<CojsonCoreWasmError> for JsValue {
fn from(err: CojsonCoreWasmError) -> Self {
JsValue::from_str(&err.to_string())
}
}
#[wasm_bindgen]
#[derive(Clone)]
pub struct SessionLog {
internal: SessionLogInternal,
}
#[derive(Serialize, Deserialize)]
struct PrivateTransactionResult {
signature: String,
encrypted_changes: String,
}
#[wasm_bindgen]
impl SessionLog {
#[wasm_bindgen(constructor)]
pub fn new(co_id: String, session_id: String, signer_id: Option<String>) -> SessionLog {
let co_id = CoID(co_id);
let session_id = SessionID(session_id);
let signer_id = signer_id.map(|id| SignerID(id));
let internal = SessionLogInternal::new(co_id, session_id, signer_id);
SessionLog { internal }
}
#[wasm_bindgen(js_name = clone)]
pub fn clone_js(&self) -> SessionLog {
self.clone()
}
#[wasm_bindgen(js_name = tryAdd)]
pub fn try_add(
&mut self,
transactions_json: Vec<String>,
new_signature_str: String,
skip_verify: bool,
) -> Result<(), CojsonCoreWasmError> {
let transactions: Vec<Box<RawValue>> = transactions_json
.into_iter()
.map(|s| {
serde_json::from_str(&s).map_err(|e| {
CojsonCoreWasmError::Js(JsValue::from(format!(
"Failed to parse transaction string: {}",
e
)))
})
})
.collect::<Result<Vec<_>, _>>()?;
let new_signature = Signature(new_signature_str);
self.internal
.try_add(transactions, &new_signature, skip_verify)?;
Ok(())
}
#[wasm_bindgen(js_name = addNewPrivateTransaction)]
pub fn add_new_private_transaction(
&mut self,
changes_json: &str,
signer_secret: String,
encryption_key: String,
key_id: String,
made_at: f64,
) -> Result<String, CojsonCoreWasmError> {
let (signature, transaction) = self.internal.add_new_transaction(
changes_json,
TransactionMode::Private{key_id: KeyID(key_id), key_secret: KeySecret(encryption_key)},
&SignerSecret(signer_secret),
made_at as u64,
);
// Extract encrypted_changes from the private transaction
let encrypted_changes = match transaction {
cojson_core::Transaction::Private(private_tx) => private_tx.encrypted_changes.value,
_ => return Err(CojsonCoreWasmError::Js(JsValue::from_str("Expected private transaction"))),
};
let result = PrivateTransactionResult{
signature: signature.0,
encrypted_changes,
};
Ok(serde_json::to_string(&result)?)
}
#[wasm_bindgen(js_name = addNewTrustingTransaction)]
pub fn add_new_trusting_transaction(
&mut self,
changes_json: &str,
signer_secret: String,
made_at: f64,
) -> Result<String, CojsonCoreWasmError> {
let (signature, _) = self.internal.add_new_transaction(
changes_json,
TransactionMode::Trusting,
&SignerSecret(signer_secret),
made_at as u64,
);
Ok(signature.0)
}
#[wasm_bindgen(js_name = decryptNextTransactionChangesJson)]
pub fn decrypt_next_transaction_changes_json(
&self,
tx_index: u32,
encryption_key: String,
) -> Result<String, CojsonCoreWasmError> {
Ok(self
.internal
.decrypt_next_transaction_changes_json(tx_index, KeySecret(encryption_key))?)
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "cojson-core"
version = "0.1.0"
edition = "2021"
[dependencies]
lzy = { path = "../lzy", optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["raw_value"] }
ed25519-dalek = { version = "2.2.0", features = ["rand_core"] }
bs58 = "0.5.1"
blake3 = "1.5.1"
salsa20 = "0.10.2"
base64 = "0.22.1"
thiserror = "1.0"
[dev-dependencies]
rand_core = { version = "0.6", features = ["getrandom"] }

View File

@@ -0,0 +1,8 @@
{
"coID": "co_zUsz4gkwCCWqMXa4LHXdwyAkVK3",
"signerID":"signer_z3FdM2ucYXUkbJQgPRf8R4Di6exd2sNPVaHaJHhQ8WAqi",
"knownKeys":[],
"exampleBase": {
"co_zkNajJ1BhLzR962jpzvXxx917ZB_session_zXzrQLTtp8rR":{"transactions":[{"changes":"[{\"key\":\"co_zkNajJ1BhLzR962jpzvXxx917ZB\",\"op\":\"set\",\"value\":\"admin\"}]","madeAt":1750685354142,"privacy":"trusting"},{"changes":"[{\"key\":\"key_z268nqpkZYFFWPoGzL_for_co_zkNajJ1BhLzR962jpzvXxx917ZB\",\"op\":\"set\",\"value\":\"sealed_UmZaEEzCUrP3Q-t2KrN00keV66wzA4LWadqhEmw0jlku5frSW2QyXUY3zYIC_XLig6BDS9rcZZdTm3CwnLjTPzp9hgd9TlJLf_Q==\"}]","madeAt":1750685354142,"privacy":"trusting"},{"changes":"[{\"key\":\"readKey\",\"op\":\"set\",\"value\":\"key_z268nqpkZYFFWPoGzL\"}]","madeAt":1750685354143,"privacy":"trusting"},{"changes":"[{\"key\":\"everyone\",\"op\":\"set\",\"value\":\"writer\"}]","madeAt":1750685354143,"privacy":"trusting"},{"changes":"[{\"key\":\"key_z268nqpkZYFFWPoGzL_for_everyone\",\"op\":\"set\",\"value\":\"keySecret_zHRFDaEsnpYSZh6rUAvXS8uUrKCxJAzeBPSSaVU1r9RZY\"}]","madeAt":1750685354143,"privacy":"trusting"}],"lastHash":"hash_z5j1DUZjBiTKm5XnLi8ZrNPV3P7zGuXnMNCZfh2qGXGC7","streamingHash":{"state":{"__wbg_ptr":1127736},"crypto":{}},"lastSignature":"signature_z4LoRVDLnJBfAzHvRn3avgK4RVBd7iAfqUMJdpDEtV8HGLKGAqLyweBkNp8jggcNUQZatrMeU9tdc31ct9qxw7rib","signatureAfter":{}}
}
}

View File

@@ -0,0 +1,6 @@
{
"coID": "co_zWnX74VrMP3n3dkm9wZVPszfiCw",
"signerID":"signer_z3FdM2ucYXUkbJQgPRf8R4Di6exd2sNPVaHaJHhQ8WAqi",
"knownKeys":[{"secret":"keySecret_zHRFDaEsnpYSZh6rUAvXS8uUrKCxJAzeBPSSaVU1r9RZY","id":"key_z268nqpkZYFFWPoGzL"}],
"exampleBase":{"co_zkNajJ1BhLzR962jpzvXxx917ZB_session_zXzrQLTtp8rR":{"transactions":[{"encryptedChanges":"encrypted_UxN_r7X7p-3GUE3GRGRO4NfIhEUvB01m-HaSSipRRrUsTmNBW9dZ-pkAk-NoVP_iEB0moLFbG9GDq9U9S-rUDfSPcaWCJtpE=","keyUsed":"key_z268nqpkZYFFWPoGzL","madeAt":1750685368555,"privacy":"private"}],"lastHash":"hash_zJCdoTRgDuFdUK2XogR7qgNnxezfYAVih3qve2UV65L5X","streamingHash":{"state":{"__wbg_ptr":1129680},"crypto":{}},"lastSignature":"signature_z3UErpugJAqDEYKgzUhs88xBMohzmaL228PgkNhEomf6AeVr7NYNxY17iUoCmPQTpGJNqYPo3y82mGX4oWBhkqN4y","signatureAfter":{}}}
}

View File

@@ -0,0 +1,689 @@
use base64::{engine::general_purpose::URL_SAFE, Engine as _};
use bs58;
use ed25519_dalek::{Signature as Ed25519Signature, Signer, SigningKey, Verifier, VerifyingKey};
use salsa20::{
cipher::{KeyIvInit, StreamCipher},
XSalsa20,
};
use serde::{Deserialize, Serialize};
use serde_json::{value::RawValue, Number, Value as JsonValue};
use thiserror::Error;
// Re-export lzy for convenience
#[cfg(feature = "lzy")]
pub use lzy;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SessionID(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SignerID(pub String);
impl From<VerifyingKey> for SignerID {
fn from(key: VerifyingKey) -> Self {
SignerID(format!(
"signer_z{}",
bs58::encode(key.to_bytes()).into_string()
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SignerSecret(pub String);
impl From<SigningKey> for SignerSecret {
fn from(key: SigningKey) -> Self {
SignerSecret(format!(
"signerSecret_z{}",
bs58::encode(key.to_bytes()).into_string()
))
}
}
impl Into<SigningKey> for &SignerSecret {
fn into(self) -> SigningKey {
let key_bytes = decode_z(&self.0).expect("Invalid key secret");
SigningKey::from_bytes(&key_bytes.try_into().expect("Invalid key secret length"))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Signature(pub String);
impl From<Ed25519Signature> for Signature {
fn from(signature: Ed25519Signature) -> Self {
Signature(format!(
"signature_z{}",
bs58::encode(signature.to_bytes()).into_string()
))
}
}
impl Into<Ed25519Signature> for &Signature {
fn into(self) -> Ed25519Signature {
let signature_bytes = decode_z(&self.0).expect("Invalid signature");
Ed25519Signature::from_bytes(
&signature_bytes
.try_into()
.expect("Invalid signature length"),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Hash(pub String);
impl From<blake3::Hash> for Hash {
fn from(hash: blake3::Hash) -> Self {
Hash(format!("hash_z{}", bs58::encode(hash.as_bytes()).into_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct KeyID(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct KeySecret(pub String);
impl Into<[u8; 32]> for &KeySecret {
fn into(self) -> [u8; 32] {
let key_bytes = decode_z(&self.0).expect("Invalid key secret");
key_bytes.try_into().expect("Invalid key secret length")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CoID(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransactionID {
#[serde(rename = "sessionID")]
pub session_id: SessionID,
#[serde(rename = "txIndex")]
pub tx_index: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Encrypted<T> {
pub value: String,
_phantom: std::marker::PhantomData<T>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrivateTransaction {
#[serde(rename = "encryptedChanges")]
pub encrypted_changes: Encrypted<JsonValue>,
#[serde(rename = "keyUsed")]
pub key_used: KeyID,
#[serde(rename = "madeAt")]
pub made_at: Number,
pub privacy: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustingTransaction {
pub changes: String,
#[serde(rename = "madeAt")]
pub made_at: Number,
pub privacy: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Transaction {
Private(PrivateTransaction),
Trusting(TrustingTransaction),
}
pub enum TransactionMode {
Private {
key_id: KeyID,
key_secret: KeySecret,
},
Trusting,
}
#[derive(Error, Debug)]
pub enum CoJsonCoreError {
#[error("Transaction not found at index {0}")]
TransactionNotFound(u32),
#[error("Invalid encrypted prefix in transaction")]
InvalidEncryptedPrefix,
#[error("Base64 decoding failed")]
Base64Decode(#[from] base64::DecodeError),
#[error("UTF-8 conversion failed")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("JSON deserialization failed")]
Json(#[from] serde_json::Error),
#[error("Signature verification failed: (hash: {0})")]
SignatureVerification(String),
}
#[derive(Clone)]
pub struct SessionLogInternal {
co_id: CoID,
session_id: SessionID,
public_key: Option<VerifyingKey>,
hasher: blake3::Hasher,
transactions_json: Vec<String>,
last_signature: Option<Signature>,
}
impl SessionLogInternal {
pub fn new(co_id: CoID, session_id: SessionID, signer_id: Option<SignerID>) -> Self {
let hasher = blake3::Hasher::new();
let public_key = match signer_id {
Some(signer_id) => Some(VerifyingKey::try_from(
decode_z(&signer_id.0)
.expect("Invalid public key")
.as_slice(),
)
.expect("Invalid public key")),
None => None,
};
Self {
co_id,
session_id,
public_key,
hasher,
transactions_json: Vec::new(),
last_signature: None,
}
}
pub fn transactions_json(&self) -> &Vec<String> {
&self.transactions_json
}
pub fn last_signature(&self) -> Option<&Signature> {
self.last_signature.as_ref()
}
fn expected_hash_after(&self, transactions: &[Box<RawValue>]) -> blake3::Hasher {
let mut hasher = self.hasher.clone();
for tx in transactions {
hasher.update(tx.get().as_bytes());
}
hasher
}
pub fn try_add(
&mut self,
transactions: Vec<Box<RawValue>>,
new_signature: &Signature,
skip_verify: bool,
) -> Result<(), CoJsonCoreError> {
if !skip_verify {
let hasher = self.expected_hash_after(&transactions);
let new_hash_encoded_stringified = format!(
"\"hash_z{}\"",
bs58::encode(hasher.finalize().as_bytes()).into_string()
);
if let Some(public_key) = self.public_key {
match public_key.verify(
new_hash_encoded_stringified.as_bytes(),
&(new_signature).into(),
) {
Ok(()) => {}
Err(_) => {
return Err(CoJsonCoreError::SignatureVerification(
new_hash_encoded_stringified.replace("\"", ""),
));
}
}
} else {
return Err(CoJsonCoreError::SignatureVerification(
new_hash_encoded_stringified.replace("\"", ""),
));
}
self.hasher = hasher;
}
for tx in transactions {
self.transactions_json.push(tx.get().to_string());
}
self.last_signature = Some(new_signature.clone());
Ok(())
}
pub fn add_new_transaction(
&mut self,
changes_json: &str,
mode: TransactionMode,
signer_secret: &SignerSecret,
made_at: u64,
) -> (Signature, Transaction) {
let new_tx = match mode {
TransactionMode::Private { key_id, key_secret } => {
let tx_index = self.transactions_json.len() as u32;
let nonce_material = JsonValue::Object(serde_json::Map::from_iter(vec![
("in".to_string(), JsonValue::String(self.co_id.0.clone())),
(
"tx".to_string(),
serde_json::to_value(TransactionID {
session_id: self.session_id.clone(),
tx_index,
})
.unwrap(),
),
]));
let nonce = self.generate_json_nonce(&nonce_material);
let secret_key_bytes: [u8; 32] = (&key_secret).into();
let mut ciphertext = changes_json.as_bytes().to_vec();
let mut cipher = XSalsa20::new(&secret_key_bytes.into(), &nonce.into());
cipher.apply_keystream(&mut ciphertext);
let encrypted_str = format!("encrypted_U{}", URL_SAFE.encode(&ciphertext));
Transaction::Private(PrivateTransaction {
encrypted_changes: Encrypted {
value: encrypted_str,
_phantom: std::marker::PhantomData,
},
key_used: key_id.clone(),
made_at: Number::from(made_at),
privacy: "private".to_string(),
})
}
TransactionMode::Trusting => Transaction::Trusting(TrustingTransaction {
changes: changes_json.to_string(),
made_at: Number::from(made_at),
privacy: "trusting".to_string(),
}),
};
let tx_json = serde_json::to_string(&new_tx).unwrap();
self.hasher.update(tx_json.as_bytes());
self.transactions_json.push(tx_json);
let new_hash = self.hasher.finalize();
let new_hash_encoded_stringified = format!("\"hash_z{}\"", bs58::encode(new_hash.as_bytes()).into_string());
let signing_key: SigningKey = signer_secret.into();
let new_signature: Signature = signing_key.sign(new_hash_encoded_stringified.as_bytes()).into();
self.last_signature = Some(new_signature.clone());
(new_signature, new_tx)
}
pub fn decrypt_next_transaction_changes_json(
&self,
tx_index: u32,
key_secret: KeySecret,
) -> Result<String, CoJsonCoreError> {
let tx_json = self
.transactions_json
.get(tx_index as usize)
.ok_or(CoJsonCoreError::TransactionNotFound(tx_index))?;
let tx: Transaction = serde_json::from_str(tx_json)?;
match tx {
Transaction::Private(private_tx) => {
let nonce_material = JsonValue::Object(serde_json::Map::from_iter(vec![
("in".to_string(), JsonValue::String(self.co_id.0.clone())),
(
"tx".to_string(),
serde_json::to_value(TransactionID {
session_id: self.session_id.clone(),
tx_index,
})?,
),
]));
let nonce = self.generate_json_nonce(&nonce_material);
let encrypted_val = private_tx.encrypted_changes.value;
let prefix = "encrypted_U";
if !encrypted_val.starts_with(prefix) {
return Err(CoJsonCoreError::InvalidEncryptedPrefix);
}
let ciphertext_b64 = &encrypted_val[prefix.len()..];
let mut ciphertext = URL_SAFE.decode(ciphertext_b64)?;
let secret_key_bytes: [u8; 32] = (&key_secret).into();
let mut cipher = XSalsa20::new((&secret_key_bytes).into(), &nonce.into());
cipher.apply_keystream(&mut ciphertext);
Ok(String::from_utf8(ciphertext)?)
}
Transaction::Trusting(trusting_tx) => Ok(trusting_tx.changes),
}
}
fn generate_nonce(&self, material: &[u8]) -> [u8; 24] {
let mut hasher = blake3::Hasher::new();
hasher.update(material);
let mut output = [0u8; 24];
let mut output_reader = hasher.finalize_xof();
output_reader.fill(&mut output);
output
}
fn generate_json_nonce(&self, material: &JsonValue) -> [u8; 24] {
let stable_json = serde_json::to_string(&material).unwrap();
self.generate_nonce(stable_json.as_bytes())
}
}
pub fn decode_z(value: &str) -> Result<Vec<u8>, String> {
let prefix_end = value.find("_z").ok_or("Invalid prefix")? + 2;
bs58::decode(&value[prefix_end..])
.into_vec()
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use rand_core::OsRng;
use std::{collections::HashMap, fs};
#[test]
fn it_works() {
let mut csprng = OsRng;
let signing_key = SigningKey::generate(&mut csprng);
let verifying_key = signing_key.verifying_key();
let session = SessionLogInternal::new(
CoID("co_test1".to_string()),
SessionID("session_test1".to_string()),
verifying_key.into(),
);
assert!(session.last_signature.is_none());
}
#[test]
fn test_add_from_example_json() {
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct TestSession<'a> {
last_signature: Signature,
#[serde(borrow)]
transactions: Vec<&'a RawValue>,
last_hash: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct Root<'a> {
#[serde(borrow)]
example_base: HashMap<String, TestSession<'a>>,
#[serde(rename = "signerID")]
signer_id: SignerID,
}
let data = fs::read_to_string("data/singleTxSession.json")
.expect("Unable to read singleTxSession.json");
let root: Root = serde_json::from_str(&data).unwrap();
let (session_id_str, example) = root.example_base.into_iter().next().unwrap();
let session_id = SessionID(session_id_str.clone());
let co_id = CoID(
session_id_str
.split("_session_")
.next()
.unwrap()
.to_string(),
);
let mut session = SessionLogInternal::new(co_id, session_id, root.signer_id);
let new_signature = example.last_signature;
let result = session.try_add(
vec![example.transactions[0].to_owned()],
&new_signature,
false,
);
match result {
Ok(returned_final_hash) => {
let final_hash = session.hasher.finalize();
let final_hash_encoded = format!(
"hash_z{}",
bs58::encode(final_hash.as_bytes()).into_string()
);
assert_eq!(final_hash_encoded, example.last_hash);
assert_eq!(session.last_signature, Some(new_signature));
}
Err(CoJsonCoreError::SignatureVerification(new_hash_encoded)) => {
assert_eq!(new_hash_encoded, example.last_hash);
panic!("Signature verification failed despite same hash");
}
Err(e) => {
panic!("Unexpected error: {:?}", e);
}
}
}
#[test]
fn test_add_from_example_json_multi_tx() {
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct TestSession<'a> {
last_signature: Signature,
#[serde(borrow)]
transactions: Vec<&'a RawValue>,
last_hash: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct Root<'a> {
#[serde(borrow)]
example_base: HashMap<String, TestSession<'a>>,
#[serde(rename = "signerID")]
signer_id: SignerID,
}
let data = fs::read_to_string("data/multiTxSession.json")
.expect("Unable to read multiTxSession.json");
let root: Root = serde_json::from_str(&data).unwrap();
let (session_id_str, example) = root.example_base.into_iter().next().unwrap();
let session_id = SessionID(session_id_str.clone());
let co_id = CoID(
session_id_str
.split("_session_")
.next()
.unwrap()
.to_string(),
);
let mut session = SessionLogInternal::new(co_id, session_id, root.signer_id);
let new_signature = example.last_signature;
let result = session.try_add(
example.transactions.into_iter().map(|tx| tx.to_owned()).collect(),
&new_signature,
false,
);
match result {
Ok(returned_final_hash) => {
let final_hash = session.hasher.finalize();
let final_hash_encoded = format!(
"hash_z{}",
bs58::encode(final_hash.as_bytes()).into_string()
);
assert_eq!(final_hash_encoded, example.last_hash);
assert_eq!(session.last_signature, Some(new_signature));
}
Err(CoJsonCoreError::SignatureVerification(new_hash_encoded)) => {
assert_eq!(new_hash_encoded, example.last_hash);
panic!("Signature verification failed despite same hash");
}
Err(e) => {
panic!("Unexpected error: {:?}", e);
}
}
}
#[test]
fn test_add_new_transaction() {
// Load the example data to get all the pieces we need
let data = fs::read_to_string("data/singleTxSession.json")
.expect("Unable to read singleTxSession.json");
let root: serde_json::Value = serde_json::from_str(&data).unwrap();
let session_data =
&root["exampleBase"]["co_zkNajJ1BhLzR962jpzvXxx917ZB_session_zXzrQLTtp8rR"];
let tx_from_example = &session_data["transactions"][0];
let known_key = &root["knownKeys"][0];
// Since we don't have the original private key, we generate a new one for this test.
let mut csprng = OsRng;
let signing_key = SigningKey::generate(&mut csprng);
let public_key = signing_key.verifying_key();
// Initialize an empty session
let mut session = SessionLogInternal::new(
CoID(root["coID"].as_str().unwrap().to_string()),
SessionID("co_zkNajJ1BhLzR962jpzvXxx917ZB_session_zXzrQLTtp8rR".to_string()),
public_key.into(),
);
// The plaintext changes we want to add
let changes_json =
r#"[{"after":"start","op":"app","value":"co_zMphsnYN6GU8nn2HDY5suvyGufY"}]"#;
// Extract all the necessary components from the example data
let key_secret = KeySecret(known_key["secret"].as_str().unwrap().to_string());
let key_id = KeyID(known_key["id"].as_str().unwrap().to_string());
let made_at = tx_from_example["madeAt"].as_u64().unwrap();
// Call the function we are testing
let (new_signature, _new_tx) = session.add_new_transaction(
changes_json,
TransactionMode::Private {
key_id: key_id,
key_secret: key_secret,
},
&signing_key.into(),
made_at,
);
// 1. Check that the transaction we created matches the one in the file
let created_tx_json = &session.transactions_json[0];
let expected_tx_json = serde_json::to_string(tx_from_example).unwrap();
assert_eq!(created_tx_json, &expected_tx_json);
// 2. Check that the final hash of the session matches the one in the file
let final_hash = session.hasher.finalize();
let final_hash_encoded = format!(
"hash_z{}",
bs58::encode(final_hash.as_bytes()).into_string()
);
assert_eq!(
final_hash_encoded,
session_data["lastHash"].as_str().unwrap()
);
let final_hash_encoded_stringified = format!(
"\"{}\"",
final_hash_encoded
);
// 3. Check that the signature is valid for our generated key
assert!(session
.public_key
.verify(final_hash_encoded_stringified.as_bytes(), &(&new_signature).into())
.is_ok());
assert_eq!(session.last_signature, Some(new_signature));
}
#[test]
fn test_decrypt_from_example_json() {
#[derive(Deserialize, Debug)]
struct KnownKey {
secret: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
#[serde(bound(deserialize = "'de: 'a"))]
struct TestSession<'a> {
last_signature: String,
#[serde(borrow)]
transactions: Vec<&'a RawValue>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
#[serde(bound(deserialize = "'de: 'a"))]
struct Root<'a> {
#[serde(borrow)]
example_base: HashMap<String, TestSession<'a>>,
#[serde(rename = "signerID")]
signer_id: SignerID,
known_keys: Vec<KnownKey>,
#[serde(rename = "coID")]
co_id: CoID,
}
let data = fs::read_to_string("data/singleTxSession.json")
.expect("Unable to read singleTxSession.json");
let root: Root = serde_json::from_str(&data).unwrap();
let (session_id_str, example) = root.example_base.into_iter().next().unwrap();
let session_id = SessionID(session_id_str.clone());
let public_key =
VerifyingKey::from_bytes(&decode_z(&root.signer_id.0).unwrap().try_into().unwrap())
.unwrap();
let mut session = SessionLogInternal::new(root.co_id, session_id, public_key.into());
let new_signature = Signature(example.last_signature);
session
.try_add(
example
.transactions
.into_iter()
.map(|v| v.to_owned())
.collect(),
&new_signature,
true, // Skipping verification because we don't have the right initial state
)
.unwrap();
let key_secret = KeySecret(root.known_keys[0].secret.clone());
let decrypted = session
.decrypt_next_transaction_changes_json(0, key_secret)
.unwrap();
assert_eq!(
decrypted,
r#"[{"after":"start","op":"app","value":"co_zMphsnYN6GU8nn2HDY5suvyGufY"}]"#
);
}
}

15
crates/lzy/Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "lzy"
version = "0.1.0"
edition = "2021"
[dependencies]
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[[bench]]
name = "compression_benchmark"
harness = false

View File

@@ -0,0 +1,36 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput};
use lzy::{compress, decompress};
use std::fs;
use std::time::Duration;
fn compression_benchmark(c: &mut Criterion) {
let data = fs::read("data/compression_66k_JSON.txt").expect("Failed to read benchmark data");
let mut group = c.benchmark_group("LZY Compression");
group.measurement_time(Duration::from_secs(10));
group.sample_size(10);
group.throughput(Throughput::Bytes(data.len() as u64));
let compressed = compress(&data);
let compression_ratio = compressed.len() as f64 / data.len() as f64;
println!(
"Compression ratio (compressed/original): {:.4} ({} / {} bytes)",
compression_ratio,
compressed.len(),
data.len()
);
group.bench_function("compress", |b| {
b.iter(|| compress(black_box(&data)))
});
let decompressed = decompress(&compressed).unwrap();
assert_eq!(data, decompressed);
group.bench_function("decompress", |b| {
b.iter(|| decompress(black_box(&compressed)))
});
}
criterion_group!(benches, compression_benchmark);
criterion_main!(benches);

File diff suppressed because one or more lines are too long

348
crates/lzy/src/lib.rs Normal file
View File

@@ -0,0 +1,348 @@
const MIN_MATCH_LEN: usize = 4;
const MAX_MATCH_LEN: usize = 15 + 3;
const MAX_LITERALS: usize = 15;
const HASH_LOG: u32 = 16;
const HASH_TABLE_SIZE: usize = 1 << HASH_LOG;
fn hash(data: &[u8]) -> usize {
const KNUTH_MULT_PRIME: u32 = 2654435761;
let val = u32::from_le_bytes(data.try_into().unwrap());
((val.wrapping_mul(KNUTH_MULT_PRIME)) >> (32 - HASH_LOG)) as usize
}
#[derive(Debug, PartialEq)]
pub enum DecompressionError {
InvalidToken,
UnexpectedEof,
}
pub fn decompress(input: &[u8]) -> Result<Vec<u8>, DecompressionError> {
let mut decompressed = Vec::with_capacity(input.len() * 2);
let mut i = 0;
while i < input.len() {
let token = input[i];
i += 1;
let literal_len = (token >> 4) as usize;
let match_len_token = (token & 0x0F) as usize;
if i + literal_len > input.len() {
return Err(DecompressionError::UnexpectedEof);
}
decompressed.extend_from_slice(&input[i..i + literal_len]);
i += literal_len;
if match_len_token > 0 {
if i + 2 > input.len() {
return Err(DecompressionError::UnexpectedEof);
}
let offset = u16::from_le_bytes([input[i], input[i + 1]]) as usize;
i += 2;
if offset == 0 || offset > decompressed.len() {
return Err(DecompressionError::InvalidToken);
}
let match_len = match_len_token + 3;
let match_start = decompressed.len() - offset;
for k in 0..match_len {
decompressed.push(decompressed[match_start + k]);
}
}
}
Ok(decompressed)
}
pub fn compress(input: &[u8]) -> Vec<u8> {
let mut compressor = Compressor::new();
compressor.compress_chunk(input)
}
fn emit_sequence(out: &mut Vec<u8>, mut literals: &[u8], match_len: usize, offset: u16) {
while literals.len() > MAX_LITERALS {
let token = (MAX_LITERALS as u8) << 4;
out.push(token);
out.extend_from_slice(&literals[..MAX_LITERALS]);
literals = &literals[MAX_LITERALS..];
}
let lit_len_token = literals.len() as u8;
let match_len_token = if match_len > 0 {
(match_len - 3) as u8
} else {
0
};
let token = lit_len_token << 4 | match_len_token;
out.push(token);
out.extend_from_slice(literals);
if match_len > 0 {
out.extend_from_slice(&offset.to_le_bytes());
}
}
pub struct Compressor {
hash_table: Vec<u32>,
history: Vec<u8>,
}
impl Compressor {
pub fn new() -> Self {
Self {
hash_table: vec![0; HASH_TABLE_SIZE],
history: Vec::new(),
}
}
pub fn compress_chunk(&mut self, chunk: &[u8]) -> Vec<u8> {
let mut compressed_chunk = Vec::new();
let chunk_start_cursor = self.history.len();
self.history.extend_from_slice(chunk);
let mut cursor = chunk_start_cursor;
let mut literal_anchor = chunk_start_cursor;
while cursor < self.history.len() {
let mut best_match: Option<(u16, usize)> = None;
if self.history.len() - cursor >= MIN_MATCH_LEN {
let h = hash(&self.history[cursor..cursor + 4]);
let match_pos = self.hash_table[h] as usize;
if match_pos < cursor && cursor - match_pos < u16::MAX as usize {
if self.history.get(match_pos..match_pos + MIN_MATCH_LEN) == Some(&self.history[cursor..cursor + MIN_MATCH_LEN]) {
let mut match_len = MIN_MATCH_LEN;
while cursor + match_len < self.history.len()
&& match_len < MAX_MATCH_LEN
&& self.history.get(match_pos + match_len) == self.history.get(cursor + match_len)
{
match_len += 1;
}
best_match = Some(((cursor - match_pos) as u16, match_len));
}
}
self.hash_table[h] = cursor as u32;
}
if let Some((offset, match_len)) = best_match {
let literals = &self.history[literal_anchor..cursor];
emit_sequence(&mut compressed_chunk, literals, match_len, offset);
cursor += match_len;
literal_anchor = cursor;
} else {
cursor += 1;
}
}
if literal_anchor < cursor {
let literals = &self.history[literal_anchor..cursor];
emit_sequence(&mut compressed_chunk, literals, 0, 0);
}
compressed_chunk
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_roundtrip() {
let data = b"hello world, hello people";
let compressed = compress(data);
println!("Compressed '{}': {:x?}", std::str::from_utf8(data).unwrap(), compressed);
let decompressed = decompress(&compressed).unwrap();
assert_eq!(data, decompressed.as_slice());
}
#[test]
fn test_long_literals() {
let data = b"abcdefghijklmnopqrstuvwxyz";
let compressed = compress(data);
println!("Compressed '{}': {:x?}", std::str::from_utf8(data).unwrap(), compressed);
let decompressed = decompress(&compressed).unwrap();
assert_eq!(data, decompressed.as_slice());
}
#[test]
fn test_decompress_empty() {
let data = b"";
let compressed = compress(data);
assert!(compressed.is_empty());
let decompressed = decompress(&compressed).unwrap();
assert_eq!(data, decompressed.as_slice());
}
#[test]
fn test_overlapping_match() {
let data = b"abcdeabcdeabcdeabcde"; // repeating sequence
let compressed = compress(data);
println!("Compressed '{}': {:x?}", std::str::from_utf8(data).unwrap(), compressed);
let decompressed = decompress(&compressed).unwrap();
assert_eq!(data, decompressed.as_slice());
let data2 = b"abababababababababab";
let compressed2 = compress(data2);
println!("Compressed '{}': {:x?}", std::str::from_utf8(data2).unwrap(), compressed2);
let decompressed2 = decompress(&compressed2).unwrap();
assert_eq!(data2, decompressed2.as_slice());
}
#[test]
fn test_json_roundtrip() {
let data = std::fs::read("data/compression_66k_JSON.txt").unwrap();
let compressed = compress(&data);
std::fs::write("compressed_66k.lzy", &compressed).unwrap();
let decompressed = decompress(&compressed).unwrap();
assert_eq!(data, decompressed.as_slice());
}
mod crdt_helpers {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct After {
pub session_id: String,
pub tx_index: u32,
pub change_idx: u32,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Transaction {
pub op: String,
pub value: String,
pub after: After,
}
pub fn generate_transactions(text: &str, session_id: &str) -> Vec<String> {
let mut transactions = Vec::new();
for (i, c) in text.chars().enumerate() {
let tx = Transaction {
op: "app".to_string(),
value: c.to_string(),
after: After {
session_id: session_id.to_string(),
tx_index: i as u32,
change_idx: 0,
},
};
transactions.push(serde_json::to_string(&tx).unwrap());
}
transactions
}
pub fn generate_shorthand_transactions(text: &str) -> Vec<String> {
let mut transactions = Vec::new();
for c in text.chars() {
transactions.push(serde_json::to_string(&c.to_string()).unwrap());
}
transactions
}
}
#[test]
fn test_crdt_transaction_generation() {
let sample_text = "This is a sample text for our CRDT simulation. \
It should be long enough to see some interesting compression results later on. \
Let's add another sentence to make it a bit more substantial.";
let session_id = "co_zRtnoNffeMHge9wvyL5mK1RWbdz_session_zKvAVFSV5cqW";
let transactions = crdt_helpers::generate_transactions(sample_text, session_id);
println!("--- Generated CRDT Transactions ---");
for tx in &transactions {
println!("{}", tx);
}
println!("--- End of CRDT Transactions ---");
assert!(!transactions.is_empty());
assert_eq!(transactions.len(), sample_text.chars().count());
}
#[test]
fn test_crdt_chunked_compression() {
let sample_text = "This is a sample text for our CRDT simulation. \
It should be long enough to see some interesting compression results later on. \
Let's add another sentence to make it a bit more substantial.";
let session_id = "co_zRtnoNffeMHge9wvyL5mK1RWbdz_session_zKvAVFSV5cqW";
let transactions_json = crdt_helpers::generate_transactions(sample_text, session_id);
let mut compressor = Compressor::new();
let mut compressed_log = Vec::new();
let mut total_json_len = 0;
for tx_json in &transactions_json {
let compressed_chunk = compressor.compress_chunk(tx_json.as_bytes());
compressed_log.extend_from_slice(&compressed_chunk);
total_json_len += tx_json.len();
}
let decompressed = decompress(&compressed_log).unwrap();
// Verify roundtrip
let original_log_concatenated = transactions_json.join("");
assert_eq!(decompressed, original_log_concatenated.as_bytes());
let plaintext_len = sample_text.len();
let compressed_len = compressed_log.len();
let compression_ratio = compressed_len as f64 / total_json_len as f64;
let overhead_ratio = compressed_len as f64 / plaintext_len as f64;
println!("\n--- CRDT Chunked Compression Test ---");
println!("Plaintext size: {} bytes", plaintext_len);
println!("Total JSON size: {} bytes", total_json_len);
println!("Compressed log size: {} bytes", compressed_len);
println!("Compression ratio (compressed/json): {:.4}", compression_ratio);
println!("Overhead ratio (compressed/plaintext): {:.4}", overhead_ratio);
println!("--- End of Test ---");
}
#[test]
fn test_crdt_shorthand_compression() {
let sample_text = "This is a sample text for our CRDT simulation. \
It should be long enough to see some interesting compression results later on. \
Let's add another sentence to make it a bit more substantial.";
let transactions_json = crdt_helpers::generate_shorthand_transactions(sample_text);
let mut compressor = Compressor::new();
let mut compressed_log = Vec::new();
let mut total_json_len = 0;
for tx_json in &transactions_json {
let compressed_chunk = compressor.compress_chunk(tx_json.as_bytes());
compressed_log.extend_from_slice(&compressed_chunk);
total_json_len += tx_json.len();
}
let decompressed = decompress(&compressed_log).unwrap();
// Verify roundtrip
let original_log_concatenated = transactions_json.join("");
assert_eq!(decompressed, original_log_concatenated.as_bytes());
let plaintext_len = sample_text.len();
let compressed_len = compressed_log.len();
let compression_ratio = compressed_len as f64 / total_json_len as f64;
let overhead_ratio = compressed_len as f64 / plaintext_len as f64;
println!("\n--- CRDT Shorthand Compression Test ---");
println!("Plaintext size: {} bytes", plaintext_len);
println!("Total JSON size: {} bytes", total_json_len);
println!("Compressed log size: {} bytes", compressed_len);
println!("Compression ratio (compressed/json): {:.4}", compression_ratio);
println!("Overhead ratio (compressed/plaintext): {:.4}", overhead_ratio);
println!("--- End of Test ---");
}
}

View File

@@ -1,48 +1,48 @@
{
"name": "betterauth",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"email": "email dev --dir src/components/emails"
},
"dependencies": {
"@icons-pack/react-simple-icons": "^12.8.0",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-slot": "^1.2.2",
"better-auth": "^1.2.4",
"better-sqlite3": "^11.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jazz-react-auth-betterauth": "workspace:*",
"jazz-betterauth-client-plugin": "workspace:*",
"jazz-betterauth-server-plugin": "workspace:*",
"jazz-tools": "workspace:*",
"lucide-react": "^0.510.0",
"next": "15.3.2",
"react": "catalog:react",
"react-dom": "catalog:react",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.2.5"
},
"devDependencies": {
"@biomejs/biome": "catalog:default",
"@playwright/test": "^1.50.1",
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^20",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"react-email": "^4.0.11",
"tailwindcss": "^4",
"typescript": "catalog:default"
}
"name": "betterauth",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"email": "email dev --dir src/components/emails"
},
"dependencies": {
"@icons-pack/react-simple-icons": "^12.8.0",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-slot": "^1.2.2",
"better-auth": "^1.2.4",
"better-sqlite3": "^11.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jazz-react-auth-betterauth": "workspace:*",
"jazz-betterauth-client-plugin": "workspace:*",
"jazz-betterauth-server-plugin": "workspace:*",
"jazz-tools": "workspace:*",
"lucide-react": "^0.510.0",
"next": "15.3.2",
"react": "catalog:react",
"react-dom": "catalog:react",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0",
"tw-animate-css": "^1.2.5"
},
"devDependencies": {
"@biomejs/biome": "catalog:default",
"@playwright/test": "^1.50.1",
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^20",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"react-email": "^4.0.11",
"tailwindcss": "^4",
"typescript": "catalog:default"
}
}

View File

@@ -1,5 +1,48 @@
# passkey-svelte
## 0.0.123
### Patch Changes
- jazz-tools@0.17.10
## 0.0.122
### Patch Changes
- Updated dependencies [52ea0c7]
- jazz-tools@0.17.9
## 0.0.121
### Patch Changes
- Updated dependencies [ac3e694]
- Updated dependencies [6dbb053]
- Updated dependencies [1a182f0]
- jazz-tools@0.17.8
## 0.0.120
### Patch Changes
- jazz-tools@0.17.7
## 0.0.119
### Patch Changes
- Updated dependencies [82de51c]
- Updated dependencies [694b168]
- jazz-tools@0.17.6
## 0.0.118
### Patch Changes
- Updated dependencies [5963658]
- jazz-tools@0.17.5
## 0.0.117
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "chat-svelte",
"version": "0.0.117",
"version": "0.0.123",
"type": "module",
"private": true,
"scripts": {

View File

@@ -9,8 +9,10 @@
</head>
<body>
<div id="root"></div>
<div id="root">
<p>Loading...</p>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

View File

@@ -24,6 +24,7 @@ function JazzProvider({ children }: { children: ReactNode }) {
sync={{
peer: `wss://cloud.jazz.tools/?key=${apiKey}`,
}}
fallback={<p>Loading...</p>}
>
{children}
</JazzReactProviderWithClerk>

View File

@@ -1,7 +1,8 @@
import { clerk } from "@clerk/testing/playwright";
import { expect, test } from "@playwright/test";
test("login & expiration", async ({ page, context }) => {
// Flaky on CI
test.skip("login & expiration", async ({ page, context }) => {
// Clear cookies first
await context.clearCookies();

View File

@@ -1,4 +1,4 @@
import { co, z } from "jazz-tools";
import { co, Group, z } from "jazz-tools";
/** Walkthrough: Defining the data model with CoJSON
*
@@ -70,15 +70,22 @@ export const MusicaAccountRoot = co.map({
activePlaylist: Playlist,
exampleDataLoaded: z.optional(z.boolean()),
accountSetupCompleted: z.optional(z.boolean()),
});
export type MusicaAccountRoot = co.loaded<typeof MusicaAccountRoot>;
export const MusicaAccountProfile = co.profile({
avatar: co.optional(co.image()),
});
export type MusicaAccountProfile = co.loaded<typeof MusicaAccountProfile>;
export const MusicaAccount = co
.account({
/** the default user profile with a name */
profile: co.profile(),
profile: MusicaAccountProfile,
root: MusicaAccountRoot,
})
.withMigration((account) => {
.withMigration(async (account) => {
/**
* The account migration is run on account creation and on every log-in.
* You can use it to set up the account root and any other initial CoValues you need.
@@ -97,6 +104,32 @@ export const MusicaAccount = co
exampleDataLoaded: false,
});
}
if (account.profile === undefined) {
account.profile = MusicaAccountProfile.create({
name: "",
});
}
// Load the profile and root in memory, to have them ready
const { profile, root } = await account.ensureLoaded({
resolve: {
profile: {
avatar: true,
},
root: true,
},
});
// Clean up the private avatars (were created using the account as owner)
if (profile.avatar) {
const group = profile.avatar._owner.castAs(Group);
if (group.getRoleOf("everyone") !== "reader") {
root.accountSetupCompleted = false;
profile.avatar = undefined;
}
}
});
export type MusicaAccount = co.loaded<typeof MusicaAccount>;

View File

@@ -7,12 +7,13 @@ import { RouterProvider, createHashRouter } from "react-router-dom";
import { HomePage } from "./3_HomePage";
import { useMediaPlayer } from "./5_useMediaPlayer";
import { InvitePage } from "./6_InvitePage";
import { WelcomeScreen } from "./components/WelcomeScreen";
import "./index.css";
import { MusicaAccount } from "@/1_schema";
import { apiKey } from "@/apiKey.ts";
import { SidebarProvider } from "@/components/ui/sidebar";
import { JazzReactProvider } from "jazz-tools/react";
import { JazzReactProvider, useAccount } from "jazz-tools/react";
import { onAnonymousAccountDiscarded } from "./4_actions";
import { KeyboardListener } from "./components/PlayerControls";
import { usePrepareAppState } from "./lib/usePrepareAppState";
@@ -28,11 +29,22 @@ import { usePrepareAppState } from "./lib/usePrepareAppState";
* `<JazzReactProvider/>` also runs our account migration
*/
function Main() {
const mediaPlayer = useMediaPlayer();
function AppContent({
mediaPlayer,
}: {
mediaPlayer: ReturnType<typeof useMediaPlayer>;
}) {
const { me } = useAccount(MusicaAccount, {
resolve: { root: true },
});
const isReady = usePrepareAppState(mediaPlayer);
// Show welcome screen if account setup is not completed
if (me && !me.root.accountSetupCompleted) {
return <WelcomeScreen />;
}
const router = createHashRouter([
{
path: "/",
@@ -59,6 +71,17 @@ function Main() {
);
}
function Main() {
const mediaPlayer = useMediaPlayer();
return (
<SidebarProvider>
<AppContent mediaPlayer={mediaPlayer} />
<JazzInspector />
</SidebarProvider>
);
}
const peer =
(new URL(window.location.href).searchParams.get(
"peer",

View File

@@ -12,11 +12,13 @@ import { MediaPlayer } from "./5_useMediaPlayer";
import { FileUploadButton } from "./components/FileUploadButton";
import { MusicTrackRow } from "./components/MusicTrackRow";
import { PlayerControls } from "./components/PlayerControls";
import { PlaylistTitleInput } from "./components/PlaylistTitleInput";
import { EditPlaylistModal } from "./components/EditPlaylistModal";
import { PlaylistMembers } from "./components/PlaylistMembers";
import { SidePanel } from "./components/SidePanel";
import { Button } from "./components/ui/button";
import { SidebarInset, SidebarTrigger } from "./components/ui/sidebar";
import { usePlayState } from "./lib/audio/usePlayState";
import { useState } from "react";
export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
/**
@@ -30,6 +32,7 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const playState = usePlayState();
const isPlaying = playState.value === "play";
const { toast } = useToast();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
async function handleFileLoad(files: FileList) {
/**
@@ -50,6 +53,7 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
},
});
const membersIds = playlist?._owner.members.map((member) => member.id);
const isRootPlaylist = !params.playlistId;
const isPlaylistOwner = playlist?._owner.myRole() === "admin";
const isActivePlaylist = playlistId === me?.root.activePlaylist?.id;
@@ -66,6 +70,10 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
});
};
const handleEditClick = () => {
setIsEditModalOpen(true);
};
const isAuthenticated = useIsAuthenticated();
return (
@@ -74,11 +82,17 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
<SidePanel />
<main className="flex-1 px-2 py-4 md:px-6 overflow-y-auto overflow-x-hidden relative sm:h-[calc(100vh-80px)] bg-white h-[calc(100vh-165px)]">
<SidebarTrigger className="md:hidden" />
<div className="flex flex-row items-center justify-between mb-4 pl-1 md:pl-10 pr-2 md:pr-0 mt-2 md:mt-0 w-full">
{isRootPlaylist ? (
<h1 className="text-2xl font-bold text-blue-800">All tracks</h1>
) : (
<PlaylistTitleInput className="w-full" playlistId={playlistId} />
<div className="flex items-center space-x-4">
<h1 className="text-2xl font-bold text-blue-800">
{playlist?.title}
</h1>
{membersIds && <PlaylistMembers memberIds={membersIds} />}
</div>
)}
<div className="flex items-center space-x-4">
{isRootPlaylist && (
@@ -89,9 +103,12 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
</>
)}
{!isRootPlaylist && isAuthenticated && (
<Button onClick={handlePlaylistShareClick}>
Share playlist
</Button>
<>
<Button onClick={handleEditClick} variant="outline">
Edit
</Button>
<Button onClick={handlePlaylistShareClick}>Share</Button>
</>
)}
</div>
</div>
@@ -118,6 +135,13 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
</main>
<PlayerControls mediaPlayer={mediaPlayer} />
</div>
{/* Playlist Title Edit Modal */}
<EditPlaylistModal
playlistId={playlistId}
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
/>
</SidebarInset>
);
}

View File

@@ -59,7 +59,7 @@ export async function uploadMusicTracks(
}
}
export async function createNewPlaylist() {
export async function createNewPlaylist(title: string = "New Playlist") {
const { root } = await MusicaAccount.getMe().ensureLoaded({
resolve: {
root: {
@@ -69,7 +69,7 @@ export async function createNewPlaylist() {
});
const playlist = Playlist.create({
title: "New Playlist",
title,
tracks: [],
});

View File

@@ -24,7 +24,7 @@ export function useMediaPlayer() {
// Reference used to avoid out-of-order track loads
const lastLoadedTrackId = useRef<string | null>(null);
async function loadTrack(track: MusicTrack) {
async function loadTrack(track: MusicTrack, autoPlay = true) {
lastLoadedTrackId.current = track.id;
audioManager.unloadCurrentAudio();
@@ -44,7 +44,7 @@ export function useMediaPlayer() {
return;
}
await playMedia(file);
await playMedia(file, autoPlay);
setLoading(null);
}

View File

@@ -7,8 +7,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAccount, usePasskeyAuth } from "jazz-tools/react";
import { useState } from "react";
@@ -18,7 +16,6 @@ interface AuthModalProps {
}
export function AuthModal({ open, onOpenChange }: AuthModalProps) {
const [username, setUsername] = useState("");
const [isSignUp, setIsSignUp] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -31,6 +28,7 @@ export function AuthModal({ open, onOpenChange }: AuthModalProps) {
},
},
},
profile: true,
},
});
@@ -48,7 +46,7 @@ export function AuthModal({ open, onOpenChange }: AuthModalProps) {
try {
if (isSignUp) {
await auth.signUp(username);
await auth.signUp(me?.profile.name || "");
} else {
await auth.logIn();
}
@@ -84,18 +82,6 @@ export function AuthModal({ open, onOpenChange }: AuthModalProps) {
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{isSignUp && (
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
required
/>
</div>
)}
{error && <div className="text-sm text-red-500">{error}</div>}
{shouldShowTransferRootPlaylist && (
<div className="text-sm text-red-500">

View File

@@ -0,0 +1,105 @@
import { createNewPlaylist } from "@/4_actions";
import { useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
interface CreatePlaylistModalProps {
isOpen: boolean;
onClose: () => void;
onPlaylistCreated: (playlistId: string) => void;
}
export function CreatePlaylistModal({
isOpen,
onClose,
onPlaylistCreated,
}: CreatePlaylistModalProps) {
const [playlistTitle, setPlaylistTitle] = useState("");
const [isCreating, setIsCreating] = useState(false);
function handleTitleChange(evt: React.ChangeEvent<HTMLInputElement>) {
setPlaylistTitle(evt.target.value);
}
async function handleCreate() {
if (!playlistTitle.trim()) return;
setIsCreating(true);
try {
const playlist = await createNewPlaylist(playlistTitle.trim());
onPlaylistCreated(playlist.id);
onClose();
} catch (error) {
console.error("Failed to create playlist:", error);
} finally {
setIsCreating(false);
}
}
function handleCancel() {
setPlaylistTitle("");
onClose();
}
function handleKeyDown(evt: React.KeyboardEvent) {
if (evt.key === "Enter") {
handleCreate();
} else if (evt.key === "Escape") {
handleCancel();
}
}
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Create New Playlist
</h2>
<p className="text-sm text-gray-600">Give your new playlist a name</p>
</div>
<div className="space-y-4">
<div>
<Label
htmlFor="playlist-title"
className="text-sm font-medium text-gray-700"
>
Playlist Title
</Label>
<Input
id="playlist-title"
value={playlistTitle}
onChange={handleTitleChange}
onKeyDown={handleKeyDown}
placeholder="Enter playlist title"
className="mt-1"
autoFocus
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<Button
variant="outline"
onClick={handleCancel}
className="px-4 py-2"
disabled={isCreating}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!playlistTitle.trim() || isCreating}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{isCreating ? "Creating..." : "Create Playlist"}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { Playlist } from "@/1_schema";
import { updatePlaylistTitle } from "@/4_actions";
import { useCoState } from "jazz-tools/react";
import { ChangeEvent, useState, useEffect } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
interface EditPlaylistModalProps {
playlistId: string | undefined;
isOpen: boolean;
onClose: () => void;
}
export function EditPlaylistModal({
playlistId,
isOpen,
onClose,
}: EditPlaylistModalProps) {
const playlist = useCoState(Playlist, playlistId);
const [localPlaylistTitle, setLocalPlaylistTitle] = useState("");
// Reset local title when modal opens or playlist changes
useEffect(() => {
if (isOpen && playlist) {
setLocalPlaylistTitle(playlist.title ?? "");
}
}, [isOpen, playlist]);
function handleTitleChange(evt: ChangeEvent<HTMLInputElement>) {
setLocalPlaylistTitle(evt.target.value);
}
function handleSave() {
if (playlist && localPlaylistTitle.trim()) {
updatePlaylistTitle(playlist, localPlaylistTitle.trim());
onClose();
}
}
function handleCancel() {
setLocalPlaylistTitle(playlist?.title ?? "");
onClose();
}
function handleKeyDown(evt: React.KeyboardEvent) {
if (evt.key === "Enter") {
handleSave();
} else if (evt.key === "Escape") {
handleCancel();
}
}
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Edit Playlist
</h2>
</div>
<div className="space-y-4">
<div>
<Label
htmlFor="playlist-title"
className="text-sm font-medium text-gray-700"
>
Playlist Title
</Label>
<Input
id="playlist-title"
value={localPlaylistTitle}
onChange={handleTitleChange}
onKeyDown={handleKeyDown}
placeholder="Enter playlist title"
className="mt-1"
autoFocus
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<Button
variant="outline"
onClick={handleCancel}
className="px-4 py-2"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!localPlaylistTitle.trim()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
Save Changes
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useCoState } from "jazz-tools/react";
import { MusicaAccount } from "@/1_schema";
import { Image } from "jazz-tools/react";
interface MemberProps {
accountId: string;
size?: "sm" | "md" | "lg";
showTooltip?: boolean;
className?: string;
}
export function Member({
accountId,
size = "md",
showTooltip = true,
className = "",
}: MemberProps) {
const account = useCoState(MusicaAccount, accountId, {
resolve: { profile: true },
});
if (!account) {
return (
<div
className={`rounded-full bg-gray-200 border-2 border-white flex items-center justify-center ${getSizeClasses(size)} ${className}`}
>
<span className="text-gray-500 text-xs">👤</span>
</div>
);
}
const avatar = account.profile?.avatar;
const name = account.profile?.name || "Unknown User";
return (
<div
className={`rounded-full border-2 border-white overflow-hidden ${getSizeClasses(size)} ${className}`}
title={showTooltip ? name : undefined}
>
{avatar ? (
<Image
imageId={avatar.id}
width={getSizePx(size)}
height={getSizePx(size)}
alt={`${name}'s avatar`}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 text-sm">
{name.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
);
}
function getSizeClasses(size: "sm" | "md" | "lg"): string {
switch (size) {
case "sm":
return "w-6 h-6";
case "md":
return "w-8 h-8";
case "lg":
return "w-10 h-10";
default:
return "w-8 h-8";
}
}
function getSizePx(size: "sm" | "md" | "lg"): number {
switch (size) {
case "sm":
return 24;
case "md":
return 32;
case "lg":
return 40;
default:
return 32;
}
}

View File

@@ -152,7 +152,7 @@ export function MusicTrackRow({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
{playlists.map((playlist, playlistIndex) => (
{playlists.filter(Boolean).map((playlist, playlistIndex) => (
<Fragment key={playlistIndex}>
{isPartOfThePlaylist(trackId, playlist) ? (
<DropdownMenuItem

View File

@@ -0,0 +1,30 @@
import { Member } from "./Member";
interface PlaylistMembersProps {
memberIds: string[];
size?: "sm" | "md" | "lg";
className?: string;
}
export function PlaylistMembers({
memberIds,
size = "md",
className = "",
}: PlaylistMembersProps) {
if (!memberIds || memberIds.length === 0) return null;
return (
<div className={`flex items-center space-x-2 ${className}`}>
<div className="flex -space-x-2">
{memberIds.map((memberId) => (
<Member
key={memberId}
accountId={memberId}
size={size}
showTooltip={true}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,213 @@
import React, { useState, useRef } from "react";
import { Image, useAccount } from "jazz-tools/react";
import { createImage } from "jazz-tools/media";
import { MusicaAccount } from "../1_schema";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Separator } from "./ui/separator";
import { Group } from "jazz-tools";
interface ProfileFormProps {
onSubmit?: (data: { username: string; avatar?: any }) => void;
submitButtonText?: string;
showHeader?: boolean;
headerTitle?: string;
headerDescription?: string;
initialUsername?: string;
initialAvatar?: any;
onCancel?: () => void;
showCancelButton?: boolean;
cancelButtonText?: string;
className?: string;
}
export function ProfileForm({
onSubmit,
submitButtonText = "Save Changes",
showHeader = false,
headerTitle = "Profile Settings",
headerDescription = "Update your profile information",
initialUsername = "",
initialAvatar,
onCancel,
showCancelButton = false,
cancelButtonText = "Cancel",
className = "",
}: ProfileFormProps) {
const { me } = useAccount(MusicaAccount, {
resolve: { profile: true, root: true },
});
const [username, setUsername] = useState(
initialUsername || me?.profile?.name || "",
);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
if (!me) return null;
const handleAvatarUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
// Create image using the Image API from jazz-tools/media
const image = await createImage(file, {
owner: Group.create().makePublic(),
maxSize: 256, // Good size for avatars
placeholder: "blur",
progressive: true,
});
// Update the profile with the new avatar
me.profile.avatar = image;
} catch (error) {
console.error("Failed to upload avatar:", error);
} finally {
setIsUploading(false);
}
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!username.trim()) return;
// Update username
me.profile.name = username.trim();
// Call custom onSubmit if provided
if (onSubmit) {
onSubmit({ username: username.trim(), avatar: me.profile.avatar });
}
};
const currentAvatar = initialAvatar || me.profile.avatar;
const canSubmit = username.trim();
return (
<div className={className}>
{showHeader && (
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{headerTitle}
</h1>
<p className="text-gray-600">{headerDescription}</p>
</div>
)}
<form className="space-y-6" onSubmit={handleSubmit}>
{/* Avatar Section */}
<div className="space-y-3">
<Label
htmlFor="avatar"
className="text-sm font-medium text-gray-700 sr-only"
>
Profile Picture
</Label>
<div className="flex flex-col items-center space-y-3">
{/* Current Avatar Display */}
<div className="relative">
<div className="w-24 h-24 rounded-full overflow-hidden border-4 border-white shadow-lg">
{currentAvatar ? (
<Image
imageId={currentAvatar.id}
width={96}
height={96}
alt="Profile"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-2xl">👤</span>
</div>
)}
</div>
{/* Upload Overlay */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="absolute -bottom-1 -right-1 w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white hover:bg-blue-700 disabled:opacity-50 transition-colors cursor-pointer"
title="Change avatar"
>
{isUploading ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<span className="text-sm">📷</span>
)}
</button>
</div>
<input
ref={fileInputRef}
type="file"
id="avatar"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
disabled={isUploading}
/>
<p className="text-xs text-gray-500 text-center">
Click the camera icon to upload a profile picture
</p>
</div>
</div>
<Separator />
{/* Username Section */}
<div className="space-y-3">
<Label
htmlFor="username"
className="text-sm font-medium text-gray-700"
>
Username
</Label>
<Input
id="username"
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full"
maxLength={30}
/>
<p className="text-xs text-gray-500">
This will be displayed to other users
</p>
</div>
{/* Action Buttons */}
<div className="flex space-x-3">
{showCancelButton && (
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1"
size="lg"
>
{cancelButtonText}
</Button>
)}
<Button
type="submit"
disabled={!canSubmit}
className={`${showCancelButton ? "flex-1" : "w-full"} bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed`}
size="lg"
>
{submitButtonText}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { useState } from "react";
import { useAccount } from "jazz-tools/react";
import { MusicaAccount } from "../1_schema";
import { ProfileForm } from "./ProfileForm";
import { Button } from "./ui/button";
export function ProfileSettings() {
const { me } = useAccount(MusicaAccount, {
resolve: { profile: true, root: true },
});
const [isEditing, setIsEditing] = useState(false);
if (!me) return null;
const handleSaveProfile = (data: { username: string; avatar?: any }) => {
// Profile is automatically updated by the ProfileForm component
// You can add additional logic here if needed
console.log("Profile updated:", data);
setIsEditing(false);
};
const handleCancel = () => {
setIsEditing(false);
};
if (isEditing) {
return (
<div className="max-w-2xl mx-auto p-6">
<div className="bg-white rounded-lg shadow-lg p-6">
<ProfileForm
onSubmit={handleSaveProfile}
onCancel={handleCancel}
showCancelButton={true}
submitButtonText="Save Changes"
showHeader={true}
headerTitle="Edit Profile"
headerDescription="Update your profile information"
initialUsername={me.profile.name || ""}
initialAvatar={me.profile.avatar}
/>
</div>
</div>
);
}
return (
<div className="max-w-2xl mx-auto p-6">
<div className="bg-white rounded-lg shadow-lg p-6">
{/* Profile Display */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Profile Settings
</h1>
{/* Avatar Display */}
<div className="mb-6">
<div className="w-32 h-32 rounded-full overflow-hidden border-4 border-white shadow-lg mx-auto">
{me.profile.avatar ? (
<img
src={`/api/images/${me.profile.avatar.id}`}
alt="Profile"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-4xl">👤</span>
</div>
)}
</div>
</div>
{/* Username Display */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-gray-800">
{me.profile.name || "No username set"}
</h2>
<p className="text-gray-600 text-sm">
{me.profile.name
? "Your display name"
: "Set a username to get started"}
</p>
</div>
{/* Edit Button */}
<Button
onClick={() => setIsEditing(true)}
className="bg-blue-600 hover:bg-blue-700"
size="lg"
>
Edit Profile
</Button>
</div>
{/* Additional Profile Information */}
<div className="border-t pt-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Account Information
</h3>
<div className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-gray-100">
<span className="text-gray-600">Account ID:</span>
<span className="text-sm font-mono text-gray-800">{me.id}</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-gray-600">Setup Completed:</span>
<span
className={`text-sm ${me.root.accountSetupCompleted ? "text-green-600" : "text-red-600"}`}
>
{me.root.accountSetupCompleted ? "Yes" : "No"}
</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { MusicaAccount } from "@/1_schema";
import { createNewPlaylist, deletePlaylist } from "@/4_actions";
import { deletePlaylist } from "@/4_actions";
import {
Sidebar,
SidebarContent,
@@ -15,7 +15,9 @@ import {
import { useAccount } from "jazz-tools/react";
import { Home, Music, Plus, Trash2 } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { useState } from "react";
import { AuthButton } from "./AuthButton";
import { CreatePlaylistModal } from "./CreatePlaylistModal";
export function SidePanel() {
const { playlistId } = useParams();
@@ -23,6 +25,7 @@ export function SidePanel() {
const { me } = useAccount(MusicaAccount, {
resolve: { root: { playlists: { $each: true } } },
});
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
function handleAllTracksClick() {
navigate(`/`);
@@ -39,92 +42,104 @@ export function SidePanel() {
}
}
async function handleCreatePlaylist() {
const playlist = await createNewPlaylist();
navigate(`/playlist/${playlist.id}`);
function handleCreatePlaylistClick() {
setIsCreateModalOpen(true);
}
function handlePlaylistCreated(playlistId: string) {
navigate(`/playlist/${playlistId}`);
}
return (
<Sidebar>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-blue-600 text-white">
<svg
className="size-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 18V5l12-2v13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 15H3c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h3c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2zM18 13h-3c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h3c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2z"
fill="currentColor"
/>
</svg>
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Music Player</span>
</div>
<AuthButton />
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={handleAllTracksClick}>
<Home className="size-4" />
<span>Go to all tracks</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Playlists</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={handleCreatePlaylist}>
<Plus className="size-4" />
<span>Add a new playlist</span>
</SidebarMenuButton>
</SidebarMenuItem>
{me?.root.playlists.map((playlist) => (
<SidebarMenuItem key={playlist.id}>
<SidebarMenuButton
onClick={() => handlePlaylistClick(playlist.id)}
isActive={playlist.id === playlistId}
>
<div className="flex items-center gap-2">
<Music className="size-4" />
<span>{playlist.title}</span>
</div>
<>
<Sidebar>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-blue-600 text-white">
<svg
className="size-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 18V5l12-2v13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 15H3c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h3c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2zM18 13h-3c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h3c1.1 0 2-.9 2-2v-4c0-1.1-.9-2-2-2z"
fill="currentColor"
/>
</svg>
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">Music Player</span>
</div>
<AuthButton />
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={handleAllTracksClick}>
<Home className="size-4" />
<span>Go to all tracks</span>
</SidebarMenuButton>
{playlist.id === playlistId && (
<SidebarMenuAction
onClick={() => handleDeletePlaylist(playlist.id)}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="size-4" />
<span className="sr-only">Delete {playlist.title}</span>
</SidebarMenuAction>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Playlists</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={handleCreatePlaylistClick}>
<Plus className="size-4" />
<span>Add a new playlist</span>
</SidebarMenuButton>
</SidebarMenuItem>
{me?.root.playlists.map((playlist) => (
<SidebarMenuItem key={playlist.id}>
<SidebarMenuButton
onClick={() => handlePlaylistClick(playlist.id)}
isActive={playlist.id === playlistId}
>
<div className="flex items-center gap-2">
<Music className="size-4" />
<span>{playlist.title}</span>
</div>
</SidebarMenuButton>
{playlist.id === playlistId && (
<SidebarMenuAction
onClick={() => handleDeletePlaylist(playlist.id)}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="size-4" />
<span className="sr-only">Delete {playlist.title}</span>
</SidebarMenuAction>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
{/* Create Playlist Modal */}
<CreatePlaylistModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onPlaylistCreated={handlePlaylistCreated}
/>
</>
);
}

View File

@@ -0,0 +1,95 @@
import { useAccount, usePasskeyAuth } from "jazz-tools/react";
import { MusicaAccount } from "../1_schema";
import { ProfileForm } from "./ProfileForm";
import { Button } from "./ui/button";
export function WelcomeScreen() {
const { me } = useAccount(MusicaAccount, {
resolve: { profile: true, root: true },
});
const auth = usePasskeyAuth({
appName: "Jazz Music Player",
});
if (!me) return null;
const handleCompleteSetup = () => {
// Mark account setup as completed
me.root.accountSetupCompleted = true;
};
const handleLogin = () => {
auth.logIn();
};
return (
<div className="w-full lg:w-auto min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="flex flex-col lg:flex-row gap-8 lg:gap-16 items-center">
{/* Form Panel */}
<div className="w-full max-w-md bg-white rounded-lg shadow-xl p-8">
<ProfileForm
onSubmit={handleCompleteSetup}
submitButtonText="Continue"
showHeader={true}
headerTitle="Welcome to Music Player! 🎵"
headerDescription="Let's set up your profile to get started"
initialUsername={me?.profile?.name || ""}
initialAvatar={me?.profile?.avatar}
/>
</div>
<div className="lg:hidden pt-4 flex justify-end items-center w-full gap-2">
<div className="text-sm font-semibold text-gray-600">
Already a user?
</div>
<Button onClick={handleLogin} size="sm">
Login
</Button>
</div>
{/* Title Section - Hidden on mobile, shown on right side for larger screens */}
<div className="hidden lg:flex flex-col justify-center items-start max-w-md">
<div className="space-y-6">
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 leading-tight">
Your Music at your fingertips.
</h1>
<div className="space-y-4">
<p className="text-xl lg:text-2xl text-gray-700 font-medium">
Offline, Collaborative, Fast
</p>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500 font-medium">
Powered by
</span>
<a
href="https://jazz.tools"
target="_blank"
rel="noopener noreferrer"
className="text-lg font-bold text-blue-600 hover:underline"
>
Jazz
</a>
</div>
{/* Login Button */}
<div className="pt-4">
<p className="text-sm font-semibold text-gray-600 mb-2">
Already a user?
</p>
<Button
onClick={handleLogin}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 text-lg font-medium rounded-lg shadow-lg hover:shadow-xl transition-all duration-200"
size="lg"
>
Login
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -294,7 +294,7 @@ const SidebarTrigger = React.forwardRef<
}}
{...props}
>
<PanelLeft />
<PanelLeft className="size-4" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);

View File

@@ -6,7 +6,7 @@ export function usePlayMedia() {
const previousMediaLoad = useRef<Promise<unknown> | undefined>(undefined);
async function playMedia(file: Blob) {
async function playMedia(file: Blob, autoPlay = true) {
// Wait for the previous load to finish
// to avoid to incur into concurrency issues
await previousMediaLoad.current;
@@ -17,7 +17,9 @@ export function usePlayMedia() {
await promise;
audioManager.play();
if (autoPlay) {
audioManager.play();
}
}
return playMedia;

View File

@@ -34,7 +34,7 @@ async function loadInitialData(mediaPlayer: MediaPlayer) {
// Load the active track in the AudioManager
if (me.root.activeTrack) {
mediaPlayer.loadTrack(me.root.activeTrack);
mediaPlayer.loadTrack(me.root.activeTrack, false);
}
}

View File

@@ -44,7 +44,10 @@ test("sign up and log out", async ({ page: marioPage }) => {
const marioHome = new HomePage(marioPage);
await marioHome.signUp("Mario");
await marioHome.fillUsername("Mario");
await marioPage.keyboard.press("Enter");
await marioHome.signUp();
await marioHome.logoutButton.waitFor({
state: "visible",

View File

@@ -47,12 +47,14 @@ test("create a new playlist and share", async ({
const marioHome = new HomePage(marioPage);
await marioHome.fillUsername("Mario");
await marioPage.keyboard.press("Enter");
// The example song should be loaded
await marioHome.expectMusicTrack("Example song");
await marioHome.editTrackTitle("Example song", "Super Mario World");
await marioHome.createPlaylist();
await marioHome.editPlaylistTitle("Save the princess");
await marioHome.createPlaylist("Save the princess");
await marioHome.navigateToPlaylist("All tracks");
await marioHome.addTrackToPlaylist("Super Mario World", "Save the princess");
@@ -60,7 +62,7 @@ test("create a new playlist and share", async ({
await marioHome.navigateToPlaylist("Save the princess");
await marioHome.expectMusicTrack("Super Mario World");
await marioHome.signUp("Mario");
await marioHome.signUp();
const url = await marioHome.getShareLink();
@@ -74,7 +76,10 @@ test("create a new playlist and share", async ({
const luigiHome = new HomePage(luigiPage);
await luigiHome.signUp("Luigi");
await luigiHome.fillUsername("Luigi");
await luigiPage.keyboard.press("Enter");
await luigiHome.signUp();
await luigiPage.goto(url);
@@ -90,15 +95,18 @@ test("create a new playlist, share, then remove track", async ({
// Create playlist with a song and share
await marioPage.goto("/");
const marioHome = new HomePage(marioPage);
await marioHome.fillUsername("Mario");
await marioPage.keyboard.press("Enter");
await marioHome.expectMusicTrack("Example song");
await marioHome.editTrackTitle("Example song", "Super Mario World");
await marioHome.createPlaylist();
await marioHome.editPlaylistTitle("Save the princess");
await marioHome.createPlaylist("Save the princess");
await marioHome.navigateToPlaylist("All tracks");
await marioHome.addTrackToPlaylist("Super Mario World", "Save the princess");
await marioHome.navigateToPlaylist("Save the princess");
await marioHome.expectMusicTrack("Super Mario World");
await marioHome.signUp("Mario");
await marioHome.signUp();
const url = await marioHome.getShareLink();
await sleep(4000); // Wait for the sync to complete
@@ -109,7 +117,12 @@ test("create a new playlist, share, then remove track", async ({
const luigiPage = await luigiContext.newPage();
await luigiPage.goto("/");
const luigiHome = new HomePage(luigiPage);
await luigiHome.signUp("Luigi");
await luigiHome.fillUsername("Luigi");
await luigiPage.keyboard.press("Enter");
await luigiHome.signUp();
await luigiPage.goto(url);
await luigiHome.expectMusicTrack("Super Mario World");

View File

@@ -19,6 +19,10 @@ export class HomePage {
name: "Sign out",
});
async fillUsername(username: string) {
await this.page.getByRole("textbox", { name: "Username" }).fill(username);
}
async expectActiveTrackPlaying() {
await expect(
this.page.getByRole("button", {
@@ -71,12 +75,10 @@ export class HomePage {
await this.page.getByRole("button", { name: "Save" }).click();
}
async createPlaylist() {
async createPlaylist(playlistTitle: string) {
await this.newPlaylistButton.click();
}
async editPlaylistTitle(playlistTitle: string) {
await this.playlistTitleInput.fill(playlistTitle);
await this.page.getByRole("button", { name: "Create Playlist" }).click();
}
async navigateToPlaylist(playlistTitle: string) {
@@ -98,7 +100,7 @@ export class HomePage {
async getShareLink() {
await this.page
.getByRole("button", {
name: "Share playlist",
name: "Share",
})
.click();
@@ -139,9 +141,8 @@ export class HomePage {
.click();
}
async signUp(name: string) {
async signUp() {
await this.page.getByRole("button", { name: "Sign up" }).click();
await this.page.getByRole("textbox", { name: "Username" }).fill(name);
await this.page
.getByRole("button", { name: "Sign up with passkey" })
.click();
@@ -156,10 +157,12 @@ export class HomePage {
async logOut() {
await this.logoutButton.click();
await this.loginButton.waitFor({
await this.page.getByRole("textbox", { name: "Username" }).waitFor({
state: "visible",
});
await expect(this.loginButton).toBeVisible();
await expect(
this.page.getByRole("textbox", { name: "Username" }),
).toBeVisible();
}
}

View File

@@ -25,6 +25,11 @@ export const docNavigationItems = [
excludeFromNavigation: true,
},
{ name: "FAQs", href: "/docs/faq", done: 100 },
{
name: "Troubleshooting",
href: "/docs/troubleshooting",
done: 100,
},
],
},
{

View File

@@ -49,18 +49,20 @@ if (bob) {
```
</CodeGroup>
**Note:** if the account ID is of type `string`, because it comes from a URL parameter or something similar, you need to cast it to `ID<Account>` first:
**Note:**
- Both `Account.load(id)` and `co.account().load(id)` do the same thing — they load an account from its ID.
<CodeGroup>
```tsx twoslash
const bobsID = "co_z123";
import { Group } from "jazz-tools";
const group = Group.create();
// ---cut---
import { co, Group } from "jazz-tools";
import { ID, Account, co } from "jazz-tools";
const bob = await co.account().load(bobsID);
const bob = await Account.load(bobsID);
// Or: const bob = await co.account().load(bobsID);
if (bob) {
group.addMember(bob, "writer");

View File

@@ -25,7 +25,10 @@ You can use [`create-jazz-app`](/docs/tools/create-jazz-app) to create a new Jaz
```
</CodeGroup>
<Alert variant="info" className="mt-4 flex gap-2 items-center">Requires at least Node.js v20.</Alert>
<Alert variant="info" className="mt-4 flex gap-2 items-center">
Requires at least Node.js v20.
See our [Troubleshooting Guide](https://jazz.tools/docs/troubleshooting) for quick fixes.
</Alert>
{/* <ContentByFramework framework="react">
Or you can follow this [React step-by-step guide](/docs/react/guide) where we walk you through building an issue tracker app.

View File

@@ -53,6 +53,7 @@ npm i -S jazz-tools
<Alert variant="info" className="mt-4" title="Note">
- Requires at least Node.js v20.
- Hermes has added support for `atob` and `btoa` in React Native 0.74. If you are using earlier versions, you may also need to polyfill `atob` and `btoa` in your `package.json`. Packages to try include `text-encoding` and `base-64`, and you can drop `@bacons/text-decoder`.
See our [Troubleshooting Guide](https://jazz.tools/docs/troubleshooting) for quick fixes.
</Alert>
#### Fix incompatible dependencies

View File

@@ -51,7 +51,6 @@ npm i -S jazz-tools
</CodeGroup>
<Alert variant="info" className="mt-4" title="Note">
- Requires at least Node.js v20.
- Hermes has added support for `atob` and `btoa` in React Native 0.74. If you are using earlier versions, you may also need to polyfill `atob` and `btoa` in your `package.json`. Packages to try include `text-encoding` and `base-64`, and you can drop `@bacons/text-decoder`.
</Alert>

View File

@@ -27,8 +27,6 @@ pnpm install jazz-tools
```
</CodeGroup>
<Alert variant="info" className="mt-4 flex gap-2 items-center">Requires at least Node.js v20.</Alert>
## Write your schema
Define your data schema using [CoValues](/docs/schemas/covalues) from `jazz-tools`.

View File

@@ -19,8 +19,6 @@ pnpm install jazz-tools
```
</CodeGroup>
<Alert variant="info" className="mt-4 flex gap-2 items-center">Requires at least Node.js v20.</Alert>
## Write your schema
See the [schema docs](/docs/schemas/covalues) for more information.

View File

@@ -1,4 +1,5 @@
import { ContentByFramework, CodeGroup } from '@/components/forMdx'
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
export const metadata = {
description: "Learn how to sync and persist your data using Jazz Cloud, or run your own sync server."
@@ -44,8 +45,14 @@ And then use `ws://localhost:4200` as the sync server URL.
You can also run this simple sync server behind a proxy that supports WebSockets, for example to provide TLS.
In this case, provide the WebSocket endpoint your proxy exposes as the sync server URL.
<Alert variant="info" className="mt-4 flex gap-2 items-center">
Requires at least Node.js v20.
See our [Troubleshooting Guide](https://jazz.tools/docs/troubleshooting) for quick fixes.
</Alert>
### Command line options:
- `--host` / `-h` - the host to run the sync server on. Defaults to 127.0.0.1.
- `--port` / `-p` - the port to run the sync server on. Defaults to 4200.
- `--in-memory` - keep CoValues in-memory only and do sync only, no persistence. Persistence is enabled by default.
- `--db` - the path to the file where to store the data (SQLite). Defaults to `sync-db/storage.db`.

View File

@@ -0,0 +1,133 @@
import { ContentByFramework, CodeGroup } from '@/components/forMdx'
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
export const metadata = {
title: "Setup troubleshooting",
description: "A few reported setup hiccups and how to fix them."
};
# Setup troubleshooting
A few reported setup hiccups and how to fix them.
---
## Node.js version requirements
Jazz requires **Node.js v20 or later** due to native module dependencies.
Check your version:
<CodeGroup>
```sh
node -v
```
</CodeGroup>
If youre on Node 18 or earlier, upgrade via nvm:
<CodeGroup>
```sh
nvm install 20
nvm use 20
```
</CodeGroup>
---
## npx jazz-run: command not found
If, when running:
<CodeGroup>
```sh
npx jazz-run sync
```
</CodeGroup>
you encounter:
<CodeGroup>
```sh
sh: jazz-run: command not found
```
</CodeGroup>
This is often due to an npx cache quirk. (For most apps using Jazz)
1. Clear your npx cache:
<CodeGroup>
```sh
npx clear-npx-cache
```
</CodeGroup>
2. Rerun the command:
<CodeGroup>
```sh
npx jazz-run sync
```
</CodeGroup>
---
### Node 18 workaround (rebuilding the native module)
If you cant upgrade to Node 20+, you can rebuild the native `better-sqlite3` module for your architecture.
1. Install `jazz-run` locally in your project:
<CodeGroup>
```sh
pnpm add -D jazz-run
```
</CodeGroup>
2. Find the installed version of better-sqlite3 inside node_modules.
It should look like this:
<CodeGroup>
```sh
./node_modules/.pnpm/better-sqlite3{version}/node_modules/better-sqlite3
```
</CodeGroup>
Replace `{version}` with your installed version and run:
<CodeGroup>
```sh
# Navigate to the installed module and rebuild
pushd ./node_modules/.pnpm/better-sqlite3{version}/node_modules/better-sqlite3
&& pnpm install
&& popd
```
</CodeGroup>
If you get ModuleNotFoundError: No module named 'distutils':
Linux:
<CodeGroup>
```sh
pip install --upgrade setuptools
```
</CodeGroup>
macOS:
<CodeGroup>
```sh
brew install python-setuptools
```
</CodeGroup>
<p><i>Workaround originally shared by @aheissenberger on Jun 24, 2025.</i></p>
---
### Still having trouble?
If none of the above fixes work:
Make sure dependencies installed without errors (`pnpm install`).
Double-check your `node -v` output matches the required version.
Open an issue on GitHub with:
- Your OS and version
- Node.js version
- Steps you ran and full error output
We're always happy to help! If you're stuck, reachout via [Discord](https://discord.gg/utDMjHYg42)

View File

@@ -26,3 +26,4 @@ export const navigationItems: NavItemProps[] = [
title: "Status",
},
];

View File

@@ -5,7 +5,8 @@
"workspaces": [
"packages/*",
"examples/*",
"starters/*"
"starters/*",
"crates/*"
],
"packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c",
"engines": {
@@ -31,6 +32,7 @@
"vitest": "catalog:default"
},
"scripts": {
"bench": "vitest bench",
"dev": "turbo dev",
"build": "turbo build && cd homepage/homepage && turbo build",
"build:packages": "turbo build --filter='./packages/*'",

View File

@@ -1,5 +1,45 @@
# cojson-storage-indexeddb
## 0.17.10
### Patch Changes
- Updated dependencies [c55297c]
- cojson@0.17.10
## 0.17.9
### Patch Changes
- Updated dependencies [7586c3b]
- cojson@0.17.9
## 0.17.8
### Patch Changes
- cojson@0.17.8
## 0.17.7
### Patch Changes
- cojson@0.17.7
## 0.17.6
### Patch Changes
- cojson@0.17.6
## 0.17.5
### Patch Changes
- Updated dependencies [71c1411]
- Updated dependencies [2d11d44]
- cojson@0.17.5
## 0.17.4
### Patch Changes

View File

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

View File

@@ -1,5 +1,45 @@
# cojson-storage-sqlite
## 0.17.10
### Patch Changes
- Updated dependencies [c55297c]
- cojson@0.17.10
## 0.17.9
### Patch Changes
- Updated dependencies [7586c3b]
- cojson@0.17.9
## 0.17.8
### Patch Changes
- cojson@0.17.8
## 0.17.7
### Patch Changes
- cojson@0.17.7
## 0.17.6
### Patch Changes
- cojson@0.17.6
## 0.17.5
### Patch Changes
- Updated dependencies [71c1411]
- Updated dependencies [2d11d44]
- cojson@0.17.5
## 0.17.4
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.17.4",
"version": "0.17.10",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",

View File

@@ -1,5 +1,45 @@
# cojson-transport-nodejs-ws
## 0.17.10
### Patch Changes
- Updated dependencies [c55297c]
- cojson@0.17.10
## 0.17.9
### Patch Changes
- Updated dependencies [7586c3b]
- cojson@0.17.9
## 0.17.8
### Patch Changes
- cojson@0.17.8
## 0.17.7
### Patch Changes
- cojson@0.17.7
## 0.17.6
### Patch Changes
- cojson@0.17.6
## 0.17.5
### Patch Changes
- Updated dependencies [71c1411]
- Updated dependencies [2d11d44]
- cojson@0.17.5
## 0.17.4
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "cojson-transport-ws",
"type": "module",
"version": "0.17.4",
"version": "0.17.10",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",

View File

@@ -1,5 +1,31 @@
# cojson
## 0.17.10
### Patch Changes
- c55297c: Move the session log management into WASM
- cojson-core-wasm@0.17.10
## 0.17.9
### Patch Changes
- 7586c3b: Adds disableTransactionVerification() method to SyncManager
## 0.17.8
## 0.17.7
## 0.17.6
## 0.17.5
### Patch Changes
- 71c1411: Removed some unnecessary content messages sent after a local transaction when sending a value as dependency before the ack response
- 2d11d44: Make the CoValueCore.unmount function detach the CoValue from LocalNode
## 0.17.4
## 0.17.3

View File

@@ -25,7 +25,7 @@
},
"type": "module",
"license": "MIT",
"version": "0.17.4",
"version": "0.17.10",
"devDependencies": {
"@opentelemetry/sdk-metrics": "^2.0.0",
"libsql": "^0.5.13",
@@ -37,7 +37,7 @@
"@noble/hashes": "^1.8.0",
"@opentelemetry/api": "^1.9.0",
"@scure/base": "1.2.1",
"jazz-crypto-rs": "0.0.7",
"cojson-core-wasm": "workspace:*",
"neverthrow": "^7.0.1",
"unicode-segmenter": "^0.12.0"
},

View File

@@ -33,11 +33,7 @@ export class GarbageCollector {
const timeSinceLastAccessed = currentTime - verified.lastAccessed;
if (timeSinceLastAccessed > GARBAGE_COLLECTOR_CONFIG.MAX_AGE) {
const unmounted = coValue.unmount();
if (unmounted) {
this.coValues.delete(coValue.id);
}
coValue.unmount();
}
}
}

View File

@@ -1,8 +1,4 @@
import {
CoValueHeader,
Transaction,
VerifiedState,
} from "./coValueCore/verifiedState.js";
import { CoValueHeader, Transaction } from "./coValueCore/verifiedState.js";
import { TRANSACTION_CONFIG } from "./config.js";
import { Signature } from "./crypto/crypto.js";
import { RawCoID, SessionID } from "./ids.js";
@@ -65,6 +61,7 @@ export function exceedsRecommendedSize(
export function knownStateFromContent(content: NewContentMessage) {
const knownState = emptyKnownState(content.id);
knownState.header = Boolean(content.header);
for (const [sessionID, session] of Object.entries(content.new)) {
knownState.sessions[sessionID as SessionID] =

View File

@@ -0,0 +1,229 @@
import { Result, err, ok } from "neverthrow";
import { ControlledAccountOrAgent } from "../coValues/account.js";
import type {
CryptoProvider,
Hash,
KeyID,
KeySecret,
SessionLogImpl,
Signature,
SignerID,
} from "../crypto/crypto.js";
import { RawCoID, SessionID } from "../ids.js";
import { parseJSON, stableStringify, Stringified } from "../jsonStringify.js";
import { JsonValue } from "../jsonValue.js";
import { CoValueKnownState } from "../sync.js";
import { TryAddTransactionsError } from "./coValueCore.js";
import { Transaction } from "./verifiedState.js";
import { exceedsRecommendedSize } from "../coValueContentMessage.js";
export type SessionLog = {
signerID?: SignerID;
impl: SessionLogImpl;
transactions: Transaction[];
lastSignature: Signature | undefined;
signatureAfter: { [txIdx: number]: Signature | undefined };
txSizeSinceLastInbetweenSignature: number;
};
export class SessionMap {
sessions: Map<SessionID, SessionLog> = new Map();
constructor(
private readonly id: RawCoID,
private readonly crypto: CryptoProvider,
) {}
get(sessionID: SessionID): SessionLog | undefined {
return this.sessions.get(sessionID);
}
private getOrCreateSessionLog(
sessionID: SessionID,
signerID?: SignerID,
): SessionLog {
let sessionLog = this.sessions.get(sessionID);
if (!sessionLog) {
sessionLog = {
signerID,
impl: this.crypto.createSessionLog(this.id, sessionID, signerID),
transactions: [],
lastSignature: undefined,
signatureAfter: {},
txSizeSinceLastInbetweenSignature: 0,
};
this.sessions.set(sessionID, sessionLog);
}
return sessionLog;
}
signerID: SignerID | undefined;
addTransaction(
sessionID: SessionID,
signerID: SignerID | undefined,
newTransactions: Transaction[],
newSignature: Signature,
skipVerify: boolean = false,
): Result<true, TryAddTransactionsError> {
const sessionLog = this.getOrCreateSessionLog(sessionID, signerID);
try {
sessionLog.impl.tryAdd(newTransactions, newSignature, skipVerify);
this.addTransactionsToJsLog(sessionLog, newTransactions, newSignature);
return ok(true as const);
} catch (e) {
return err({
type: "InvalidSignature",
id: this.id,
sessionID,
newSignature,
signerID,
} satisfies TryAddTransactionsError);
}
}
makeNewPrivateTransaction(
sessionID: SessionID,
signerAgent: ControlledAccountOrAgent,
changes: JsonValue[],
keyID: KeyID,
keySecret: KeySecret,
): { signature: Signature; transaction: Transaction } {
const sessionLog = this.getOrCreateSessionLog(
sessionID,
signerAgent.currentSignerID(),
);
const madeAt = Date.now();
const result = sessionLog.impl.addNewPrivateTransaction(
signerAgent,
changes,
keyID,
keySecret,
madeAt,
);
this.addTransactionsToJsLog(
sessionLog,
[result.transaction],
result.signature,
);
return result;
}
makeNewTrustingTransaction(
sessionID: SessionID,
signerAgent: ControlledAccountOrAgent,
changes: JsonValue[],
): { signature: Signature; transaction: Transaction } {
const sessionLog = this.getOrCreateSessionLog(
sessionID,
signerAgent.currentSignerID(),
);
const madeAt = Date.now();
const result = sessionLog.impl.addNewTrustingTransaction(
signerAgent,
changes,
madeAt,
);
this.addTransactionsToJsLog(
sessionLog,
[result.transaction],
result.signature,
);
return result;
}
private addTransactionsToJsLog(
sessionLog: SessionLog,
newTransactions: Transaction[],
signature: Signature,
) {
for (const tx of newTransactions) {
sessionLog.transactions.push(tx);
}
sessionLog.lastSignature = signature;
sessionLog.txSizeSinceLastInbetweenSignature += newTransactions.reduce(
(sum, tx) =>
sum +
(tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length),
0,
);
if (exceedsRecommendedSize(sessionLog.txSizeSinceLastInbetweenSignature)) {
sessionLog.signatureAfter[sessionLog.transactions.length - 1] = signature;
sessionLog.txSizeSinceLastInbetweenSignature = 0;
}
}
knownState(): CoValueKnownState {
const sessions: CoValueKnownState["sessions"] = {};
for (const [sessionID, sessionLog] of this.sessions.entries()) {
sessions[sessionID] = sessionLog.transactions.length;
}
return { id: this.id, header: true, sessions };
}
decryptTransaction(
sessionID: SessionID,
txIndex: number,
keySecret: KeySecret,
): JsonValue[] | undefined {
const sessionLog = this.sessions.get(sessionID);
if (!sessionLog) {
return undefined;
}
const decrypted = sessionLog.impl.decryptNextTransactionChangesJson(
txIndex,
keySecret,
);
if (!decrypted) {
return undefined;
}
return parseJSON(decrypted as Stringified<JsonValue[] | undefined>);
}
get size() {
return this.sessions.size;
}
entries() {
return this.sessions.entries();
}
values() {
return this.sessions.values();
}
keys() {
return this.sessions.keys();
}
clone(): SessionMap {
const clone = new SessionMap(this.id, this.crypto);
for (const [sessionID, sessionLog] of this.sessions) {
clone.sessions.set(sessionID, {
impl: sessionLog.impl.clone(),
transactions: sessionLog.transactions.slice(),
lastSignature: sessionLog.lastSignature,
signatureAfter: { ...sessionLog.signatureAfter },
txSizeSinceLastInbetweenSignature:
sessionLog.txSizeSinceLastInbetweenSignature,
signerID: sessionLog.signerID,
});
}
return clone;
}
}

View File

@@ -1,5 +1,5 @@
import { UpDownCounter, ValueType, metrics } from "@opentelemetry/api";
import { Result, err } from "neverthrow";
import { Result, err, ok } from "neverthrow";
import type { PeerState } from "../PeerState.js";
import type { RawCoValue } from "../coValue.js";
import type { ControlledAccountOrAgent } from "../coValues/account.js";
@@ -27,6 +27,7 @@ import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfrom
import { expectGroup } from "../typeUtils/expectGroup.js";
import { getDependedOnCoValuesFromRawData } from "./utils.js";
import { CoValueHeader, Transaction, VerifiedState } from "./verifiedState.js";
import { SessionMap } from "./SessionMap.js";
export function idforHeader(
header: CoValueHeader,
@@ -95,12 +96,7 @@ export class CoValueCore {
this.crypto = node.crypto;
if ("header" in init) {
this.id = idforHeader(init.header, node.crypto);
this._verified = new VerifiedState(
this.id,
node.crypto,
init.header,
new Map(),
);
this._verified = new VerifiedState(this.id, node.crypto, init.header);
} else {
this.id = init.id;
this._verified = null;
@@ -219,6 +215,8 @@ export class CoValueCore {
this.groupInvalidationSubscription = undefined;
}
this.node.internalDeleteCoValue(this.id);
return true;
}
@@ -296,7 +294,7 @@ export class CoValueCore {
this.id,
this.node.crypto,
header,
new Map(),
new SessionMap(this.id, this.node.crypto),
streamingKnownState,
);
@@ -433,60 +431,67 @@ export class CoValueCore {
tryAddTransactions(
sessionID: SessionID,
newTransactions: Transaction[],
givenExpectedNewHash: Hash | undefined,
newSignature: Signature,
notifyMode: "immediate" | "deferred",
skipVerify: boolean = false,
givenNewStreamingHash?: StreamingHash,
): Result<true, TryAddTransactionsError> {
return this.node
.resolveAccountAgent(
accountOrAgentIDfromSessionID(sessionID),
"Expected to know signer of transaction",
)
.andThen((agent) => {
if (!this.verified) {
return err({
type: "TriedToAddTransactionsWithoutVerifiedState",
id: this.id,
} satisfies TriedToAddTransactionsWithoutVerifiedStateErrpr);
}
let result: Result<SignerID | undefined, TryAddTransactionsError>;
const signerID = this.crypto.getAgentSignerID(agent);
if (skipVerify) {
result = ok(undefined);
} else {
result = this.node
.resolveAccountAgent(
accountOrAgentIDfromSessionID(sessionID),
"Expected to know signer of transaction",
)
.andThen((agent) => {
return ok(this.crypto.getAgentSignerID(agent));
});
}
const result = this.verified.tryAddTransactions(
sessionID,
signerID,
newTransactions,
givenExpectedNewHash,
newSignature,
skipVerify,
givenNewStreamingHash,
);
return result.andThen((signerID) => {
if (!this.verified) {
return err({
type: "TriedToAddTransactionsWithoutVerifiedState",
id: this.id,
} satisfies TriedToAddTransactionsWithoutVerifiedStateErrpr);
}
if (result.isOk()) {
if (
this._cachedContent &&
"processNewTransactions" in this._cachedContent &&
typeof this._cachedContent.processNewTransactions === "function"
) {
this._cachedContent.processNewTransactions();
} else {
this._cachedContent = undefined;
}
const result = this.verified.tryAddTransactions(
sessionID,
signerID,
newTransactions,
newSignature,
skipVerify,
);
this._cachedDependentOn = undefined;
if (result.isOk()) {
this.updateContentAndNotifyUpdate("immediate");
}
this.notifyUpdate(notifyMode);
}
return result;
});
return result;
});
}
deferredUpdates = 0;
nextDeferredNotify: Promise<void> | undefined;
updateContentAndNotifyUpdate(notifyMode: "immediate" | "deferred") {
if (
this._cachedContent &&
"processNewTransactions" in this._cachedContent &&
typeof this._cachedContent.processNewTransactions === "function"
) {
this._cachedContent.processNewTransactions();
} else {
this._cachedContent = undefined;
}
this._cachedDependentOn = undefined;
this.notifyUpdate(notifyMode);
}
notifyUpdate(notifyMode: "immediate" | "deferred") {
if (this.listeners.size === 0) {
return;
@@ -554,38 +559,6 @@ export class CoValueCore {
);
}
const madeAt = Date.now();
let transaction: Transaction;
if (privacy === "private") {
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
if (!keySecret) {
throw new Error("Can't make transaction without read key secret");
}
const encrypted = this.crypto.encryptForTransaction(changes, keySecret, {
in: this.id,
tx: this.nextTransactionID(),
});
this._decryptionCache[encrypted] = changes;
transaction = {
privacy: "private",
madeAt,
keyUsed: keyID,
encryptedChanges: encrypted,
};
} else {
transaction = {
privacy: "trusting",
madeAt,
changes: stableStringify(changes),
};
}
// This is an ugly hack to get a unique but stable session ID for editing the current account
const sessionID =
this.verified.header.meta?.type === "account"
@@ -595,39 +568,53 @@ export class CoValueCore {
) as SessionID)
: this.node.currentSessionID;
const { expectedNewHash, newStreamingHash } =
this.verified.expectedNewHashAfter(sessionID, [transaction]);
const signerAgent = this.node.getCurrentAgent();
const signature = this.crypto.sign(
this.node.getCurrentAgent().currentSignerSecret(),
expectedNewHash,
);
let result: { signature: Signature; transaction: Transaction };
const success = this.tryAddTransactions(
sessionID,
[transaction],
expectedNewHash,
signature,
"immediate",
true,
newStreamingHash,
)._unsafeUnwrap({ withStackTrace: true });
if (privacy === "private") {
const { secret: keySecret, id: keyID } = this.getCurrentReadKey();
if (success) {
const session = this.verified.sessions.get(sessionID);
const txIdx = session ? session.transactions.length - 1 : 0;
if (!keySecret) {
throw new Error("Can't make transaction without read key secret");
}
this.node.syncManager.recordTransactionsSize([transaction], "local");
this.node.syncManager.syncLocalTransaction(
this.verified,
transaction,
result = this.verified.makeNewPrivateTransaction(
sessionID,
signature,
txIdx,
signerAgent,
changes,
keyID,
keySecret,
);
if (result.transaction.privacy === "private") {
this._decryptionCache[result.transaction.encryptedChanges] = changes;
}
} else {
result = this.verified.makeNewTrustingTransaction(
sessionID,
signerAgent,
changes,
);
}
return success;
const { transaction, signature } = result;
this.node.syncManager.recordTransactionsSize([transaction], "local");
const session = this.verified.sessions.get(sessionID);
const txIdx = session ? session.transactions.length - 1 : 0;
this.updateContentAndNotifyUpdate("immediate");
this.node.syncManager.syncLocalTransaction(
this.verified,
transaction,
sessionID,
signature,
txIdx,
);
return true;
}
getCurrentContent(options?: { ignorePrivateTransactions: true }): RawCoValue {
@@ -656,6 +643,12 @@ export class CoValueCore {
ignorePrivateTransactions: boolean;
knownTransactions?: CoValueKnownState["sessions"];
}): DecryptedTransaction[] {
if (!this.verified) {
throw new Error(
"CoValueCore: getValidTransactions called on coValue without verified state",
);
}
const validTransactions = determineValidTransactions(
this,
options?.knownTransactions,
@@ -699,25 +692,12 @@ export class CoValueCore {
let decryptedChanges = this._decryptionCache[tx.encryptedChanges];
if (!decryptedChanges) {
const decryptedString = this.crypto.decryptRawForTransaction(
tx.encryptedChanges,
decryptedChanges = this.verified.decryptTransaction(
txID.sessionID,
txID.txIndex,
readKey,
{
in: this.id,
tx: txID,
},
);
try {
decryptedChanges = decryptedString && parseJSON(decryptedString);
} catch (e) {
logger.error("Failed to parse private transaction on " + this.id, {
err: e,
txID,
changes: decryptedString?.slice(0, 50),
});
continue;
}
this._decryptionCache[tx.encryptedChanges] = decryptedChanges;
}
@@ -996,7 +976,7 @@ export type InvalidSignatureError = {
id: RawCoID;
newSignature: Signature;
sessionID: SessionID;
signerID: SignerID;
signerID: SignerID | undefined;
};
export type TriedToAddTransactionsWithoutVerifiedStateErrpr = {
@@ -1004,8 +984,15 @@ export type TriedToAddTransactionsWithoutVerifiedStateErrpr = {
id: RawCoID;
};
export type TriedToAddTransactionsWithoutSignerIDError = {
type: "TriedToAddTransactionsWithoutSignerID";
id: RawCoID;
sessionID: SessionID;
};
export type TryAddTransactionsError =
| TriedToAddTransactionsWithoutVerifiedStateErrpr
| TriedToAddTransactionsWithoutSignerIDError
| ResolveAccountAgentError
| InvalidHashError
| InvalidSignatureError;

View File

@@ -10,6 +10,7 @@ import {
Encrypted,
Hash,
KeyID,
KeySecret,
Signature,
SignerID,
StreamingHash,
@@ -21,6 +22,8 @@ import { PermissionsDef as RulesetDef } from "../permissions.js";
import { CoValueKnownState, NewContentMessage } from "../sync.js";
import { InvalidHashError, InvalidSignatureError } from "./coValueCore.js";
import { TryAddTransactionsError } from "./coValueCore.js";
import { SessionLog, SessionMap } from "./SessionMap.js";
import { ControlledAccountOrAgent } from "../coValues/account.js";
export type CoValueHeader = {
type: AnyRawCoValue["type"];
@@ -48,20 +51,11 @@ export type TrustingTransaction = {
export type Transaction = PrivateTransaction | TrustingTransaction;
type SessionLog = {
readonly transactions: Transaction[];
streamingHash?: StreamingHash;
readonly signatureAfter: { [txIdx: number]: Signature | undefined };
lastSignature: Signature;
};
export type ValidatedSessions = Map<SessionID, SessionLog>;
export class VerifiedState {
readonly id: RawCoID;
readonly crypto: CryptoProvider;
readonly header: CoValueHeader;
readonly sessions: ValidatedSessions;
readonly sessions: SessionMap;
private _cachedKnownState?: CoValueKnownState;
private _cachedNewContentSinceEmpty: NewContentMessage[] | undefined;
private streamingKnownState?: CoValueKnownState["sessions"];
@@ -71,88 +65,87 @@ export class VerifiedState {
id: RawCoID,
crypto: CryptoProvider,
header: CoValueHeader,
sessions: ValidatedSessions,
sessions?: SessionMap,
streamingKnownState?: CoValueKnownState["sessions"],
) {
this.id = id;
this.crypto = crypto;
this.header = header;
this.sessions = sessions;
this.sessions = sessions ?? new SessionMap(id, crypto);
this.streamingKnownState = streamingKnownState
? { ...streamingKnownState }
: undefined;
}
clone(): VerifiedState {
// do a deep clone, including the sessions
const clonedSessions = new Map();
for (let [sessionID, sessionLog] of this.sessions) {
clonedSessions.set(sessionID, {
lastSignature: sessionLog.lastSignature,
streamingHash: sessionLog.streamingHash?.clone(),
signatureAfter: { ...sessionLog.signatureAfter },
transactions: sessionLog.transactions.slice(),
} satisfies SessionLog);
}
return new VerifiedState(
this.id,
this.crypto,
this.header,
clonedSessions,
this.streamingKnownState,
this.sessions.clone(),
this.streamingKnownState ? { ...this.streamingKnownState } : undefined,
);
}
tryAddTransactions(
sessionID: SessionID,
signerID: SignerID,
signerID: SignerID | undefined,
newTransactions: Transaction[],
givenExpectedNewHash: Hash | undefined,
newSignature: Signature,
skipVerify: boolean = false,
givenNewStreamingHash?: StreamingHash,
): Result<true, TryAddTransactionsError> {
if (skipVerify === true) {
this.doAddTransactions(
sessionID,
newTransactions,
newSignature,
givenNewStreamingHash,
);
} else {
const { expectedNewHash, newStreamingHash } = this.expectedNewHashAfter(
sessionID,
newTransactions,
);
const result = this.sessions.addTransaction(
sessionID,
signerID,
newTransactions,
newSignature,
skipVerify,
);
if (givenExpectedNewHash && givenExpectedNewHash !== expectedNewHash) {
return err({
type: "InvalidHash",
id: this.id,
expectedNewHash,
givenExpectedNewHash,
} satisfies InvalidHashError);
}
if (!this.crypto.verify(newSignature, expectedNewHash, signerID)) {
return err({
type: "InvalidSignature",
id: this.id,
newSignature,
sessionID,
signerID,
} satisfies InvalidSignatureError);
}
this.doAddTransactions(
sessionID,
newTransactions,
newSignature,
newStreamingHash,
);
if (result.isOk()) {
this._cachedNewContentSinceEmpty = undefined;
this._cachedKnownState = undefined;
}
return ok(true as const);
return result;
}
makeNewTrustingTransaction(
sessionID: SessionID,
signerAgent: ControlledAccountOrAgent,
changes: JsonValue[],
) {
const result = this.sessions.makeNewTrustingTransaction(
sessionID,
signerAgent,
changes,
);
this._cachedNewContentSinceEmpty = undefined;
this._cachedKnownState = undefined;
return result;
}
makeNewPrivateTransaction(
sessionID: SessionID,
signerAgent: ControlledAccountOrAgent,
changes: JsonValue[],
keyID: KeyID,
keySecret: KeySecret,
) {
const result = this.sessions.makeNewPrivateTransaction(
sessionID,
signerAgent,
changes,
keyID,
keySecret,
);
this._cachedNewContentSinceEmpty = undefined;
this._cachedKnownState = undefined;
return result;
}
getLastSignatureCheckpoint(sessionID: SessionID): number {
@@ -166,78 +159,6 @@ export class VerifiedState {
);
}
private doAddTransactions(
sessionID: SessionID,
newTransactions: Transaction[],
newSignature: Signature,
newStreamingHash?: StreamingHash,
) {
const sessionLog = this.sessions.get(sessionID);
const transactions = sessionLog?.transactions ?? [];
for (const tx of newTransactions) {
transactions.push(tx);
}
const signatureAfter = sessionLog?.signatureAfter ?? {};
const lastInbetweenSignatureIdx =
this.getLastSignatureCheckpoint(sessionID);
const sizeOfTxsSinceLastInbetweenSignature = transactions
.slice(lastInbetweenSignatureIdx + 1)
.reduce((sum, tx) => sum + getTransactionSize(tx), 0);
if (exceedsRecommendedSize(sizeOfTxsSinceLastInbetweenSignature)) {
signatureAfter[transactions.length - 1] = newSignature;
}
this.sessions.set(sessionID, {
transactions,
streamingHash: newStreamingHash,
lastSignature: newSignature,
signatureAfter: signatureAfter,
});
this._cachedNewContentSinceEmpty = undefined;
this._cachedKnownState = undefined;
}
expectedNewHashAfter(
sessionID: SessionID,
newTransactions: Transaction[],
): { expectedNewHash: Hash; newStreamingHash: StreamingHash } {
const sessionLog = this.sessions.get(sessionID);
if (!sessionLog?.streamingHash) {
const streamingHash = new StreamingHash(this.crypto);
const oldTransactions = sessionLog?.transactions ?? [];
for (const transaction of oldTransactions) {
streamingHash.update(transaction);
}
for (const transaction of newTransactions) {
streamingHash.update(transaction);
}
return {
expectedNewHash: streamingHash.digest(),
newStreamingHash: streamingHash,
};
}
const streamingHash = sessionLog.streamingHash.clone();
for (const transaction of newTransactions) {
streamingHash.update(transaction);
}
return {
expectedNewHash: streamingHash.digest(),
newStreamingHash: streamingHash,
};
}
newContentSince(
knownState: CoValueKnownState | undefined,
): NewContentMessage[] | undefined {
@@ -424,6 +345,14 @@ export class VerifiedState {
sessions,
};
}
decryptTransaction(
sessionID: SessionID,
txIndex: number,
keySecret: KeySecret,
): JsonValue[] | undefined {
return this.sessions.decryptTransaction(sessionID, txIndex, keySecret);
}
}
function getNextKnownSignatureIdx(

View File

@@ -92,6 +92,10 @@ export class ControlledAccount implements ControlledAccountOrAgent {
account: RawAccount<AccountMeta>;
agentSecret: AgentSecret;
_cachedCurrentAgentID: AgentID | undefined;
_cachedCurrentSignerID: SignerID | undefined;
_cachedCurrentSignerSecret: SignerSecret | undefined;
_cachedCurrentSealerID: SealerID | undefined;
_cachedCurrentSealerSecret: SealerSecret | undefined;
crypto: CryptoProvider;
constructor(account: RawAccount<AccountMeta>, agentSecret: AgentSecret) {
@@ -114,19 +118,39 @@ export class ControlledAccount implements ControlledAccountOrAgent {
}
currentSignerID() {
return this.crypto.getAgentSignerID(this.currentAgentID());
if (this._cachedCurrentSignerID) {
return this._cachedCurrentSignerID;
}
const signerID = this.crypto.getAgentSignerID(this.currentAgentID());
this._cachedCurrentSignerID = signerID;
return signerID;
}
currentSignerSecret(): SignerSecret {
return this.crypto.getAgentSignerSecret(this.agentSecret);
if (this._cachedCurrentSignerSecret) {
return this._cachedCurrentSignerSecret;
}
const signerSecret = this.crypto.getAgentSignerSecret(this.agentSecret);
this._cachedCurrentSignerSecret = signerSecret;
return signerSecret;
}
currentSealerID() {
return this.crypto.getAgentSealerID(this.currentAgentID());
if (this._cachedCurrentSealerID) {
return this._cachedCurrentSealerID;
}
const sealerID = this.crypto.getAgentSealerID(this.currentAgentID());
this._cachedCurrentSealerID = sealerID;
return sealerID;
}
currentSealerSecret(): SealerSecret {
return this.crypto.getAgentSealerSecret(this.agentSecret);
if (this._cachedCurrentSealerSecret) {
return this._cachedCurrentSealerSecret;
}
const sealerSecret = this.crypto.getAgentSealerSecret(this.agentSecret);
this._cachedCurrentSealerSecret = sealerSecret;
return sealerSecret;
}
}

View File

@@ -370,16 +370,24 @@ export class RawGroup<
if (role === "writeOnly" || role === "writeOnlyInvite") {
const previousRole = this.get(memberKey);
this.set(memberKey, role, "trusting");
if (
previousRole === "admin" &&
memberKey !== this.core.node.getCurrentAgent().id
) {
throw new Error(
"Administrators cannot demote other administrators in a group",
);
}
if (
previousRole === "reader" ||
previousRole === "writer" ||
previousRole === "admin"
) {
this.rotateReadKey();
this.rotateReadKey(memberKey);
}
this.set(memberKey, role, "trusting");
this.internalCreateWriteOnlyKeyForMember(memberKey, agent);
} else {
const currentReadKey = this.getCurrentReadKey();

View File

@@ -3,23 +3,32 @@ import { ed25519, x25519 } from "@noble/curves/ed25519";
import { blake3 } from "@noble/hashes/blake3";
import { base58 } from "@scure/base";
import { base64URLtoBytes, bytesToBase64url } from "../base64url.js";
import { RawCoID, TransactionID } from "../ids.js";
import {
PrivateTransaction,
Transaction,
TrustingTransaction,
} from "../coValueCore/verifiedState.js";
import { RawCoID, SessionID, TransactionID } from "../ids.js";
import { Stringified, stableStringify } from "../jsonStringify.js";
import { JsonValue } from "../jsonValue.js";
import { logger } from "../logger.js";
import {
CryptoProvider,
Encrypted,
KeyID,
KeySecret,
Sealed,
SealerID,
SealerSecret,
SessionLogImpl,
Signature,
SignerID,
SignerSecret,
StreamingHash,
textDecoder,
textEncoder,
} from "./crypto.js";
import { ControlledAccountOrAgent } from "../coValues/account.js";
type Blake3State = ReturnType<typeof blake3.create>;
@@ -67,7 +76,7 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
return this.blake3HashOnce(input).slice(0, 24);
}
protected generateJsonNonce(material: JsonValue): Uint8Array {
generateJsonNonce(material: JsonValue): Uint8Array {
return this.generateNonce(textEncoder.encode(stableStringify(material)));
}
@@ -199,4 +208,199 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
return undefined;
}
}
createSessionLog(
coID: RawCoID,
sessionID: SessionID,
signerID?: SignerID,
): SessionLogImpl {
return new PureJSSessionLog(coID, sessionID, signerID, this);
}
}
export class PureJSSessionLog implements SessionLogImpl {
transactions: string[] = [];
lastSignature: Signature | undefined;
streamingHash: Blake3State;
constructor(
private readonly coID: RawCoID,
private readonly sessionID: SessionID,
private readonly signerID: SignerID | undefined,
private readonly crypto: PureJSCrypto,
) {
this.streamingHash = this.crypto.emptyBlake3State();
}
clone(): SessionLogImpl {
const newLog = new PureJSSessionLog(
this.coID,
this.sessionID,
this.signerID,
this.crypto,
);
newLog.transactions = this.transactions.slice();
newLog.lastSignature = this.lastSignature;
newLog.streamingHash = this.crypto.cloneBlake3State(this.streamingHash);
return newLog;
}
tryAdd(
transactions: Transaction[],
newSignature: Signature,
skipVerify: boolean,
): void {
this.internalTryAdd(
transactions.map((tx) => stableStringify(tx)),
newSignature,
skipVerify,
);
}
internalTryAdd(
transactions: string[],
newSignature: Signature,
skipVerify: boolean,
) {
if (!skipVerify) {
if (!this.signerID) {
throw new Error("Tried to add transactions without signer ID");
}
const checkHasher = this.crypto.cloneBlake3State(this.streamingHash);
for (const tx of transactions) {
checkHasher.update(textEncoder.encode(tx));
}
const newHash = checkHasher.digest();
const newHashEncoded = `hash_z${base58.encode(newHash)}`;
if (!this.crypto.verify(newSignature, newHashEncoded, this.signerID)) {
throw new Error("Signature verification failed");
}
}
for (const tx of transactions) {
this.crypto.blake3IncrementalUpdate(
this.streamingHash,
textEncoder.encode(tx),
);
this.transactions.push(tx);
}
this.lastSignature = newSignature;
return newSignature;
}
expectedHashAfter(transactionsJson: string[]): string {
const hasher = this.crypto.cloneBlake3State(this.streamingHash);
for (const tx of transactionsJson) {
hasher.update(textEncoder.encode(tx));
}
const newHash = hasher.digest();
return `hash_z${base58.encode(newHash)}`;
}
internalAddNewTransaction(
transaction: string,
signerAgent: ControlledAccountOrAgent,
) {
this.crypto.blake3IncrementalUpdate(
this.streamingHash,
textEncoder.encode(transaction),
);
const newHash = this.crypto.blake3DigestForState(this.streamingHash);
const newHashEncoded = `hash_z${base58.encode(newHash)}`;
const signature = this.crypto.sign(
signerAgent.currentSignerSecret(),
newHashEncoded,
);
this.transactions.push(transaction);
this.lastSignature = signature;
return signature;
}
addNewPrivateTransaction(
signerAgent: ControlledAccountOrAgent,
changes: JsonValue[],
keyID: KeyID,
keySecret: KeySecret,
madeAt: number,
): { signature: Signature; transaction: PrivateTransaction } {
const encryptedChanges = this.crypto.encrypt(changes, keySecret, {
in: this.coID,
tx: { sessionID: this.sessionID, txIndex: this.transactions.length },
});
const tx = {
encryptedChanges: encryptedChanges,
madeAt: madeAt,
privacy: "private",
keyUsed: keyID,
} satisfies Transaction;
const signature = this.internalAddNewTransaction(
stableStringify(tx),
signerAgent,
);
return {
signature: signature as Signature,
transaction: tx,
};
}
addNewTrustingTransaction(
signerAgent: ControlledAccountOrAgent,
changes: JsonValue[],
madeAt: number,
): { signature: Signature; transaction: TrustingTransaction } {
const tx = {
changes: stableStringify(changes),
madeAt: madeAt,
privacy: "trusting",
} satisfies Transaction;
const signature = this.internalAddNewTransaction(
stableStringify(tx),
signerAgent,
);
return {
signature: signature as Signature,
transaction: tx,
};
}
decryptNextTransactionChangesJson(
txIndex: number,
keySecret: KeySecret,
): string {
const txJson = this.transactions[txIndex];
if (!txJson) {
throw new Error("Transaction not found");
}
const tx = JSON.parse(txJson) as Transaction;
if (tx.privacy === "private") {
const nOnceMaterial = {
in: this.coID,
tx: { sessionID: this.sessionID, txIndex: txIndex },
};
const nOnce = this.crypto.generateJsonNonce(nOnceMaterial);
const ciphertext = base64URLtoBytes(
tx.encryptedChanges.substring("encrypted_U".length),
);
const keySecretBytes = base58.decode(
keySecret.substring("keySecret_z".length),
);
const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext);
return textDecoder.decode(plaintext);
} else {
return tx.changes;
}
}
free(): void {
// no-op
}
}

View File

@@ -1,4 +1,6 @@
import {
SessionLog,
initialize,
Blake3Hasher,
blake3_empty_state,
blake3_hash_once,
@@ -7,16 +9,15 @@ import {
encrypt,
get_sealer_id,
get_signer_id,
initialize,
new_ed25519_signing_key,
new_x25519_private_key,
seal,
sign,
unseal,
verify,
} from "jazz-crypto-rs";
} from "cojson-core-wasm";
import { base64URLtoBytes, bytesToBase64url } from "../base64url.js";
import { RawCoID, TransactionID } from "../ids.js";
import { RawCoID, SessionID, TransactionID } from "../ids.js";
import { Stringified, stableStringify } from "../jsonStringify.js";
import { JsonValue } from "../jsonValue.js";
import { logger } from "../logger.js";
@@ -24,6 +25,8 @@ import { PureJSCrypto } from "./PureJSCrypto.js";
import {
CryptoProvider,
Encrypted,
Hash,
KeyID,
KeySecret,
Sealed,
SealerID,
@@ -34,11 +37,17 @@ import {
textDecoder,
textEncoder,
} from "./crypto.js";
import { ControlledAccountOrAgent } from "../coValues/account.js";
import {
PrivateTransaction,
Transaction,
TrustingTransaction,
} from "../coValueCore/verifiedState.js";
type Blake3State = Blake3Hasher;
/**
* WebAssembly implementation of the CryptoProvider interface using jazz-crypto-rs.
* WebAssembly implementation of the CryptoProvider interface using cojson-core-wasm.
* This provides the primary implementation using WebAssembly for optimal performance, offering:
* - Signing/verifying (Ed25519)
* - Encryption/decryption (XSalsa20)
@@ -195,4 +204,86 @@ export class WasmCrypto extends CryptoProvider<Blake3State> {
return undefined;
}
}
createSessionLog(coID: RawCoID, sessionID: SessionID, signerID?: SignerID) {
return new SessionLogAdapter(new SessionLog(coID, sessionID, signerID));
}
}
class SessionLogAdapter {
constructor(private readonly sessionLog: SessionLog) {}
tryAdd(
transactions: Transaction[],
newSignature: Signature,
skipVerify: boolean,
): void {
this.sessionLog.tryAdd(
transactions.map((tx) => stableStringify(tx)),
newSignature,
skipVerify,
);
}
addNewPrivateTransaction(
signerAgent: ControlledAccountOrAgent,
changes: JsonValue[],
keyID: KeyID,
keySecret: KeySecret,
madeAt: number,
): { signature: Signature; transaction: PrivateTransaction } {
const output = this.sessionLog.addNewPrivateTransaction(
stableStringify(changes),
signerAgent.currentSignerSecret(),
keySecret,
keyID,
madeAt,
);
const parsedOutput = JSON.parse(output);
const transaction: PrivateTransaction = {
privacy: "private",
madeAt,
encryptedChanges: parsedOutput.encrypted_changes,
keyUsed: keyID,
};
return { signature: parsedOutput.signature, transaction };
}
addNewTrustingTransaction(
signerAgent: ControlledAccountOrAgent,
changes: JsonValue[],
madeAt: number,
): { signature: Signature; transaction: TrustingTransaction } {
const stringifiedChanges = stableStringify(changes);
const output = this.sessionLog.addNewTrustingTransaction(
stringifiedChanges,
signerAgent.currentSignerSecret(),
madeAt,
);
const transaction: TrustingTransaction = {
privacy: "trusting",
madeAt,
changes: stringifiedChanges,
};
return { signature: output as Signature, transaction };
}
decryptNextTransactionChangesJson(
txIndex: number,
keySecret: KeySecret,
): string {
const output = this.sessionLog.decryptNextTransactionChangesJson(
txIndex,
keySecret,
);
return output;
}
free() {
this.sessionLog.free();
}
clone(): SessionLogAdapter {
return new SessionLogAdapter(this.sessionLog.clone());
}
}

View File

@@ -1,10 +1,15 @@
import { base58 } from "@scure/base";
import { RawAccountID } from "../coValues/account.js";
import { ControlledAccountOrAgent, RawAccountID } from "../coValues/account.js";
import { AgentID, RawCoID, TransactionID } from "../ids.js";
import { SessionID } from "../ids.js";
import { Stringified, parseJSON, stableStringify } from "../jsonStringify.js";
import { JsonValue } from "../jsonValue.js";
import { logger } from "../logger.js";
import {
PrivateTransaction,
Transaction,
TrustingTransaction,
} from "../coValueCore/verifiedState.js";
function randomBytes(bytesLength = 32): Uint8Array {
return crypto.getRandomValues(new Uint8Array(bytesLength));
@@ -297,6 +302,12 @@ export abstract class CryptoProvider<Blake3State = any> {
newRandomSessionID(accountID: RawAccountID | AgentID): SessionID {
return `${accountID}_session_z${base58.encode(this.randomBytes(8))}`;
}
abstract createSessionLog(
coID: RawCoID,
sessionID: SessionID,
signerID?: SignerID,
): SessionLogImpl;
}
export type Hash = `hash_z${string}`;
@@ -341,3 +352,29 @@ export type KeySecret = `keySecret_z${string}`;
export type KeyID = `key_z${string}`;
export const secretSeedLength = 32;
export interface SessionLogImpl {
clone(): SessionLogImpl;
tryAdd(
transactions: Transaction[],
newSignature: Signature,
skipVerify: boolean,
): void;
addNewPrivateTransaction(
signerAgent: ControlledAccountOrAgent,
changes: JsonValue[],
keyID: KeyID,
keySecret: KeySecret,
madeAt: number,
): { signature: Signature; transaction: PrivateTransaction };
addNewTrustingTransaction(
signerAgent: ControlledAccountOrAgent,
changes: JsonValue[],
madeAt: number,
): { signature: Signature; transaction: TrustingTransaction };
decryptNextTransactionChangesJson(
tx_index: number,
key_secret: KeySecret,
): string;
free(): void;
}

View File

@@ -132,17 +132,25 @@ export class LocalNode {
return accountOrAgentIDfromSessionID(this.currentSessionID);
}
_cachedCurrentAgent: ControlledAccountOrAgent | undefined;
getCurrentAgent(): ControlledAccountOrAgent {
const accountOrAgent = this.getCurrentAccountOrAgentID();
if (isAgentID(accountOrAgent)) {
return new ControlledAgent(this.agentSecret, this.crypto);
if (!this._cachedCurrentAgent) {
const accountOrAgent = this.getCurrentAccountOrAgentID();
if (isAgentID(accountOrAgent)) {
this._cachedCurrentAgent = new ControlledAgent(
this.agentSecret,
this.crypto,
);
} else {
this._cachedCurrentAgent = new ControlledAccount(
expectAccount(
this.expectCoValueLoaded(accountOrAgent).getCurrentContent(),
),
this.agentSecret,
);
}
}
return new ControlledAccount(
expectAccount(
this.expectCoValueLoaded(accountOrAgent).getCurrentContent(),
),
this.agentSecret,
);
return this._cachedCurrentAgent;
}
expectCurrentAccountID(reason: string): RawAccountID {
@@ -360,7 +368,7 @@ export class LocalNode {
const coValue = this.putCoValue(
id,
new VerifiedState(id, this.crypto, header, new Map()),
new VerifiedState(id, this.crypto, header),
);
this.garbageCollector?.trackCoValueAccess(coValue);

View File

@@ -31,7 +31,23 @@ export type PermissionsDef =
| { type: "ownedByGroup"; group: RawCoID }
| { type: "unsafeAllowAll" };
export type AccountRole = "reader" | "writer" | "admin" | "writeOnly";
export type AccountRole =
/**
* Can read the group's CoValues
*/
| "reader"
/**
* Can read and write to the group's CoValues
*/
| "writer"
/**
* Can read and write to the group, and change group member roles
*/
| "admin"
/**
* Can only write to the group's CoValues and read their own changes
*/
| "writeOnly";
export type Role =
| AccountRole

View File

@@ -132,6 +132,11 @@ export class SyncManager {
peers: { [key: PeerID]: PeerState } = {};
local: LocalNode;
// When true, transactions will not be verified.
// This is useful when syncing only for storage purposes, with the expectation that
// the transactions have already been verified by the [trusted] peer that sent them.
private skipVerify: boolean = false;
peersCounter = metrics.getMeter("cojson").createUpDownCounter("jazz.peers", {
description: "Amount of connected peers",
valueType: ValueType.INT,
@@ -154,6 +159,10 @@ export class SyncManager {
syncState: SyncStateManager;
disableTransactionVerification() {
this.skipVerify = true;
}
peersInPriorityOrder(): PeerState[] {
return Object.values(this.peers).sort((a, b) => {
const aPriority = a.priority || 0;
@@ -634,9 +643,8 @@ export class SyncManager {
const result = coValue.tryAddTransactions(
sessionID,
newTransactions,
undefined,
newContentForSession.lastSignature,
"immediate",
this.skipVerify,
);
if (result.isErr()) {

View File

@@ -0,0 +1,153 @@
import { assert, beforeEach, describe, expect, it } from "vitest";
import {
loadCoValueOrFail,
setCurrentTestCryptoProvider,
setupTestNode,
setupTestAccount,
randomAgentAndSessionID,
} from "./testUtils";
import { PureJSCrypto } from "../crypto/PureJSCrypto";
import { stableStringify } from "../jsonStringify";
const jsCrypto = await PureJSCrypto.create();
setCurrentTestCryptoProvider(jsCrypto);
let syncServer: ReturnType<typeof setupTestNode>;
beforeEach(() => {
syncServer = setupTestNode({ isSyncServer: true });
});
// A suite of tests focused on high-level tests that verify:
// - Keys creation and unsealing
// - Signature creation and verification
// - Encryption and decryption of values
describe("PureJSCrypto", () => {
it("successfully creates a private CoValue and reads it in another session", async () => {
const client = setupTestNode({
connected: true,
});
const group = client.node.createGroup();
const map = group.createMap();
map.set("count", 0, "private");
map.set("count", 1, "private");
map.set("count", 2, "private");
const client2 = client.spawnNewSession();
const mapInTheOtherSession = await loadCoValueOrFail(client2.node, map.id);
expect(mapInTheOtherSession.get("count")).toEqual(2);
});
it("successfully updates a private CoValue and reads it in another session", async () => {
const client = setupTestNode({
connected: true,
});
const group = client.node.createGroup();
const map = group.createMap();
map.set("count", 0, "private");
map.set("count", 1, "private");
map.set("count", 2, "private");
const client2 = client.spawnNewSession();
const mapInTheOtherSession = await loadCoValueOrFail(client2.node, map.id);
mapInTheOtherSession.set("count", 3, "private");
await mapInTheOtherSession.core.waitForSync();
expect(mapInTheOtherSession.get("count")).toEqual(3);
});
it("can invite another account to a group and share a private CoValue", async () => {
const client = setupTestNode({
connected: true,
});
const account = await setupTestAccount({
connected: true,
});
const group = client.node.createGroup();
const invite = group.createInvite("admin");
await account.node.acceptInvite(group.id, invite);
const map = group.createMap();
map.set("secret", "private-data", "private");
// The other account should be able to read the private value
const mapInOtherSession = await loadCoValueOrFail(account.node, map.id);
expect(mapInOtherSession.get("secret")).toEqual("private-data");
mapInOtherSession.set("secret", "modified", "private");
await mapInOtherSession.core.waitForSync();
expect(map.get("secret")).toEqual("modified");
});
it("rejects sessions with invalid signatures", async () => {
const client = setupTestNode({
connected: true,
});
const group = client.node.createGroup();
const map = group.createMap();
map.set("count", 0, "trusting");
// Create a new session with the same agent
const client2 = client.spawnNewSession();
// This should work normally
const mapInOtherSession = await loadCoValueOrFail(client2.node, map.id);
expect(mapInOtherSession.get("count")).toEqual(0);
mapInOtherSession.core.tryAddTransactions(
client2.node.currentSessionID,
[
{
privacy: "trusting",
changes: stableStringify([{ op: "set", key: "count", value: 1 }]),
madeAt: Date.now(),
},
],
"signature_z12345678",
true,
);
const content =
mapInOtherSession.core.verified.newContentSince(undefined)?.[0];
assert(content);
client.node.syncManager.handleNewContent(content, "storage");
expect(map.get("count")).toEqual(0);
});
});
describe("PureJSSessionLog", () => {
it("fails to verify signatures without a signer ID", async () => {
const agentSecret = jsCrypto.newRandomAgentSecret();
const sessionID = jsCrypto.newRandomSessionID(
jsCrypto.getAgentID(agentSecret),
);
const sessionLog = jsCrypto.createSessionLog("co_z12345678", sessionID);
expect(() =>
sessionLog.tryAdd(
[
{
privacy: "trusting",
changes: stableStringify([{ op: "set", key: "count", value: 1 }]),
madeAt: Date.now(),
},
],
"signature_z12345678",
false,
),
).toThrow("Tried to add transactions without signer ID");
});
});

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