Compare commits

..

56 Commits

Author SHA1 Message Date
Guido D'Orsi
1e625f3c12 chore: changeset 2025-02-18 18:14:39 +01:00
Guido D'Orsi
8b3686c7ce feat: use the uniqueSessions index to get the single coValue session 2025-02-18 18:12:47 +01:00
Guido D'Orsi
bce04ee06d chore: restore the transactions autobatching 2025-02-18 18:05:53 +01:00
Guido D'Orsi
f2e9115f4c fix: improve transactions management on IDB 2025-02-18 17:51:32 +01:00
Guido D'Orsi
ee0897d9a8 fix: improve the rollback on failure when handling new content in storage 2025-02-18 14:42:29 +01:00
Guido D'Orsi
1d71ca1511 feat: add React 19 to the peerDependencies 2025-02-17 15:32:04 +01:00
Guido D'Orsi
a37dc1c22f test: cover another case on existing account migrations 2025-02-17 13:01:59 +01:00
Benjamin S. Leveritt
774f232390 Merge pull request #1307 from garden-co/jazz-679-create-crypto-rust-crate
Jazz 679 Use jazz-crypto-rs
2025-02-17 12:00:46 +00:00
Benjamin S. Leveritt
12c19fc940 Merge pull request #1396 from garden-co/jazz-713-fix-llms-fulltxt-content
Fixes llms-full.txt, adds sanity tests
2025-02-17 11:56:29 +00:00
Benjamin S. Leveritt
338f5421f4 Adds test task 2025-02-17 11:26:38 +00:00
Benjamin S. Leveritt
e0daca300b Fixes llms-full.txt, adds sanity tests 2025-02-17 11:07:16 +00:00
Benjamin S. Leveritt
5c76e37f14 Adds changeset 2025-02-12 21:35:05 +00:00
Benjamin S. Leveritt
0117d0c9b9 Bumps jazz-crypto version 2025-02-12 21:21:34 +00:00
Benjamin S. Leveritt
b90c766c05 Cleans up WasmCrypto.create 2025-02-12 21:21:13 +00:00
Benjamin S. Leveritt
262a36e456 Bumps dep 2025-02-12 20:20:27 +00:00
Benjamin S. Leveritt
cb1df65beb Refactors to use stateful Blake3Hasher 2025-02-12 20:20:26 +00:00
Benjamin S. Leveritt
ea91e63ff2 Bumps jazz-crypto-rs version 2025-02-12 20:20:26 +00:00
Benjamin S. Leveritt
8eae2eb31e Fixes test 2025-02-12 20:20:26 +00:00
Benjamin S. Leveritt
c9044f5123 Adds npm dep 2025-02-12 20:20:26 +00:00
Benjamin S. Leveritt
24340173fa Picks up that can
#riseandshinemrfreeman
2025-02-12 20:19:48 +00:00
Benjamin S. Leveritt
53e88993a0 Moves jazz-crypto-rs into own repo 2025-02-12 20:18:47 +00:00
Benjamin S. Leveritt
ece168878b Use external dep 2025-02-12 20:18:47 +00:00
Benjamin S. Leveritt
cad84db52b Adds comments 2025-02-12 20:18:47 +00:00
Benjamin S. Leveritt
342a385111 Simplifies build process 2025-02-12 20:18:47 +00:00
Benjamin S. Leveritt
f87ba7d927 Fixes seal port 2025-02-12 20:18:47 +00:00
Benjamin S. Leveritt
7c7f55b85c Ports high-level encrypt and decrypt fns 2025-02-12 20:18:46 +00:00
Benjamin S. Leveritt
0e5b9f5292 Fixes unused lint 2025-02-12 20:18:46 +00:00
Benjamin S. Leveritt
2f5af3dece Ports get_sealer_id 2025-02-12 20:18:46 +00:00
Benjamin S. Leveritt
2c35e2ba85 Ports signing fns 2025-02-12 20:18:46 +00:00
Benjamin S. Leveritt
0a4f79d5a4 Adds wasm-pack via pnpm 2025-02-12 20:18:46 +00:00
Benjamin S. Leveritt
43cb7abba7 Exclude target output from formatter 2025-02-12 20:18:46 +00:00
Benjamin S. Leveritt
25f76f6b02 Updates README 2025-02-12 20:18:46 +00:00
Benjamin S. Leveritt
6a56561c98 Refactor into modules 2025-02-12 20:18:45 +00:00
Benjamin S. Leveritt
2ac31e7c51 Removes unused berith dep 2025-02-12 20:18:45 +00:00
Benjamin S. Leveritt
1bbefab5a9 Ports final ed25519 fns 2025-02-12 20:18:45 +00:00
Benjamin S. Leveritt
1143b32cf3 Remove generateNonce from crypto, as only used by PureJS class 2025-02-12 20:18:45 +00:00
Benjamin S. Leveritt
51ada27810 Refactors to pass nonceMaterial to reduce wasm boundary crossing 2025-02-12 20:18:45 +00:00
Benjamin S. Leveritt
954ecb3984 Ports xsalsa20 encrypt/decrypt fns to Rust 2025-02-12 20:18:45 +00:00
Benjamin S. Leveritt
05089270d9 Refactors to pass nonceMaterial, rather than nonces 2025-02-12 20:18:45 +00:00
Benjamin S. Leveritt
fecc81111a Ports seal/unseal to rs lib 2025-02-12 20:18:45 +00:00
Benjamin S. Leveritt
4d3e7dbcd5 Adds X25519 fns 2025-02-12 20:18:44 +00:00
Benjamin S. Leveritt
ee65f18fd9 Add note about rust-analyzer 2025-02-12 20:18:44 +00:00
Benjamin S. Leveritt
bcbc4636ed Moves randomBytes up into crypto class to remove duplication 2025-02-12 20:18:44 +00:00
Benjamin S. Leveritt
8c323c4513 Fixes type 2025-02-12 20:18:44 +00:00
Benjamin S. Leveritt
4103ea0c88 Removes hash-wasm dep 2025-02-12 20:18:44 +00:00
Benjamin S. Leveritt
733ebec902 Ports more Blake3 functions 2025-02-12 19:50:23 +00:00
Benjamin S. Leveritt
10a3834668 Ports blake3_hash with context 2025-02-12 19:50:23 +00:00
Benjamin S. Leveritt
593c3aeb6e Port blake3_hash_once 2025-02-12 19:50:23 +00:00
Benjamin S. Leveritt
a55d71c28d Fixes exported fn for node 2025-02-12 19:49:53 +00:00
Benjamin S. Leveritt
c030c7a57e Adds isomorphic generate_nonce function to rs lib 2025-02-12 19:49:53 +00:00
Benjamin S. Leveritt
e5b4c0448a Refactor into nonceGeneration 2025-02-12 19:49:53 +00:00
Benjamin S. Leveritt
0d516a3c6a Adds a test for unsealed error 2025-02-12 19:49:53 +00:00
Benjamin S. Leveritt
271ff3eb40 Adds comments to crypto libs 2025-02-12 19:49:53 +00:00
Benjamin S. Leveritt
dcc836ff98 Imports jazz-crypto-rs into cojson 2025-02-12 19:49:53 +00:00
Benjamin S. Leveritt
22da4ea136 Add jazz-crypto-rs lib 2025-02-12 18:27:21 +00:00
Benjamin S. Leveritt
80e86c92b2 Add rust tools to nix dev environment 2025-02-12 18:27:21 +00:00
33 changed files with 800 additions and 593 deletions

View File

@@ -0,0 +1,8 @@
---
"jazz-react-auth-clerk": patch
"jazz-react-core": patch
"hash-slash": patch
"jazz-react": patch
---
Add React 19 to the peer dependencies

View File

@@ -0,0 +1,8 @@
---
"cojson-storage-indexeddb": patch
"cojson-storage-rn-sqlite": patch
"cojson-storage-sqlite": patch
"cojson-storage": patch
---
Improve rollback on error when failing to add new content

View File

@@ -0,0 +1,5 @@
---
"cojson": patch
---
Ports Wasm crypto functions to use exported library `jazz-crypto-rs`

View File

@@ -16,15 +16,18 @@
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# General development
git
# JS development
nodejs_22
nodePackages.pnpm
git
];
shellHook = ''
echo ""
echo "Welcome to the Jazz development environment!"
echo "Run 'pnpm install' to install the dependencies."
echo "Run 'pnpm install' to install dependencies."
echo ""
'';
};

View File

@@ -229,7 +229,10 @@ async function readMdxContent(url) {
const relativePath = url.replace(/^\/docs\/?/, "");
// Base directory for docs
const baseDir = path.join(process.cwd(), "app/docs/[framework]/[...slug]");
const baseDir = path.join(
process.cwd(),
"app/(docs)/docs/[framework]/[...slug]",
);
// If it's a directory, try to read all framework variants
const fullPath = path.join(baseDir, relativePath);

View File

@@ -0,0 +1,24 @@
import assert from "node:assert";
import fs from "node:fs/promises";
import path from "node:path";
import { test } from "node:test";
test("Size test", async () => {
const filePath = path.join(process.cwd(), "public", "llms-full.txt");
const stats = await fs.stat(filePath);
assert.ok(
stats.size > 100 * 1024,
"llms-full.txt should be larger than 100kb", // Somewhat arbitrary, but it's a good sanity check
);
});
test("Content test", async () => {
const filePath = path.join(process.cwd(), "public", "llms-full.txt");
const content = await fs.readFile(filePath, "utf-8");
assert.ok(
content.includes(
'Jazz authentication is based on cryptographic keys ("Account keys").',
),
"Should contain authentication message", // From authentication, it's unlikely to change much
);
});

View File

@@ -13,7 +13,9 @@
"generate:docs": "node generate-docs/typedocs.mjs --build",
"generate:llm-docs:all": "pnpm run generate:llm-docs:concise && pnpm run generate:llm-docs:full",
"generate:llm-docs:concise": "node generate-docs/llms.mjs",
"generate:llm-docs:full": "node generate-docs/llms-full.mjs"
"generate:llm-docs:full": "node generate-docs/llms-full.mjs",
"test": "pnpm run test:llm-docs",
"test:llm-docs": "node generate-docs/llms-full.test.mjs"
},
"packageManager": "pnpm@9.14.0",
"dependencies": {

View File

@@ -0,0 +1,111 @@
export type StoreName =
| "coValues"
| "sessions"
| "transactions"
| "signatureAfter";
// A access unit for the IndexedDB Jazz database
// It's a wrapper around the IDBTransaction object that helps on batching multiple operations
// in a single transaction.
export class CoJsonIDBTransaction {
db: IDBDatabase;
tx: IDBTransaction;
pendingRequests: ((txEntry: this) => void)[] = [];
rejectHandlers: (() => void)[] = [];
id = Math.random();
running = false;
failed = false;
done = false;
constructor(db: IDBDatabase) {
this.db = db;
this.tx = this.db.transaction(
["coValues", "sessions", "transactions", "signatureAfter"],
"readwrite",
);
this.tx.oncomplete = () => {
this.done = true;
};
this.tx.onabort = () => {
this.done = true;
};
}
startedAt = performance.now();
isReusable() {
const delta = performance.now() - this.startedAt;
return !this.done && delta <= 20;
}
getObjectStore(name: StoreName) {
return this.tx.objectStore(name);
}
private pushRequest<T>(
handler: (txEntry: this, next: () => void) => Promise<T>,
) {
const next = () => {
const next = this.pendingRequests.shift();
if (next) {
next(this);
} else {
this.running = false;
this.done = true;
}
};
if (this.running) {
return new Promise<T>((resolve, reject) => {
this.rejectHandlers.push(reject);
this.pendingRequests.push(async () => {
try {
const result = await handler(this, next);
resolve(result);
} catch (error) {
reject(error);
}
});
});
}
this.running = true;
return handler(this, next);
}
handleRequest<T>(handler: (txEntry: this) => IDBRequest<T>) {
return this.pushRequest<T>((txEntry, next) => {
return new Promise<T>((resolve, reject) => {
const request = handler(txEntry);
request.onerror = () => {
this.failed = true;
this.tx.abort();
console.error(request.error);
reject(request.error);
// Don't leave any pending promise
for (const handler of this.rejectHandlers) {
handler();
}
};
request.onsuccess = () => {
resolve(request.result as T);
next();
};
});
});
}
commit() {
if (!this.done) {
this.tx.commit();
}
}
}

View File

@@ -1,4 +1,4 @@
import type { CojsonInternalTypes, RawCoID } from "cojson";
import type { CojsonInternalTypes, RawCoID, SessionID } from "cojson";
import type {
CoValueRow,
DBClientInterface,
@@ -8,119 +8,60 @@ import type {
StoredSessionRow,
TransactionRow,
} from "cojson-storage";
import { SyncPromise } from "./syncPromises.js";
import { CoJsonIDBTransaction } from "./CoJsonIDBTransaction.js";
export class IDBClient implements DBClientInterface {
private db;
currentTx:
| {
id: number;
tx: IDBTransaction;
stores: {
coValues: IDBObjectStore;
sessions: IDBObjectStore;
transactions: IDBObjectStore;
signatureAfter: IDBObjectStore;
};
startedAt: number;
pendingRequests: ((txEntry: {
stores: {
coValues: IDBObjectStore;
sessions: IDBObjectStore;
transactions: IDBObjectStore;
signatureAfter: IDBObjectStore;
};
}) => void)[];
}
| undefined;
currentTxID = 0;
activeTransaction: CoJsonIDBTransaction | undefined;
autoBatchingTransaction: CoJsonIDBTransaction | undefined;
constructor(db: IDBDatabase) {
this.db = db;
}
makeRequest<T>(
handler: (stores: {
coValues: IDBObjectStore;
sessions: IDBObjectStore;
transactions: IDBObjectStore;
signatureAfter: IDBObjectStore;
}) => IDBRequest,
): SyncPromise<T> {
return new SyncPromise((resolve, reject) => {
let txEntry = this.currentTx;
handler: (txEntry: CoJsonIDBTransaction) => IDBRequest<T>,
): Promise<T> {
if (this.activeTransaction) {
return this.activeTransaction.handleRequest<T>(handler);
}
const requestEntry = ({
stores,
}: {
stores: {
coValues: IDBObjectStore;
sessions: IDBObjectStore;
transactions: IDBObjectStore;
signatureAfter: IDBObjectStore;
};
}) => {
const request = handler(stores);
request.onerror = () => {
console.error("Error in request", request.error);
this.currentTx = undefined;
reject(request.error);
};
request.onsuccess = () => {
const value = request.result as T;
resolve(value);
if (this.autoBatchingTransaction?.isReusable()) {
return this.autoBatchingTransaction.handleRequest<T>(handler);
}
const next = txEntry?.pendingRequests.shift();
const tx = new CoJsonIDBTransaction(this.db);
if (next) {
next({ stores });
} else {
if (this.currentTx === txEntry) {
this.currentTx = undefined;
}
}
};
};
this.autoBatchingTransaction = tx;
// Transaction batching
if (!txEntry || performance.now() - txEntry.startedAt > 20) {
const tx = this.db.transaction(
["coValues", "sessions", "transactions", "signatureAfter"],
"readwrite",
);
txEntry = {
id: this.currentTxID++,
tx,
stores: {
coValues: tx.objectStore("coValues"),
sessions: tx.objectStore("sessions"),
transactions: tx.objectStore("transactions"),
signatureAfter: tx.objectStore("signatureAfter"),
},
startedAt: performance.now(),
pendingRequests: [],
};
this.currentTx = txEntry;
requestEntry(txEntry);
} else {
txEntry.pendingRequests.push(requestEntry);
}
});
return tx.handleRequest<T>(handler);
}
async getCoValue(coValueId: RawCoID): Promise<StoredCoValueRow | undefined> {
return this.makeRequest<StoredCoValueRow | undefined>(({ coValues }) =>
coValues.index("coValuesById").get(coValueId),
return this.makeRequest<StoredCoValueRow | undefined>((tx) =>
tx.getObjectStore("coValues").index("coValuesById").get(coValueId),
);
}
async getCoValueSessions(coValueRowId: number): Promise<StoredSessionRow[]> {
return this.makeRequest<StoredSessionRow[]>(({ sessions }) =>
sessions.index("sessionsByCoValue").getAll(coValueRowId),
return this.makeRequest<StoredSessionRow[]>((tx) =>
tx
.getObjectStore("sessions")
.index("sessionsByCoValue")
.getAll(coValueRowId),
);
}
async getSingleCoValueSession(
coValueRowId: number,
sessionID: SessionID,
): Promise<StoredSessionRow | undefined> {
return this.makeRequest<StoredSessionRow>((tx) =>
tx
.getObjectStore("sessions")
.index("uniqueSessions")
.get([coValueRowId, sessionID]),
);
}
@@ -128,13 +69,15 @@ export class IDBClient implements DBClientInterface {
sessionRowId: number,
firstNewTxIdx: number,
): Promise<TransactionRow[]> {
return this.makeRequest<TransactionRow[]>(({ transactions }) =>
transactions.getAll(
IDBKeyRange.bound(
[sessionRowId, firstNewTxIdx],
[sessionRowId, Number.POSITIVE_INFINITY],
return this.makeRequest<TransactionRow[]>((tx) =>
tx
.getObjectStore("transactions")
.getAll(
IDBKeyRange.bound(
[sessionRowId, firstNewTxIdx],
[sessionRowId, Number.POSITIVE_INFINITY],
),
),
),
);
}
@@ -142,9 +85,10 @@ export class IDBClient implements DBClientInterface {
sessionRowId: number,
firstNewTxIdx: number,
): Promise<SignatureAfterRow[]> {
return this.makeRequest<SignatureAfterRow[]>(
({ signatureAfter }: { signatureAfter: IDBObjectStore }) =>
signatureAfter.getAll(
return this.makeRequest<SignatureAfterRow[]>((tx) =>
tx
.getObjectStore("signatureAfter")
.getAll(
IDBKeyRange.bound(
[sessionRowId, firstNewTxIdx],
[sessionRowId, Number.POSITIVE_INFINITY],
@@ -160,8 +104,8 @@ export class IDBClient implements DBClientInterface {
throw new Error(`Header is required, coId: ${msg.id}`);
}
return (await this.makeRequest<IDBValidKey>(({ coValues }) =>
coValues.put({
return (await this.makeRequest<IDBValidKey>((tx) =>
tx.getObjectStore("coValues").put({
id: msg.id,
// biome-ignore lint/style/noNonNullAssertion: TODO(JAZZ-561): Review
header: msg.header!,
@@ -176,25 +120,26 @@ export class IDBClient implements DBClientInterface {
sessionUpdate: SessionRow;
sessionRow?: StoredSessionRow;
}): Promise<number> {
return this.makeRequest<number>(({ sessions }) =>
sessions.put(
sessionRow?.rowID
? {
rowID: sessionRow.rowID,
...sessionUpdate,
}
: sessionUpdate,
),
return this.makeRequest<number>(
(tx) =>
tx.getObjectStore("sessions").put(
sessionRow?.rowID
? {
rowID: sessionRow.rowID,
...sessionUpdate,
}
: sessionUpdate,
) as IDBRequest<number>,
);
}
addTransaction(
async addTransaction(
sessionRowID: number,
idx: number,
newTransaction: CojsonInternalTypes.Transaction,
) {
return this.makeRequest(({ transactions }) =>
transactions.add({
await this.makeRequest((tx) =>
tx.getObjectStore("transactions").add({
ses: sessionRowID,
idx,
tx: newTransaction,
@@ -211,8 +156,8 @@ export class IDBClient implements DBClientInterface {
idx: number;
signature: CojsonInternalTypes.Signature;
}) {
return this.makeRequest(({ signatureAfter }) =>
signatureAfter.put({
return this.makeRequest((tx) =>
tx.getObjectStore("signatureAfter").put({
ses: sessionRowID,
idx,
signature,
@@ -220,7 +165,24 @@ export class IDBClient implements DBClientInterface {
);
}
async unitOfWork(operationsCallback: () => unknown[]) {
return Promise.all(operationsCallback());
closeTransaction(tx: CoJsonIDBTransaction) {
tx.commit();
if (tx === this.activeTransaction) {
this.activeTransaction = undefined;
}
}
async transaction(operationsCallback: () => unknown) {
const tx = new CoJsonIDBTransaction(this.db);
this.activeTransaction = tx;
try {
await operationsCallback();
tx.commit(); // Tells the browser to not wait for another possible request and commit the transaction immediately
} finally {
this.activeTransaction = undefined;
}
}
}

View File

@@ -33,18 +33,7 @@ export class IDBNode {
}
await this.syncManager.handleSyncMessage(msg);
} catch (e) {
console.error(
new Error(
`Error reading from localNode, handling msg\n\n${JSON.stringify(
msg,
(k, v) =>
k === "changes" || k === "encryptedChanges"
? `${v.slice(0, 20)}...`
: v,
)}`,
{ cause: e },
),
);
console.error(e);
}
}
};

View File

@@ -1,224 +0,0 @@
const isFunction = (func: any) => typeof func === "function";
const isObject = (supposedObject: any) =>
typeof supposedObject === "object" &&
supposedObject !== null &&
!Array.isArray(supposedObject);
const isThenable = (obj: any) => isObject(obj) && isFunction(obj.then);
const identity = (co: any) => co;
export { identity, isFunction, isObject, isThenable };
enum States {
PENDING = "PENDING",
RESOLVED = "RESOLVED",
REJECTED = "REJECTED",
}
interface Handler<T, U> {
onSuccess: HandlerOnSuccess<T, U>;
onFail: HandlerOnFail<U>;
}
type HandlerOnSuccess<T, U = any> = (value: T) => U | Thenable<U>;
type HandlerOnFail<U = any> = (reason: any) => U | Thenable<U>;
type Finally<U> = () => U | Thenable<U>;
interface Thenable<T> {
then<U>(
onSuccess?: HandlerOnSuccess<T, U>,
onFail?: HandlerOnFail<U>,
): Thenable<U>;
then<U>(
onSuccess?: HandlerOnSuccess<T, U>,
onFail?: (reason: any) => void,
): Thenable<U>;
}
type Resolve<R> = (value?: R | Thenable<R>) => void;
type Reject = (value?: any) => void;
export class SyncPromise<T> {
private state: States = States.PENDING;
private handlers: Handler<T, any>[] = [];
private value: T | any;
public constructor(callback: (resolve: Resolve<T>, reject: Reject) => void) {
try {
callback(this.resolve as Resolve<T>, this.reject);
} catch (e) {
this.reject(e);
}
}
private resolve = (value: T) => {
return this.setResult(value, States.RESOLVED);
};
private reject = (reason: any) => {
return this.setResult(reason, States.REJECTED);
};
private setResult = (value: T | any, state: States) => {
const set = () => {
if (this.state !== States.PENDING) {
return null;
}
if (isThenable(value)) {
return (value as Thenable<T>).then(this.resolve, this.reject);
}
this.value = value;
this.state = state;
return this.executeHandlers();
};
void set();
};
private executeHandlers = () => {
if (this.state === States.PENDING) {
return null;
}
for (const handler of this.handlers) {
if (this.state === States.REJECTED) {
handler.onFail(this.value);
} else {
handler.onSuccess(this.value);
}
}
this.handlers = [];
};
private attachHandler = (handler: Handler<T, any>) => {
this.handlers = [...this.handlers, handler];
this.executeHandlers();
};
// biome-ignore lint/suspicious/noThenProperty: TODO(JAZZ-561): Review
public then<U>(onSuccess: HandlerOnSuccess<T, U>, onFail?: HandlerOnFail<U>) {
return new SyncPromise<U>((resolve, reject) => {
return this.attachHandler({
onSuccess: (result) => {
try {
return resolve(onSuccess(result));
} catch (e) {
return reject(e);
}
},
onFail: (reason) => {
if (!onFail) {
return reject(reason);
}
try {
return resolve(onFail(reason));
} catch (e) {
return reject(e);
}
},
});
});
}
public catch<U>(onFail: HandlerOnFail<U>) {
return this.then<U>(identity, onFail);
}
// methods
public toString() {
return "[object SyncPromise]";
}
public finally<U>(cb: Finally<U>) {
return new SyncPromise<U>((resolve, reject) => {
let co: U | any;
let isRejected: boolean;
return this.then(
(value) => {
isRejected = false;
co = value;
return cb();
},
(reason) => {
isRejected = true;
co = reason;
return cb();
},
).then(() => {
if (isRejected) {
return reject(co);
}
return resolve(co);
});
});
}
public spread<U>(handler: (...args: any[]) => U) {
return this.then<U>((collection) => {
if (Array.isArray(collection)) {
return handler(...collection);
}
return handler(collection);
});
}
// static
public static resolve<U = any>(value?: U | Thenable<U>) {
return new SyncPromise<U>((resolve) => {
return resolve(value);
});
}
public static reject<U>(reason?: any) {
return new SyncPromise<U>((_resolve, reject) => {
return reject(reason);
});
}
public static all<U = any>(collection: (U | Thenable<U>)[]) {
return new SyncPromise<U[]>((resolve, reject) => {
if (!Array.isArray(collection)) {
return reject(new TypeError("An array must be provided."));
}
if (collection.length === 0) {
return resolve([]);
}
let counter = collection.length;
const resolvedCollection: U[] = [];
const tryResolve = (value: U, index: number) => {
counter -= 1;
resolvedCollection[index] = value;
if (counter !== 0) {
return null;
}
return resolve(resolvedCollection);
};
return collection.forEach((item, index) => {
return SyncPromise.resolve(item)
.then((value) => {
return tryResolve(value, index);
})
.catch(reject);
});
});
}
}

View File

@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { CoJsonIDBTransaction } from "../CoJsonIDBTransaction";
const TEST_DB_NAME = "test-cojson-idb-transaction";
describe("CoJsonIDBTransaction", () => {
let db: IDBDatabase;
beforeEach(async () => {
// Create test database
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open(TEST_DB_NAME, 1);
request.onerror = () => reject(request.error);
request.onupgradeneeded = (event) => {
const db = request.result;
// Create test stores
db.createObjectStore("coValues", { keyPath: "id" });
const sessions = db.createObjectStore("sessions", { keyPath: "id" });
sessions.createIndex("uniqueSessions", ["coValue", "sessionID"], {
unique: true,
});
db.createObjectStore("transactions", { keyPath: "id" });
db.createObjectStore("signatureAfter", { keyPath: "id" });
};
request.onsuccess = () => {
db = request.result;
resolve();
};
});
});
afterEach(async () => {
// Close and delete test database
db.close();
await new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(TEST_DB_NAME);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
});
test("handles successful write and read operations", async () => {
const tx = new CoJsonIDBTransaction(db);
// Write test
await tx.handleRequest((tx) =>
tx.getObjectStore("coValues").put({
id: "test1",
value: "hello",
}),
);
// Read test
const readTx = new CoJsonIDBTransaction(db);
const result = await readTx.handleRequest((tx) =>
tx.getObjectStore("coValues").get("test1"),
);
expect(result).toEqual({
id: "test1",
value: "hello",
});
});
test("handles multiple operations in single transaction", async () => {
const tx = new CoJsonIDBTransaction(db);
// Multiple writes
await Promise.all([
tx.handleRequest((tx) =>
tx.getObjectStore("coValues").put({
id: "test1",
value: "hello",
}),
),
tx.handleRequest((tx) =>
tx.getObjectStore("coValues").put({
id: "test2",
value: "world",
}),
),
]);
// Read results
const readTx = new CoJsonIDBTransaction(db);
const [result1, result2] = await Promise.all([
readTx.handleRequest((tx) => tx.getObjectStore("coValues").get("test1")),
readTx.handleRequest((tx) => tx.getObjectStore("coValues").get("test2")),
]);
expect(result1).toEqual({
id: "test1",
value: "hello",
});
expect(result2).toEqual({
id: "test2",
value: "world",
});
});
test("handles transaction across multiple stores", async () => {
const tx = new CoJsonIDBTransaction(db);
await Promise.all([
tx.handleRequest((tx) =>
tx.getObjectStore("coValues").put({
id: "value1",
data: "value data",
}),
),
tx.handleRequest((tx) =>
tx.getObjectStore("sessions").put({
id: "session1",
data: "session data",
}),
),
]);
const readTx = new CoJsonIDBTransaction(db);
const [valueResult, sessionResult] = await Promise.all([
readTx.handleRequest((tx) => tx.getObjectStore("coValues").get("value1")),
readTx.handleRequest((tx) =>
tx.getObjectStore("sessions").get("session1"),
),
]);
expect(valueResult).toEqual({
id: "value1",
data: "value data",
});
expect(sessionResult).toEqual({
id: "session1",
data: "session data",
});
});
test("handles failed transactions", async () => {
const tx = new CoJsonIDBTransaction(db);
await expect(
tx.handleRequest((tx) =>
tx.getObjectStore("sessions").put({
id: 1,
coValue: "value1",
sessionID: "session1",
data: "session data",
}),
),
).resolves.toBe(1);
expect(tx.failed).toBe(false);
const badTx = new CoJsonIDBTransaction(db);
await expect(
badTx.handleRequest((tx) =>
tx.getObjectStore("sessions").put({
id: 2,
coValue: "value1",
sessionID: "session1",
data: "session data",
}),
),
).rejects.toThrow();
expect(badTx.failed).toBe(true);
});
});

View File

@@ -1,5 +1,10 @@
import { type DB as DatabaseT } from "@op-engineering/op-sqlite";
import { CojsonInternalTypes, type OutgoingSyncQueue, RawCoID } from "cojson";
import {
CojsonInternalTypes,
type OutgoingSyncQueue,
RawCoID,
SessionID,
} from "cojson";
import type {
DBClientInterface,
SessionRow,
@@ -48,6 +53,17 @@ export class SQLiteClient implements DBClientInterface {
return rows as StoredSessionRow[];
}
async getSingleCoValueSession(
coValueRowId: number,
sessionID: SessionID,
): Promise<StoredSessionRow | undefined> {
const { rows } = await this.db.execute(
"SELECT * FROM sessions WHERE coValue = ? AND sessionID = ?",
[coValueRowId, sessionID],
);
return rows[0] as StoredSessionRow | undefined;
}
async getNewTransactionInSession(
sessionRowId: number,
firstNewTxIdx: number,
@@ -142,12 +158,10 @@ export class SQLiteClient implements DBClientInterface {
);
}
async unitOfWork(
operationsCallback: () => Promise<unknown>[],
): Promise<void> {
async transaction(operationsCallback: () => unknown) {
try {
await this.db.transaction(async () => {
await Promise.all(operationsCallback());
await operationsCallback();
});
} catch (e) {
console.error("Transaction failed:", e);

View File

@@ -71,6 +71,17 @@ export class SQLiteClient implements DBClientInterface {
.all(coValueRowId) as StoredSessionRow[];
}
getSingleCoValueSession(
coValueRowId: number,
sessionID: SessionID,
): StoredSessionRow | undefined {
return this.db
.prepare<[number, string]>(
`SELECT * FROM sessions WHERE coValue = ? AND sessionID = ?`,
)
.get(coValueRowId, sessionID) as StoredSessionRow | undefined;
}
getNewTransactionInSession(
sessionRowId: number,
firstNewTxIdx: number,
@@ -159,7 +170,7 @@ export class SQLiteClient implements DBClientInterface {
.run(sessionRowID, idx, signature);
}
unitOfWork(operationsCallback: () => any[]) {
transaction(operationsCallback: () => unknown) {
this.db.transaction(operationsCallback)();
}
}

View File

@@ -200,15 +200,6 @@ export class SyncManager {
? coValueRow.rowID
: await this.dbClient.addCoValue(msg);
const allOurSessionsEntries =
await this.dbClient.getCoValueSessions(storedCoValueRowID);
const allOurSessions: {
[sessionID: SessionID]: StoredSessionRow;
} = Object.fromEntries(
allOurSessionsEntries.map((row) => [row.sessionID, row]),
);
const ourKnown: CojsonInternalTypes.CoValueKnownState = {
id: msg.id,
header: true,
@@ -217,9 +208,13 @@ export class SyncManager {
let invalidAssumptions = false;
await this.dbClient.unitOfWork(() =>
(Object.keys(msg.new) as SessionID[]).map((sessionID) => {
const sessionRow = allOurSessions[sessionID];
for (const sessionID of Object.keys(msg.new) as SessionID[]) {
await this.dbClient.transaction(async () => {
const sessionRow = await this.dbClient.getSingleCoValueSession(
storedCoValueRowID,
sessionID,
);
if (sessionRow) {
ourKnown.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
}
@@ -229,8 +224,8 @@ export class SyncManager {
} else {
return this.putNewTxs(msg, sessionID, sessionRow, storedCoValueRowID);
}
}),
);
});
}
if (invalidAssumptions) {
this.sendStateMessage({

View File

@@ -45,9 +45,11 @@ describe("DB sync manager", () => {
const DBClient = vi.fn();
DBClient.prototype.getCoValue = vi.fn();
DBClient.prototype.getCoValueSessions = vi.fn();
DBClient.prototype.getSingleCoValueSession = vi.fn();
DBClient.prototype.getNewTransactionInSession = vi.fn();
DBClient.prototype.addSessionUpdate = vi.fn();
DBClient.prototype.addTransaction = vi.fn();
DBClient.prototype.unitOfWork = vi.fn((callback) => Promise.all(callback()));
DBClient.prototype.transaction = vi.fn((callback) => callback());
beforeEach(async () => {
const idbClient = new DBClient() as unknown as Mocked<DBClientInterface>;

View File

@@ -41,6 +41,11 @@ export interface DBClientInterface {
coValueRowId: number,
): Promise<StoredSessionRow[]> | StoredSessionRow[];
getSingleCoValueSession(
coValueRowId: number,
sessionID: SessionID,
): Promise<StoredSessionRow | undefined> | StoredSessionRow | undefined;
getNewTransactionInSession(
sessionRowId: number,
firstNewTxIdx: number,
@@ -79,5 +84,5 @@ export interface DBClientInterface {
signature: Signature;
}): Promise<number> | void | unknown;
unitOfWork(operationsCallback: () => unknown[]): Promise<unknown> | void;
transaction(callback: () => unknown): Promise<unknown> | void;
}

View File

@@ -32,13 +32,12 @@
"vitest": "3.0.5"
},
"dependencies": {
"@hazae41/berith": "^1.2.6",
"@noble/ciphers": "^0.1.3",
"@noble/curves": "^1.3.0",
"@noble/hashes": "^1.4.0",
"@opentelemetry/api": "^1.0.0",
"@scure/base": "1.2.1",
"hash-wasm": "^4.9.0",
"jazz-crypto-rs": "0.0.3",
"neverthrow": "^7.0.1",
"queueueue": "^4.1.2"
},

View File

@@ -1,5 +1,4 @@
import { xsalsa20, xsalsa20_poly1305 } from "@noble/ciphers/salsa";
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
import { ed25519, x25519 } from "@noble/curves/ed25519";
import { blake3 } from "@noble/hashes/blake3";
import { base58 } from "@scure/base";
@@ -24,21 +23,24 @@ import {
type Blake3State = ReturnType<typeof blake3.create>;
/**
* Pure JavaScript implementation of the CryptoProvider interface using noble-curves and noble-ciphers libraries.
* This provides a fallback implementation that doesn't require WebAssembly, offering:
* - Signing/verifying (Ed25519)
* - Encryption/decryption (XSalsa20)
* - Sealing/unsealing (X25519 + XSalsa20-Poly1305)
* - Hashing (BLAKE3)
*/
export class PureJSCrypto extends CryptoProvider<Blake3State> {
static async create(): Promise<PureJSCrypto> {
return new PureJSCrypto();
}
randomBytes(length: number): Uint8Array {
return randomBytes(length);
}
emptyBlake3State(): Blake3State {
return blake3.create({});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cloneBlake3State(state: any): Blake3State {
cloneBlake3State(state: Blake3State): Blake3State {
return state.clone();
}
@@ -61,6 +63,14 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
return state.clone().digest();
}
generateNonce(input: Uint8Array): Uint8Array {
return this.blake3HashOnce(input).slice(0, 24);
}
private generateJsonNonce(material: JsonValue): Uint8Array {
return this.generateNonce(textEncoder.encode(stableStringify(material)));
}
newEd25519SigningKey(): Uint8Array {
return ed25519.utils.randomPrivateKey();
}
@@ -109,9 +119,7 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
const keySecretBytes = base58.decode(
keySecret.substring("keySecret_z".length),
);
const nOnce = this.blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)),
).slice(0, 24);
const nOnce = this.generateJsonNonce(nOnceMaterial);
const plaintext = textEncoder.encode(stableStringify(value));
const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext);
@@ -126,9 +134,7 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
const keySecretBytes = base58.decode(
keySecret.substring("keySecret_z".length),
);
const nOnce = this.blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)),
).slice(0, 24);
const nOnce = this.generateJsonNonce(nOnceMaterial);
const ciphertext = base64URLtoBytes(
encrypted.substring("encrypted_U".length),
@@ -149,9 +155,7 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
to: SealerID;
nOnceMaterial: { in: RawCoID; tx: TransactionID };
}): Sealed<T> {
const nOnce = this.blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)),
).slice(0, 24);
const nOnce = this.generateJsonNonce(nOnceMaterial);
const sealerPub = base58.decode(to.substring("sealer_z".length));
@@ -174,9 +178,7 @@ export class PureJSCrypto extends CryptoProvider<Blake3State> {
from: SealerID,
nOnceMaterial: { in: RawCoID; tx: TransactionID },
): T | undefined {
const nOnce = this.blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)),
).slice(0, 24);
const nOnce = this.generateJsonNonce(nOnceMaterial);
const sealerPriv = base58.decode(sealer.substring("sealerSecret_z".length));

View File

@@ -1,16 +1,19 @@
import {
Ed25519Signature,
Ed25519SigningKey,
Ed25519VerifyingKey,
Memory,
X25519PublicKey,
X25519StaticSecret,
initBundledOnce,
} from "@hazae41/berith";
import { xsalsa20, xsalsa20_poly1305 } from "@noble/ciphers/salsa";
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
import { base58 } from "@scure/base";
import { createBLAKE3 } from "hash-wasm";
Blake3Hasher,
blake3_empty_state,
blake3_hash_once,
blake3_hash_once_with_context,
decrypt,
encrypt,
get_sealer_id,
get_signer_id,
new_ed25519_signing_key,
new_x25519_private_key,
seal,
sign,
unseal,
verify,
} from "jazz-crypto-rs";
import { base64URLtoBytes, bytesToBase64url } from "../base64url.js";
import { RawCoID, TransactionID } from "../ids.js";
import { Stringified, stableStringify } from "../jsonStringify.js";
@@ -30,103 +33,82 @@ import {
textEncoder,
} from "./crypto.js";
export class WasmCrypto extends CryptoProvider<Uint8Array> {
private constructor(
public blake3Instance: Awaited<ReturnType<typeof createBLAKE3>>,
) {
type Blake3State = Blake3Hasher;
/**
* WebAssembly implementation of the CryptoProvider interface using jazz-crypto-rs.
* This provides the primary implementation using WebAssembly for optimal performance, offering:
* - Signing/verifying (Ed25519)
* - Encryption/decryption (XSalsa20)
* - Sealing/unsealing (X25519 + XSalsa20-Poly1305)
* - Hashing (BLAKE3)
*/
export class WasmCrypto extends CryptoProvider<Blake3State> {
private constructor() {
super();
}
static async create(): Promise<WasmCrypto> {
return Promise.all([createBLAKE3(), initBundledOnce()]).then(
([blake3instance]) => new WasmCrypto(blake3instance),
);
return new WasmCrypto();
}
randomBytes(length: number): Uint8Array {
return randomBytes(length);
emptyBlake3State(): Blake3State {
return blake3_empty_state();
}
emptyBlake3State(): Uint8Array {
return this.blake3Instance.init().save();
}
cloneBlake3State(state: Uint8Array): Uint8Array {
return this.blake3Instance.load(state).save();
cloneBlake3State(state: Blake3State): Blake3State {
return state.clone();
}
blake3HashOnce(data: Uint8Array) {
return this.blake3Instance.init().update(data).digest("binary");
return blake3_hash_once(data);
}
blake3HashOnceWithContext(
data: Uint8Array,
{ context }: { context: Uint8Array },
) {
return this.blake3Instance
.init()
.update(context)
.update(data)
.digest("binary");
return blake3_hash_once_with_context(data, context);
}
blake3IncrementalUpdate(state: Uint8Array, data: Uint8Array): Uint8Array {
return this.blake3Instance.load(state).update(data).save();
blake3IncrementalUpdate(state: Blake3State, data: Uint8Array): Blake3State {
state.update(data);
return state;
}
blake3DigestForState(state: Uint8Array): Uint8Array {
return this.blake3Instance.load(state).digest("binary");
blake3DigestForState(state: Blake3State): Uint8Array {
return state.finalize();
}
newEd25519SigningKey(): Uint8Array {
return new Ed25519SigningKey().to_bytes().copyAndDispose();
return new_ed25519_signing_key();
}
getSignerID(secret: SignerSecret): SignerID {
return `signer_z${base58.encode(
Ed25519SigningKey.from_bytes(
new Memory(base58.decode(secret.substring("signerSecret_z".length))),
)
.public()
.to_bytes()
.copyAndDispose(),
)}`;
return get_signer_id(textEncoder.encode(secret)) as SignerID;
}
sign(secret: SignerSecret, message: JsonValue): Signature {
const signature = Ed25519SigningKey.from_bytes(
new Memory(base58.decode(secret.substring("signerSecret_z".length))),
)
.sign(new Memory(textEncoder.encode(stableStringify(message))))
.to_bytes()
.copyAndDispose();
return `signature_z${base58.encode(signature)}`;
return sign(
textEncoder.encode(stableStringify(message)),
textEncoder.encode(secret),
) as Signature;
}
verify(signature: Signature, message: JsonValue, id: SignerID): boolean {
return new Ed25519VerifyingKey(
new Memory(base58.decode(id.substring("signer_z".length))),
).verify(
new Memory(textEncoder.encode(stableStringify(message))),
new Ed25519Signature(
new Memory(base58.decode(signature.substring("signature_z".length))),
),
return verify(
textEncoder.encode(signature),
textEncoder.encode(stableStringify(message)),
textEncoder.encode(id),
);
}
newX25519StaticSecret(): Uint8Array {
return new X25519StaticSecret().to_bytes().copyAndDispose();
return new_x25519_private_key();
}
getSealerID(secret: SealerSecret): SealerID {
return `sealer_z${base58.encode(
X25519StaticSecret.from_bytes(
new Memory(base58.decode(secret.substring("sealerSecret_z".length))),
)
.to_public()
.to_bytes()
.copyAndDispose(),
)}`;
return get_sealer_id(textEncoder.encode(secret)) as SealerID;
}
encrypt<T extends JsonValue, N extends JsonValue>(
@@ -134,16 +116,13 @@ export class WasmCrypto extends CryptoProvider<Uint8Array> {
keySecret: KeySecret,
nOnceMaterial: N,
): Encrypted<T, N> {
const keySecretBytes = base58.decode(
keySecret.substring("keySecret_z".length),
);
const nOnce = this.blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)),
).slice(0, 24);
const plaintext = textEncoder.encode(stableStringify(value));
const ciphertext = xsalsa20(keySecretBytes, nOnce, plaintext);
return `encrypted_U${bytesToBase64url(ciphertext)}` as Encrypted<T, N>;
return `encrypted_U${bytesToBase64url(
encrypt(
textEncoder.encode(stableStringify(value)),
keySecret,
textEncoder.encode(stableStringify(nOnceMaterial)),
),
)}` as Encrypted<T, N>;
}
decryptRaw<T extends JsonValue, N extends JsonValue>(
@@ -151,19 +130,13 @@ export class WasmCrypto extends CryptoProvider<Uint8Array> {
keySecret: KeySecret,
nOnceMaterial: N,
): Stringified<T> {
const keySecretBytes = base58.decode(
keySecret.substring("keySecret_z".length),
);
const nOnce = this.blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)),
).slice(0, 24);
const ciphertext = base64URLtoBytes(
encrypted.substring("encrypted_U".length),
);
const plaintext = xsalsa20(keySecretBytes, nOnce, ciphertext);
return textDecoder.decode(plaintext) as Stringified<T>;
return textDecoder.decode(
decrypt(
base64URLtoBytes(encrypted.substring("encrypted_U".length)),
keySecret,
textEncoder.encode(stableStringify(nOnceMaterial)),
),
) as Stringified<T>;
}
seal<T extends JsonValue>({
@@ -177,26 +150,14 @@ export class WasmCrypto extends CryptoProvider<Uint8Array> {
to: SealerID;
nOnceMaterial: { in: RawCoID; tx: TransactionID };
}): Sealed<T> {
const nOnce = this.blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)),
).slice(0, 24);
const sealerPub = base58.decode(to.substring("sealer_z".length));
const senderPriv = base58.decode(from.substring("sealerSecret_z".length));
const plaintext = textEncoder.encode(stableStringify(message));
const sharedSecret = X25519StaticSecret.from_bytes(new Memory(senderPriv))
.diffie_hellman(X25519PublicKey.from_bytes(new Memory(sealerPub)))
.to_bytes()
.copyAndDispose();
const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt(
plaintext,
);
return `sealed_U${bytesToBase64url(sealedBytes)}` as Sealed<T>;
return `sealed_U${bytesToBase64url(
seal(
textEncoder.encode(stableStringify(message)),
from,
to,
textEncoder.encode(stableStringify(nOnceMaterial)),
),
)}` as Sealed<T>;
}
unseal<T extends JsonValue>(
@@ -205,31 +166,21 @@ export class WasmCrypto extends CryptoProvider<Uint8Array> {
from: SealerID,
nOnceMaterial: { in: RawCoID; tx: TransactionID },
): T | undefined {
const nOnce = this.blake3HashOnce(
textEncoder.encode(stableStringify(nOnceMaterial)),
).slice(0, 24);
const sealerPriv = base58.decode(sealer.substring("sealerSecret_z".length));
const senderPub = base58.decode(from.substring("sealer_z".length));
const sealedBytes = base64URLtoBytes(sealed.substring("sealed_U".length));
const sharedSecret = X25519StaticSecret.from_bytes(new Memory(sealerPriv))
.diffie_hellman(X25519PublicKey.from_bytes(new Memory(senderPub)))
.to_bytes()
.copyAndDispose();
const plaintext = xsalsa20_poly1305(sharedSecret, nOnce).decrypt(
sealedBytes,
const plaintext = textDecoder.decode(
unseal(
base64URLtoBytes(sealed.substring("sealed_U".length)),
sealer,
from,
textEncoder.encode(stableStringify(nOnceMaterial)),
),
);
try {
return JSON.parse(textDecoder.decode(plaintext));
return JSON.parse(plaintext) as T;
} catch (e) {
logger.error(
"Failed to decrypt/parse sealed message: " + (e as Error)?.message,
);
return undefined;
}
}
}

View File

@@ -1,3 +1,4 @@
import { randomBytes } from "@noble/ciphers/webcrypto/utils";
import { base58 } from "@scure/base";
import { RawAccountID } from "../coValues/account.js";
import { AgentID, RawCoID, TransactionID } from "../ids.js";
@@ -21,7 +22,9 @@ export const textDecoder = new TextDecoder();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export abstract class CryptoProvider<Blake3State = any> {
abstract randomBytes(length: number): Uint8Array;
randomBytes(length: number): Uint8Array {
return randomBytes(length);
}
abstract newEd25519SigningKey(): Uint8Array;

View File

@@ -2,7 +2,7 @@ import { xsalsa20_poly1305 } from "@noble/ciphers/salsa";
import { x25519 } from "@noble/curves/ed25519";
import { blake3 } from "@noble/hashes/blake3";
import { base58, base64url } from "@scure/base";
import { expect, test } from "vitest";
import { expect, test, vi } from "vitest";
import { PureJSCrypto } from "../crypto/PureJSCrypto.js";
import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { SessionID } from "../ids.js";
@@ -186,4 +186,46 @@ const pureJSCrypto = await PureJSCrypto.create();
expect(decrypted).toBeUndefined();
});
test(`Unsealing malformed JSON logs error [${name}]`, () => {
const data = "not valid json";
const sender = crypto.newRandomSealer();
const sealer = crypto.newRandomSealer();
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const nOnceMaterial = {
in: "co_zTEST",
tx: { sessionID: "co_zTEST_session_zTEST" as SessionID, txIndex: 0 },
} as const;
// Create a sealed message with invalid JSON
const nOnce = blake3(
new TextEncoder().encode(stableStringify(nOnceMaterial)),
).slice(0, 24);
const senderPriv = base58.decode(sender.substring("sealerSecret_z".length));
const sealerPub = base58.decode(
crypto.getSealerID(sealer).substring("sealer_z".length),
);
const plaintext = new TextEncoder().encode(data);
const sharedSecret = x25519.getSharedSecret(senderPriv, sealerPub);
const sealedBytes = xsalsa20_poly1305(sharedSecret, nOnce).encrypt(
plaintext,
);
const sealed = `sealed_U${base64url.encode(sealedBytes)}`;
const result = crypto.unseal(
sealed as any,
sealer,
crypto.getSealerID(sender),
nOnceMaterial,
);
expect(result).toBeUndefined();
expect(consoleSpy.mock.lastCall?.[0]).toContain(
"Failed to decrypt/parse sealed message",
);
});
});

View File

@@ -7,7 +7,7 @@ import { SessionID } from "../ids.js";
describe.each([
{ impl: await WasmCrypto.create(), name: "Wasm" },
{ impl: await PureJSCrypto.create(), name: "PureJS" },
])("Crypto $name", ({ impl }) => {
])("$name implementation", ({ impl, name }) => {
test("randomBytes", () => {
expect(impl.randomBytes(32).length).toEqual(32);
});

View File

@@ -11,7 +11,7 @@
"typescript": "~5.6.2"
},
"peerDependencies": {
"react": "17 - 18"
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"scripts": {
"dev": "tsc --watch --sourceMap --outDir dist",

View File

@@ -4,10 +4,7 @@
"type": "module",
"main": "./dist/app.js",
"types": "./dist/app.d.ts",
"files": [
"dist/**",
"src"
],
"files": ["dist/**", "src"],
"scripts": {
"dev": "vite build --watch",
"build": "rm -rf ./dist && tsc --sourceMap --outDir dist",

View File

@@ -12,7 +12,7 @@
"jazz-tools": "workspace:*"
},
"peerDependencies": {
"react": "^18.2.0"
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"scripts": {
"format-and-lint": "biome check .",

View File

@@ -30,8 +30,8 @@
"typescript": "~5.6.2"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"scripts": {
"dev": "tsc --watch --sourceMap --outDir dist",

View File

@@ -32,8 +32,8 @@
"typescript": "~5.6.2"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"scripts": {
"dev": "tsc --watch --sourceMap --outDir dist",

View File

@@ -199,7 +199,6 @@ describe("ContextManager", () => {
test("the migration should be applied correctly on existing accounts ", async () => {
class AccountRoot extends CoMap {
value = co.string;
transferredRoot = co.optional.ref(AccountRoot);
}
let lastRootId: string | undefined;
@@ -236,6 +235,48 @@ describe("ContextManager", () => {
expect(me.root.id).toBe(lastRootId);
});
test("the migration should be applied correctly on existing accounts (2)", async () => {
class AccountRoot extends CoMap {
value = co.number;
}
class CustomAccount extends Account {
root = co.ref(AccountRoot);
async migrate(this: CustomAccount) {
if (this.root === undefined) {
this.root = AccountRoot.create({
value: 1,
});
} else {
const { root } = await this.ensureLoaded({ root: {} });
root.value = 2;
}
}
}
const customManager = new TestJazzContextManager<CustomAccount>();
// Create initial anonymous context
await customManager.createContext({
AccountSchema: CustomAccount,
});
const account = (
customManager.getCurrentValue() as JazzAuthContext<CustomAccount>
).me;
await customManager.authenticate({
accountID: account.id,
accountSecret: account._raw.core.node.account.agentSecret,
provider: "test",
});
const me = await CustomAccount.getMe().ensureLoaded({ root: {} });
expect(me.root.value).toBe(2);
});
test("onAnonymousAccountDiscarded should work on transfering data between accounts", async () => {
class AccountRoot extends CoMap {
value = co.string;

56
pnpm-lock.yaml generated
View File

@@ -1413,9 +1413,6 @@ importers:
packages/cojson:
dependencies:
'@hazae41/berith':
specifier: ^1.2.6
version: 1.2.6
'@noble/ciphers':
specifier: ^0.1.3
version: 0.1.4
@@ -1431,9 +1428,9 @@ importers:
'@scure/base':
specifier: 1.2.1
version: 1.2.1
hash-wasm:
specifier: ^4.9.0
version: 4.12.0
jazz-crypto-rs:
specifier: 0.0.3
version: 0.0.3
neverthrow:
specifier: ^7.0.1
version: 7.2.0
@@ -3637,18 +3634,6 @@ packages:
'@formkit/auto-animate@0.8.2':
resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==}
'@hazae41/berith@1.2.6':
resolution: {integrity: sha512-TQLkisoolGD2kdSQVtTVXNwmsotA9dLmUgXcpHTNzssmN11Mp+vHVf4Myn3+Q4U18Na3UzGi6DZqogZFqjPPvg==}
'@hazae41/box@1.0.14':
resolution: {integrity: sha512-RevIMZBVRmp0BDkpBpL77sr3Rk7XdqzBZcw+UsfUT4JAly/sryM/NDzJa80sMyO6aERJkjHUQaxH5DYDznPhkw==}
'@hazae41/option@1.1.4':
resolution: {integrity: sha512-ZGAVkOJJw2YIeihG8PaWs4R3KDloCgWGNDPqbATvT3kTtoyU+dJmya/UCM2P+ZwCfWYpE7nGiYJbBTlpp929Qg==}
'@hazae41/result@1.3.2':
resolution: {integrity: sha512-X2RdjCC4DypMZFN3aZ7P5Zh8kEMN6SEqcMARRa/CraEU7Es/hj0yBh1Pt5eAYrRkpnWiE9+ohKSU4s7nL5L1gg==}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -7475,9 +7460,6 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hash-wasm@4.12.0:
resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -7876,6 +7858,9 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jazz-crypto-rs@0.0.3:
resolution: {integrity: sha512-b8yDOUQCRoOdH1KrIQsAIdhZqXcOgpCLGY93/NZEWqimFeGGKAQY7KRXNQtXndKN0jC1y/TH8a8Vxsf0T0+yqQ==}
jest-changed-files@29.7.0:
resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -13440,22 +13425,6 @@ snapshots:
'@formkit/auto-animate@0.8.2': {}
'@hazae41/berith@1.2.6':
dependencies:
'@hazae41/box': 1.0.14
'@hazae41/box@1.0.14':
dependencies:
'@hazae41/result': 1.3.2
'@hazae41/option@1.1.4':
dependencies:
'@hazae41/result': 1.3.2
'@hazae41/result@1.3.2':
dependencies:
'@hazae41/option': 1.1.4
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6':
@@ -17834,8 +17803,6 @@ snapshots:
dependencies:
has-symbols: 1.1.0
hash-wasm@4.12.0: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -18208,6 +18175,8 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
jazz-crypto-rs@0.0.3: {}
jest-changed-files@29.7.0:
dependencies:
execa: 5.1.1
@@ -21777,16 +21746,15 @@ snapshots:
dependencies:
vite: 6.0.11(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1)
vite-node@3.0.5(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1):
vite-node@3.0.5(@types/node@22.10.2)(lightningcss@1.29.1)(terser@5.37.0):
dependencies:
cac: 6.7.14
debug: 4.4.0
es-module-lexer: 1.6.0
pathe: 2.0.2
vite: 6.0.11(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1)
vite: 5.4.11(@types/node@22.10.2)(lightningcss@1.29.1)(terser@5.37.0)
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
@@ -21795,8 +21763,6 @@ snapshots:
- sugarss
- supports-color
- terser
- tsx
- yaml
vite-plugin-dts@4.4.0(@types/node@22.10.2)(rollup@4.28.1)(typescript@5.6.3)(vite@6.0.11(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1)):
dependencies:
@@ -21922,7 +21888,7 @@ snapshots:
tinypool: 1.0.2
tinyrainbow: 2.0.0
vite: 6.0.11(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1)
vite-node: 3.0.5(@types/node@22.10.2)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.6.1)
vite-node: 3.0.5(@types/node@22.10.2)(lightningcss@1.29.1)(terser@5.37.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.10.2

View File

@@ -2,6 +2,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { Link, RouterProvider, createBrowserRouter } from "react-router-dom";
import { AuthAndJazz } from "./jazz";
import { ConcurrentChanges } from "./pages/ConcurrentChanges";
import { FileStreamTest } from "./pages/FileStream";
import { InboxPage } from "./pages/Inbox";
import { ResumeSyncState } from "./pages/ResumeSyncState";
@@ -31,6 +32,9 @@ function Index() {
<li>
<Link to="/write-only">Write Only</Link>
</li>
<li>
<Link to="/concurrent-changes">Concurrent Changes</Link>
</li>
<li>
<Link to="/inbox">Inbox</Link>
</li>
@@ -67,6 +71,10 @@ const router = createBrowserRouter([
path: "/inbox",
element: <InboxPage />,
},
{
path: "/concurrent-changes",
element: <ConcurrentChanges />,
},
{
path: "/",
element: <Index />,

View File

@@ -0,0 +1,75 @@
import { useAccount, useCoState } from "jazz-react";
import { CoFeed, Group, ID, co } from "jazz-tools";
import { useEffect, useState } from "react";
export class Counter extends CoFeed.Of(co.json<{ value: number }>()) {}
function getIdParam() {
const url = new URL(window.location.href);
return (url.searchParams.get("id") as ID<Counter>) ?? undefined;
}
export function ConcurrentChanges() {
const [id, setId] = useState(getIdParam);
const counter = useCoState(Counter, id, []);
const { me } = useAccount();
useEffect(() => {
if (id) {
const url = new URL(window.location.href);
url.searchParams.set("id", id);
history.pushState({}, "", url.toString());
}
}, [id]);
useEffect(() => {
if (counter?.byMe) {
count(counter);
}
}, [counter?.byMe?.value !== undefined]);
const createCounter = () => {
if (!me) return;
const group = Group.create();
group.addMember("everyone", "writer");
const id = Counter.create([{ value: 0 }], group).id;
setId(id);
window.open(`?id=${id}`, "_blank");
};
const done = Object.entries(counter?.perSession ?? {}).every(
([_, entry]) => entry.value.value === 300,
);
return (
<div>
<h1>Concurrent Changes</h1>
<p>
{Object.entries(counter?.perSession ?? {}).map(([sessionId, entry]) => (
<div key={sessionId}>
<p>{sessionId}</p>
<p data-testid="value">{entry.value.value}</p>
</div>
))}
</p>
<button onClick={createCounter}>Create a new value!</button>
{done && <p data-testid="done">Done!</p>}
</div>
);
}
async function count(counter: Counter) {
if (!counter.byMe) return;
let value = counter.byMe.value?.value ?? 0;
while (value < 300) {
await new Promise((resolve) => setTimeout(resolve, 10));
counter.push({ value: ++value });
}
}

View File

@@ -0,0 +1,35 @@
import { expect, test } from "@playwright/test";
test.describe("Concurrent Changes", () => {
test("should complete the task without incurring on InvalidSignature errors", async ({
page,
context,
}) => {
await page.goto("/concurrent-changes");
const newPage = await context.newPage();
await page.getByRole("button", { name: "Create a new value!" }).click();
await newPage.goto(page.url());
await page.getByTestId("done").waitFor();
await newPage.close();
const errorLogs: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") {
errorLogs.push(message.text());
}
});
await page.reload();
await expect(page.getByTestId("done")).toBeVisible();
expect(
errorLogs.find((log) => log.includes("InvalidSignature")),
).toBeUndefined();
});
});