Compare commits
43 Commits
jazz-run@0
...
jazz-react
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c936c8c611 | ||
|
|
58c6013770 | ||
|
|
3eb3291a97 | ||
|
|
6b659f2df3 | ||
|
|
dcc9c9a5ec | ||
|
|
fe9a244363 | ||
|
|
9440bbc058 | ||
|
|
1c92cc2997 | ||
|
|
33ebbf0bdd | ||
|
|
d630b5bde5 | ||
|
|
1c6ae12cd9 | ||
|
|
21bcaabd5a | ||
|
|
17b4d5b668 | ||
|
|
3cd15862d5 | ||
|
|
b3d1ad7201 | ||
|
|
d87df11795 | ||
|
|
82c2a62b2a | ||
|
|
0a9112506e | ||
|
|
fbc29f2f17 | ||
|
|
f6361ee43b | ||
|
|
726dbfb6df | ||
|
|
267f689f10 | ||
|
|
893ad3ae23 | ||
|
|
f5590b1be8 | ||
|
|
17a01f57e8 | ||
|
|
7318d86f52 | ||
|
|
1c8403e87a | ||
|
|
dd747c068a | ||
|
|
1f0f230fe2 | ||
|
|
da655cbff5 | ||
|
|
02f6c6220e | ||
|
|
0755cd198e | ||
|
|
c4a8227b66 | ||
|
|
86f0302233 | ||
|
|
5c98ff4e4f | ||
|
|
1b881cc89f | ||
|
|
c2899e94ca | ||
|
|
f4be67e9b6 | ||
|
|
9ed5a96ef8 | ||
|
|
4272ea9019 | ||
|
|
9509307ed1 | ||
|
|
be08921bc5 | ||
|
|
25be055a51 |
@@ -1,5 +1,13 @@
|
||||
# passkey-svelte
|
||||
|
||||
## 0.0.111
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
|
||||
## 0.0.110
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chat-svelte",
|
||||
"version": "0.0.110",
|
||||
"version": "0.0.111",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function removeTrackFromPlaylist(
|
||||
|
||||
if (track._owner._type === "Group" && playlist._owner._type === "Group") {
|
||||
const trackGroup = track._owner;
|
||||
await trackGroup.removeMember(playlist._owner);
|
||||
trackGroup.removeMember(playlist._owner);
|
||||
|
||||
const index =
|
||||
playlist.tracks?.findIndex(
|
||||
|
||||
@@ -28,18 +28,19 @@ See the [schema docs](/docs/schemas/covalues) for more information.
|
||||
<CodeGroup>
|
||||
```ts
|
||||
// src/lib/schema.ts
|
||||
import { Account, Profile, coField } from "jazz-tools";
|
||||
import { co, z } from "jazz-tools"
|
||||
|
||||
export class MyProfile extends Profile {
|
||||
name = coField.string;
|
||||
counter = coField.number; // This will be publically visible
|
||||
}
|
||||
export const MyProfile = co.profile({
|
||||
name: z.string(),
|
||||
counter: z.number()
|
||||
});
|
||||
|
||||
export class MyAccount extends Account {
|
||||
profile = coField.ref(MyProfile);
|
||||
export const root = co.map({});
|
||||
|
||||
// ...
|
||||
}
|
||||
export const UserAccount = co.account({
|
||||
root,
|
||||
profile: MyProfile
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
@@ -48,17 +49,17 @@ export class MyAccount extends Account {
|
||||
<CodeGroup>
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
|
||||
<script lang="ts">
|
||||
import { JazzSvelteProvider } from 'jazz-tools/svelte';
|
||||
import { JazzSvelteProvider } from "jazz-tools/svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Example configuration for authentication and peer connection
|
||||
let sync = { peer: "wss://cloud.jazz.tools/?key=you@example.com" };
|
||||
let AccountSchema = MyAccount;
|
||||
</script>
|
||||
|
||||
<JazzSvelteProvider {sync} {AccountSchema}>
|
||||
<App />
|
||||
<JazzSvelteProvider {sync} AccountSchema={MyAccount}>
|
||||
{@render children?.()}
|
||||
</JazzSvelteProvider>
|
||||
```
|
||||
</CodeGroup>
|
||||
@@ -69,12 +70,11 @@ export class MyAccount extends Account {
|
||||
```svelte
|
||||
<!-- src/routes/+page.svelte -->
|
||||
<script lang="ts">
|
||||
import { useCoState, useAccount } from 'jazz-tools/svelte';
|
||||
import { MyProfile } from './schema';
|
||||
import { CoState, AccountCoState } from "jazz-tools/svelte";
|
||||
import { MyProfile, UserAccount } from "$lib/schema";
|
||||
|
||||
const { me } = useAccount();
|
||||
|
||||
const profile = $derived(useCoState(MyProfile, me._refs.profile.id));
|
||||
const me = new AccountCoState(UserAccount);
|
||||
const profile = new CoState(MyProfile, me.current?._refs.profile?.id);
|
||||
|
||||
function increment() {
|
||||
if (!profile.current) return;
|
||||
@@ -82,7 +82,7 @@ export class MyAccount extends Account {
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={increment}>
|
||||
<button onclick={increment}>
|
||||
Count: {profile.current?.counter}
|
||||
</button>
|
||||
```
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# cojson-storage-indexeddb
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cojson-storage-indexeddb",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.5",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# cojson-storage-sqlite
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-storage-sqlite",
|
||||
"type": "module",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.5",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# cojson-transport-nodejs-ws
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cojson-transport-ws",
|
||||
"type": "module",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.5",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# cojson
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3cd1586: Makes the key rotation not fail when child groups are unavailable or their readkey is not accessible.
|
||||
|
||||
Also changes the Group.removeMember method to not return a Promise, because:
|
||||
|
||||
- All the locally available child groups are rotated immediately
|
||||
- All the remote child groups are rotated in background, but since they are not locally available the user won't need the new key immediately
|
||||
|
||||
- 267f689: Groups: fix the readkey not being revealed to everyone when doing a key rotation
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.5",
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "^2.0.0",
|
||||
"libsql": "^0.5.13",
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
AvailableCoValueCore,
|
||||
CoValueCore,
|
||||
} from "./coValueCore/coValueCore.js";
|
||||
import { AvailableCoValueCore } from "./coValueCore/coValueCore.js";
|
||||
import { RawProfile as Profile, RawAccount } from "./coValues/account.js";
|
||||
import { RawCoList } from "./coValues/coList.js";
|
||||
import { RawCoMap } from "./coValues/coMap.js";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { UpDownCounter, ValueType, metrics } from "@opentelemetry/api";
|
||||
import { Result, err } from "neverthrow";
|
||||
import { PeerState } from "../PeerState.js";
|
||||
import { RawCoValue } from "../coValue.js";
|
||||
import { ControlledAccountOrAgent } from "../coValues/account.js";
|
||||
import { RawGroup } from "../coValues/group.js";
|
||||
import type { PeerState } from "../PeerState.js";
|
||||
import type { RawCoValue } from "../coValue.js";
|
||||
import type { ControlledAccountOrAgent } from "../coValues/account.js";
|
||||
import type { RawGroup } from "../coValues/group.js";
|
||||
import { CO_VALUE_LOADING_CONFIG } from "../config.js";
|
||||
import { coreToCoValue } from "../coreToCoValue.js";
|
||||
import {
|
||||
@@ -16,25 +16,15 @@ import {
|
||||
SignerID,
|
||||
StreamingHash,
|
||||
} from "../crypto/crypto.js";
|
||||
import {
|
||||
RawCoID,
|
||||
SessionID,
|
||||
TransactionID,
|
||||
getParentGroupId,
|
||||
isParentGroupReference,
|
||||
} from "../ids.js";
|
||||
import { RawCoID, SessionID, TransactionID } from "../ids.js";
|
||||
import { parseJSON, stableStringify } from "../jsonStringify.js";
|
||||
import { JsonValue } from "../jsonValue.js";
|
||||
import { LocalNode, ResolveAccountAgentError } from "../localNode.js";
|
||||
import { logger } from "../logger.js";
|
||||
import {
|
||||
determineValidTransactions,
|
||||
isKeyForKeyField,
|
||||
} from "../permissions.js";
|
||||
import { determineValidTransactions } from "../permissions.js";
|
||||
import { CoValueKnownState, PeerID, emptyKnownState } from "../sync.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||
import { isAccountID } from "../typeUtils/isAccountID.js";
|
||||
import { getDependedOnCoValuesFromRawData } from "./utils.js";
|
||||
import { CoValueHeader, Transaction, VerifiedState } from "./verifiedState.js";
|
||||
|
||||
@@ -53,8 +43,6 @@ export type DecryptedTransaction = {
|
||||
trusting?: boolean;
|
||||
};
|
||||
|
||||
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
|
||||
|
||||
export type AvailableCoValueCore = CoValueCore & { verified: VerifiedState };
|
||||
|
||||
export class CoValueCore {
|
||||
@@ -768,20 +756,7 @@ export class CoValueCore {
|
||||
}
|
||||
|
||||
if (this.verified.header.ruleset.type === "group") {
|
||||
const content = expectGroup(this.getCurrentContent());
|
||||
|
||||
const currentKeyId = content.getCurrentReadKeyId();
|
||||
|
||||
if (!currentKeyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
const secret = this.getReadKey(currentKeyId);
|
||||
|
||||
return {
|
||||
secret: secret,
|
||||
id: currentKeyId,
|
||||
};
|
||||
return expectGroup(this.getCurrentContent()).getCurrentReadKey();
|
||||
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
||||
@@ -793,154 +768,36 @@ export class CoValueCore {
|
||||
}
|
||||
}
|
||||
|
||||
readKeyCache = new Map<KeyID, KeySecret>();
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
let key = readKeyCache.get(this)?.[keyID];
|
||||
if (!key) {
|
||||
key = this.getUncachedReadKey(keyID);
|
||||
if (key) {
|
||||
let cache = readKeyCache.get(this);
|
||||
if (!cache) {
|
||||
cache = {};
|
||||
readKeyCache.set(this, cache);
|
||||
}
|
||||
cache[keyID] = key;
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
// We want to check the cache here, to skip re-computing the group content
|
||||
const cachedSecret = this.readKeyCache.get(keyID);
|
||||
|
||||
if (cachedSecret) {
|
||||
return cachedSecret;
|
||||
}
|
||||
|
||||
getUncachedReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
if (!this.verified) {
|
||||
throw new Error(
|
||||
"CoValueCore: getUncachedReadKey called on coValue without verified state",
|
||||
);
|
||||
}
|
||||
|
||||
// Getting the readKey from accounts
|
||||
if (this.verified.header.ruleset.type === "group") {
|
||||
const content = expectGroup(
|
||||
this.getCurrentContent({ ignorePrivateTransactions: true }), // to prevent recursion
|
||||
);
|
||||
const keyForEveryone = content.get(`${keyID}_for_everyone`);
|
||||
if (keyForEveryone) {
|
||||
return keyForEveryone;
|
||||
}
|
||||
|
||||
// Try to find key revelation for us
|
||||
const currentAgentOrAccountID = accountOrAgentIDfromSessionID(
|
||||
this.node.currentSessionID,
|
||||
// load the account without private transactions, because we are here
|
||||
// to be able to decrypt those
|
||||
this.getCurrentContent({ ignorePrivateTransactions: true }),
|
||||
);
|
||||
|
||||
// being careful here to avoid recursion
|
||||
const lookupAccountOrAgentID = isAccountID(currentAgentOrAccountID)
|
||||
? this.id === currentAgentOrAccountID
|
||||
? this.crypto.getAgentID(this.node.agentSecret) // in accounts, the read key is revealed for the primitive agent
|
||||
: currentAgentOrAccountID // current account ID
|
||||
: currentAgentOrAccountID; // current agent ID
|
||||
|
||||
const lastReadyKeyEdit = content.lastEditAt(
|
||||
`${keyID}_for_${lookupAccountOrAgentID}`,
|
||||
);
|
||||
|
||||
if (lastReadyKeyEdit?.value) {
|
||||
const revealer = lastReadyKeyEdit.by;
|
||||
const revealerAgent = this.node
|
||||
.resolveAccountAgent(revealer, "Expected to know revealer")
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
const secret = this.crypto.unseal(
|
||||
lastReadyKeyEdit.value,
|
||||
this.crypto.getAgentSealerSecret(this.node.agentSecret), // being careful here to avoid recursion
|
||||
this.crypto.getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: lastReadyKeyEdit.tx,
|
||||
},
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
|
||||
for (const co of content.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = content.get(co)!;
|
||||
|
||||
const secret = this.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// try to find revelation to parent group read keys
|
||||
for (const co of content.keys()) {
|
||||
if (isParentGroupReference(co)) {
|
||||
const parentGroupID = getParentGroupId(co);
|
||||
const parentGroup = this.node.expectCoValueLoaded(
|
||||
parentGroupID,
|
||||
"Expected parent group to be loaded",
|
||||
);
|
||||
|
||||
const parentKeys = this.findValidParentKeys(
|
||||
keyID,
|
||||
content,
|
||||
parentGroup,
|
||||
);
|
||||
|
||||
for (const parentKey of parentKeys) {
|
||||
const revelationForParentKey = content.get(
|
||||
`${keyID}_for_${parentKey.id}`,
|
||||
);
|
||||
|
||||
if (revelationForParentKey) {
|
||||
const secret = parentGroup.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: parentKey.id,
|
||||
encrypted: revelationForParentKey,
|
||||
},
|
||||
parentKey.secret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return content.getReadKey(keyID);
|
||||
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
|
||||
return this.node
|
||||
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
||||
.getReadKey(keyID);
|
||||
return expectGroup(
|
||||
this.node
|
||||
.expectCoValueLoaded(this.verified.header.ruleset.group)
|
||||
.getCurrentContent(),
|
||||
).getReadKey(keyID);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only groups or values owned by groups have read secrets",
|
||||
@@ -948,28 +805,6 @@ export class CoValueCore {
|
||||
}
|
||||
}
|
||||
|
||||
findValidParentKeys(keyID: KeyID, group: RawGroup, parentGroup: CoValueCore) {
|
||||
const validParentKeys: { id: KeyID; secret: KeySecret }[] = [];
|
||||
|
||||
for (const co of group.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = parentGroup.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validParentKeys.push({
|
||||
id: encryptingKeyID,
|
||||
secret: encryptingKeySecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return validParentKeys;
|
||||
}
|
||||
|
||||
getGroup(): RawGroup {
|
||||
if (!this.verified) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { base58 } from "@scure/base";
|
||||
import { CoID } from "../coValue.js";
|
||||
import { AvailableCoValueCore } from "../coValueCore/coValueCore.js";
|
||||
import { CoValueUniqueness } from "../coValueCore/verifiedState.js";
|
||||
import {
|
||||
import type { CoID } from "../coValue.js";
|
||||
import type {
|
||||
AvailableCoValueCore,
|
||||
CoValueCore,
|
||||
} from "../coValueCore/coValueCore.js";
|
||||
import type { CoValueUniqueness } from "../coValueCore/verifiedState.js";
|
||||
import type {
|
||||
CryptoProvider,
|
||||
Encrypted,
|
||||
KeyID,
|
||||
@@ -21,8 +24,10 @@ import {
|
||||
} from "../ids.js";
|
||||
import { JsonObject } from "../jsonValue.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { AccountRole, Role } from "../permissions.js";
|
||||
import { AccountRole, Role, isKeyForKeyField } from "../permissions.js";
|
||||
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
|
||||
import { expectGroup } from "../typeUtils/expectGroup.js";
|
||||
import { isAccountID } from "../typeUtils/isAccountID.js";
|
||||
import {
|
||||
ControlledAccountOrAgent,
|
||||
RawAccount,
|
||||
@@ -60,6 +65,59 @@ export type GroupShape = {
|
||||
[child: ChildGroupReference]: "revoked" | "extend";
|
||||
};
|
||||
|
||||
// We had a bug on key rotation, where the new read key was not revealed to everyone
|
||||
// TODO: remove this when we hit the 0.18.0 release (either the groups are healed or they are not used often, it's a minor issue anyway)
|
||||
function healMissingKeyForEveryone(group: RawGroup) {
|
||||
const readKeyId = group.get("readKey");
|
||||
|
||||
if (
|
||||
!readKeyId ||
|
||||
!canRead(group, EVERYONE) ||
|
||||
group.get(`${readKeyId}_for_${EVERYONE}`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAccessToReadKey = canRead(
|
||||
group,
|
||||
group.core.node.getCurrentAgent().id,
|
||||
);
|
||||
|
||||
// If the current account has access to the read key, we can fix the group
|
||||
if (hasAccessToReadKey) {
|
||||
const secret = group.getReadKey(readKeyId);
|
||||
if (secret) {
|
||||
group.set(`${readKeyId}_for_${EVERYONE}`, secret, "trusting");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to the latest readable key for everyone
|
||||
const keys = group
|
||||
.keys()
|
||||
.filter((key) => key.startsWith("key_") && key.endsWith("_for_everyone"));
|
||||
|
||||
let latestKey = keys[0];
|
||||
|
||||
for (const key of keys) {
|
||||
if (!latestKey) {
|
||||
latestKey = key;
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyEntry = group.getRaw(key);
|
||||
const latestKeyEntry = group.getRaw(latestKey);
|
||||
|
||||
if (keyEntry && latestKeyEntry && keyEntry.madeAt > latestKeyEntry.madeAt) {
|
||||
latestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestKey) {
|
||||
group._lastReadableKeyId = latestKey.replace("_for_everyone", "") as KeyID;
|
||||
}
|
||||
}
|
||||
|
||||
/** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
|
||||
*
|
||||
* A `Group` object exposes methods for permission management and allows you to create new CoValues owned by that group.
|
||||
@@ -86,6 +144,8 @@ export class RawGroup<
|
||||
> extends RawCoMap<GroupShape, Meta> {
|
||||
protected readonly crypto: CryptoProvider;
|
||||
|
||||
_lastReadableKeyId?: KeyID;
|
||||
|
||||
constructor(
|
||||
core: AvailableCoValueCore,
|
||||
options?: {
|
||||
@@ -94,6 +154,8 @@ export class RawGroup<
|
||||
) {
|
||||
super(core, options);
|
||||
this.crypto = core.node.crypto;
|
||||
|
||||
healMissingKeyForEveryone(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,43 +253,7 @@ export class RawGroup<
|
||||
return groups;
|
||||
}
|
||||
|
||||
loadAllChildGroups() {
|
||||
const requests: Promise<unknown>[] = [];
|
||||
const peers = this.core.node.syncManager.getServerPeers();
|
||||
|
||||
for (const key of this.keys()) {
|
||||
if (!isChildGroupReference(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = getChildGroupId(key);
|
||||
const child = this.core.node.getCoValue(id);
|
||||
|
||||
if (
|
||||
child.loadingState === "unknown" ||
|
||||
child.loadingState === "unavailable"
|
||||
) {
|
||||
child.load(peers);
|
||||
}
|
||||
|
||||
requests.push(
|
||||
child.waitForAvailableOrUnavailable().then((coValue) => {
|
||||
if (!coValue.isAvailable()) {
|
||||
throw new Error(`Child group ${child.id} is unavailable`);
|
||||
}
|
||||
|
||||
// Recursively load child groups
|
||||
return expectGroup(coValue.getCurrentContent()).loadAllChildGroups();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(requests);
|
||||
}
|
||||
|
||||
getChildGroups() {
|
||||
const groups: RawGroup[] = [];
|
||||
|
||||
forEachChildGroup(callback: (child: RawGroup) => void) {
|
||||
for (const key of this.keys()) {
|
||||
if (isChildGroupReference(key)) {
|
||||
// Check if the child group reference is revoked
|
||||
@@ -235,15 +261,22 @@ export class RawGroup<
|
||||
continue;
|
||||
}
|
||||
|
||||
const child = this.core.node.expectCoValueLoaded(
|
||||
getChildGroupId(key),
|
||||
"Expected child group to be loaded",
|
||||
);
|
||||
groups.push(expectGroup(child.getCurrentContent()));
|
||||
const id = getChildGroupId(key);
|
||||
const child = this.core.node.getCoValue(id);
|
||||
|
||||
if (child.isAvailable()) {
|
||||
callback(expectGroup(child.getCurrentContent()));
|
||||
} else {
|
||||
this.core.node.load(id).then((child) => {
|
||||
if (child !== "unavailable") {
|
||||
callback(expectGroup(child));
|
||||
} else {
|
||||
logger.warn(`Unable to load child group ${id}, skipping`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,7 +312,7 @@ export class RawGroup<
|
||||
"Can't make everyone something other than reader, writer or writeOnly",
|
||||
);
|
||||
}
|
||||
const currentReadKey = this.core.getCurrentReadKey();
|
||||
const currentReadKey = this.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
@@ -306,7 +339,7 @@ export class RawGroup<
|
||||
|
||||
if (role === "writeOnly") {
|
||||
if (previousRole === "reader" || previousRole === "writer") {
|
||||
this.rotateReadKey();
|
||||
this.rotateReadKey("everyone");
|
||||
}
|
||||
|
||||
this.delete(`${currentReadKey.id}_for_${EVERYONE}`);
|
||||
@@ -349,7 +382,7 @@ export class RawGroup<
|
||||
|
||||
this.internalCreateWriteOnlyKeyForMember(memberKey, agent);
|
||||
} else {
|
||||
const currentReadKey = this.core.getCurrentReadKey();
|
||||
const currentReadKey = this.getCurrentReadKey();
|
||||
|
||||
if (!currentReadKey.secret) {
|
||||
throw new Error("Can't add member without read key secret");
|
||||
@@ -467,6 +500,10 @@ export class RawGroup<
|
||||
}
|
||||
|
||||
getCurrentReadKeyId() {
|
||||
if (this._lastReadableKeyId) {
|
||||
return this._lastReadableKeyId;
|
||||
}
|
||||
|
||||
const myRole = this.myRole();
|
||||
|
||||
if (myRole === "writeOnly") {
|
||||
@@ -518,23 +555,173 @@ export class RawGroup<
|
||||
return memberKeys;
|
||||
}
|
||||
|
||||
getReadKey(keyID: KeyID): KeySecret | undefined {
|
||||
const cache = this.core.readKeyCache;
|
||||
|
||||
let key = cache.get(keyID);
|
||||
if (!key) {
|
||||
key = this.getUncachedReadKey(keyID);
|
||||
if (key) {
|
||||
cache.set(keyID, key);
|
||||
}
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
getUncachedReadKey(keyID: KeyID) {
|
||||
const core = this.core;
|
||||
|
||||
const keyForEveryone = this.get(`${keyID}_for_everyone`);
|
||||
if (keyForEveryone) {
|
||||
return keyForEveryone;
|
||||
}
|
||||
|
||||
// Try to find key revelation for us
|
||||
const currentAgentOrAccountID = accountOrAgentIDfromSessionID(
|
||||
core.node.currentSessionID,
|
||||
);
|
||||
|
||||
// being careful here to avoid recursion
|
||||
const lookupAccountOrAgentID = isAccountID(currentAgentOrAccountID)
|
||||
? core.id === currentAgentOrAccountID
|
||||
? core.node.crypto.getAgentID(core.node.agentSecret) // in accounts, the read key is revealed for the primitive agent
|
||||
: currentAgentOrAccountID // current account ID
|
||||
: currentAgentOrAccountID; // current agent ID
|
||||
|
||||
const lastReadyKeyEdit = this.lastEditAt(
|
||||
`${keyID}_for_${lookupAccountOrAgentID}`,
|
||||
);
|
||||
|
||||
if (lastReadyKeyEdit?.value) {
|
||||
const revealer = lastReadyKeyEdit.by;
|
||||
const revealerAgent = core.node
|
||||
.resolveAccountAgent(revealer, "Expected to know revealer")
|
||||
._unsafeUnwrap({ withStackTrace: true });
|
||||
|
||||
const secret = this.crypto.unseal(
|
||||
lastReadyKeyEdit.value,
|
||||
this.crypto.getAgentSealerSecret(core.node.agentSecret), // being careful here to avoid recursion
|
||||
this.crypto.getAgentSealerID(revealerAgent),
|
||||
{
|
||||
in: this.id,
|
||||
tx: lastReadyKeyEdit.tx,
|
||||
},
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find indirect revelation through previousKeys
|
||||
for (const co of this.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedPreviousKey = this.get(co)!;
|
||||
|
||||
const secret = this.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: encryptingKeyID,
|
||||
encrypted: encryptedPreviousKey,
|
||||
},
|
||||
encryptingKeySecret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// try to find revelation to parent group read keys
|
||||
for (const co of this.keys()) {
|
||||
if (isParentGroupReference(co)) {
|
||||
const parentGroupID = getParentGroupId(co);
|
||||
const parentGroup = core.node.expectCoValueLoaded(
|
||||
parentGroupID,
|
||||
"Expected parent group to be loaded",
|
||||
);
|
||||
|
||||
const parentKeys = this.findValidParentKeys(keyID, parentGroup);
|
||||
|
||||
for (const parentKey of parentKeys) {
|
||||
const revelationForParentKey = this.get(
|
||||
`${keyID}_for_${parentKey.id}`,
|
||||
);
|
||||
|
||||
if (revelationForParentKey) {
|
||||
const secret = parentGroup.node.crypto.decryptKeySecret(
|
||||
{
|
||||
encryptedID: keyID,
|
||||
encryptingID: parentKey.id,
|
||||
encrypted: revelationForParentKey,
|
||||
},
|
||||
parentKey.secret,
|
||||
);
|
||||
|
||||
if (secret) {
|
||||
return secret as KeySecret;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
findValidParentKeys(keyID: KeyID, parentGroup: CoValueCore) {
|
||||
const validParentKeys: { id: KeyID; secret: KeySecret }[] = [];
|
||||
|
||||
for (const co of this.keys()) {
|
||||
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
|
||||
const encryptingKeyID = co.split("_for_")[1] as KeyID;
|
||||
const encryptingKeySecret = parentGroup.getReadKey(encryptingKeyID);
|
||||
|
||||
if (!encryptingKeySecret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validParentKeys.push({
|
||||
id: encryptingKeyID,
|
||||
secret: encryptingKeySecret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return validParentKeys;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
rotateReadKey(removedMemberKey?: RawAccountID | AgentID | "everyone") {
|
||||
if (removedMemberKey !== EVERYONE && canRead(this, EVERYONE)) {
|
||||
// When everyone has access to the group, rotating the key is useless
|
||||
// because it would be stored unencrypted and available to everyone
|
||||
return;
|
||||
}
|
||||
|
||||
const memberKeys = this.getMemberKeys().filter(
|
||||
(key) => key !== removedMemberKey,
|
||||
);
|
||||
|
||||
const currentlyPermittedReaders = memberKeys.filter((key) => {
|
||||
const role = this.get(key);
|
||||
return (
|
||||
role === "admin" ||
|
||||
role === "writer" ||
|
||||
role === "reader" ||
|
||||
role === "adminInvite" ||
|
||||
role === "writerInvite" ||
|
||||
role === "readerInvite"
|
||||
);
|
||||
});
|
||||
const currentlyPermittedReaders = memberKeys.filter((key) =>
|
||||
canRead(this, key),
|
||||
);
|
||||
|
||||
const writeOnlyMembers = memberKeys.filter((key) => {
|
||||
const role = this.get(key);
|
||||
@@ -543,12 +730,12 @@ export class RawGroup<
|
||||
|
||||
// Get these early, so we fail fast if they are unavailable
|
||||
const parentGroups = this.getParentGroups();
|
||||
const childGroups = this.getChildGroups();
|
||||
|
||||
const maybeCurrentReadKey = this.core.getCurrentReadKey();
|
||||
const maybeCurrentReadKey = this.getCurrentReadKey();
|
||||
|
||||
if (!maybeCurrentReadKey.secret) {
|
||||
throw new Error("Can't rotate read key secret we don't have access to");
|
||||
throw new NoReadKeyAccessError(
|
||||
"Can't rotate read key secret we don't have access to",
|
||||
);
|
||||
}
|
||||
|
||||
const currentReadKey = {
|
||||
@@ -631,7 +818,7 @@ export class RawGroup<
|
||||
*/
|
||||
for (const parent of parentGroups) {
|
||||
const { id: parentReadKeyID, secret: parentReadKeySecret } =
|
||||
parent.core.getCurrentReadKey();
|
||||
parent.getCurrentReadKey();
|
||||
|
||||
if (!parentReadKeySecret) {
|
||||
// We can't reveal the new child key to the parent group where we don't have access to the parent read key
|
||||
@@ -655,16 +842,26 @@ export class RawGroup<
|
||||
);
|
||||
}
|
||||
|
||||
for (const child of childGroups) {
|
||||
this.forEachChildGroup((child) => {
|
||||
// Since child references are mantained only for the key rotation,
|
||||
// circular references are skipped here because it's more performant
|
||||
// than always checking for circular references in childs inside the permission checks
|
||||
if (child.isSelfExtension(this)) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
child.rotateReadKey(removedMemberKey);
|
||||
}
|
||||
try {
|
||||
child.rotateReadKey(removedMemberKey);
|
||||
} catch (error) {
|
||||
if (error instanceof NoReadKeyAccessError) {
|
||||
logger.warn(
|
||||
`Can't rotate read key on child ${child.id} because we don't have access to the read key`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Detect circular references in group inheritance */
|
||||
@@ -695,6 +892,19 @@ export class RawGroup<
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentReadKey() {
|
||||
const keyId = this.getCurrentReadKeyId();
|
||||
|
||||
if (!keyId) {
|
||||
throw new Error("No readKey set");
|
||||
}
|
||||
|
||||
return {
|
||||
secret: this.getReadKey(keyId),
|
||||
id: keyId,
|
||||
};
|
||||
}
|
||||
|
||||
extend(
|
||||
parent: RawGroup,
|
||||
role: "reader" | "writer" | "admin" | "inherit" = "inherit",
|
||||
@@ -727,14 +937,15 @@ export class RawGroup<
|
||||
);
|
||||
}
|
||||
|
||||
const { id: parentReadKeyID, secret: parentReadKeySecret } =
|
||||
parent.core.getCurrentReadKey();
|
||||
let { id: parentReadKeyID, secret: parentReadKeySecret } =
|
||||
parent.getCurrentReadKey();
|
||||
|
||||
if (!parentReadKeySecret) {
|
||||
throw new Error("Can't extend group without parent read key secret");
|
||||
}
|
||||
|
||||
const { id: childReadKeyID, secret: childReadKeySecret } =
|
||||
this.core.getCurrentReadKey();
|
||||
this.getCurrentReadKey();
|
||||
if (!childReadKeySecret) {
|
||||
throw new Error("Can't extend group without child read key secret");
|
||||
}
|
||||
@@ -755,7 +966,7 @@ export class RawGroup<
|
||||
);
|
||||
}
|
||||
|
||||
async revokeExtend(parent: RawGroup) {
|
||||
revokeExtend(parent: RawGroup) {
|
||||
if (this.myRole() !== "admin") {
|
||||
throw new Error(
|
||||
"To unextend a group, the current account must be an admin in the child group",
|
||||
@@ -786,8 +997,6 @@ export class RawGroup<
|
||||
// Set the child key on the parent group to `revoked`
|
||||
parent.set(`child_${this.id}`, "revoked", "trusting");
|
||||
|
||||
await this.loadAllChildGroups();
|
||||
|
||||
// Rotate the keys on the child group
|
||||
this.rotateReadKey();
|
||||
}
|
||||
@@ -799,19 +1008,7 @@ export class RawGroup<
|
||||
*
|
||||
* @category 2. Role changing
|
||||
*/
|
||||
async removeMember(
|
||||
account: RawAccount | ControlledAccountOrAgent | Everyone,
|
||||
) {
|
||||
// Ensure all child groups are loaded before removing a member
|
||||
await this.loadAllChildGroups();
|
||||
|
||||
this.removeMemberInternal(account);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
removeMemberInternal(
|
||||
account: RawAccount | ControlledAccountOrAgent | AgentID | Everyone,
|
||||
) {
|
||||
removeMember(account: RawAccount | ControlledAccountOrAgent | Everyone) {
|
||||
const memberKey = typeof account === "string" ? account : account.id;
|
||||
|
||||
if (this.myRole() === "admin") {
|
||||
@@ -1022,3 +1219,25 @@ export function secretSeedFromInviteSecret(inviteSecret: InviteSecret) {
|
||||
|
||||
return base58.decode(inviteSecret.slice("inviteSecret_z".length));
|
||||
}
|
||||
|
||||
const canRead = (
|
||||
group: RawGroup,
|
||||
key: RawAccountID | AgentID | "everyone",
|
||||
): boolean => {
|
||||
const role = group.get(key);
|
||||
return (
|
||||
role === "admin" ||
|
||||
role === "writer" ||
|
||||
role === "reader" ||
|
||||
role === "adminInvite" ||
|
||||
role === "writerInvite" ||
|
||||
role === "readerInvite"
|
||||
);
|
||||
};
|
||||
|
||||
class NoReadKeyAccessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NoReadKeyAccessError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { base58 } from "@scure/base";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { RawAccountID } from "./coValues/account.js";
|
||||
import type { CoID } from "./coValue.js";
|
||||
import type { RawAccountID } from "./coValues/account.js";
|
||||
import type { RawGroup } from "./coValues/group.js";
|
||||
import { shortHashLength } from "./crypto/crypto.js";
|
||||
import { RawGroup } from "./exports.js";
|
||||
|
||||
export type RawCoID = `co_z${string}`;
|
||||
export type ParentGroupReference = `parent_${CoID<RawGroup>}`;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Result, err, ok } from "neverthrow";
|
||||
import { CoID } from "./coValue.js";
|
||||
import { RawCoValue } from "./coValue.js";
|
||||
import type { CoID } from "./coValue.js";
|
||||
import type { RawCoValue } from "./coValue.js";
|
||||
import {
|
||||
AvailableCoValueCore,
|
||||
type AvailableCoValueCore,
|
||||
CoValueCore,
|
||||
idforHeader,
|
||||
} from "./coValueCore/coValueCore.js";
|
||||
import {
|
||||
CoValueHeader,
|
||||
CoValueUniqueness,
|
||||
type CoValueHeader,
|
||||
type CoValueUniqueness,
|
||||
VerifiedState,
|
||||
} from "./coValueCore/verifiedState.js";
|
||||
import {
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
expectAccount,
|
||||
} from "./coValues/account.js";
|
||||
import {
|
||||
InviteSecret,
|
||||
RawGroup,
|
||||
type InviteSecret,
|
||||
type RawGroup,
|
||||
secretSeedFromInviteSecret,
|
||||
} from "./coValues/group.js";
|
||||
import { CO_VALUE_LOADING_CONFIG } from "./config.js";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import type { CoID, RawGroup } from "../exports";
|
||||
import { NewContentMessage } from "../sync";
|
||||
import {
|
||||
SyncMessagesLog,
|
||||
createThreeConnectedNodes,
|
||||
@@ -96,6 +98,32 @@ describe("extend", () => {
|
||||
expect(mapOnNode2.get("test")).toEqual("Written from node2");
|
||||
});
|
||||
|
||||
test("inherited everyone roles should work correctly", async () => {
|
||||
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
||||
|
||||
const group = node1.node.createGroup();
|
||||
group.addMember("everyone", "writer");
|
||||
|
||||
const childGroup = node1.node.createGroup();
|
||||
childGroup.extend(group);
|
||||
|
||||
expect(childGroup.roleOf("everyone")).toEqual("writer");
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Written from the admin");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
|
||||
|
||||
// The writer role should be able to see the edits from the admin
|
||||
expect(mapOnNode2.get("test")).toEqual("Written from the admin");
|
||||
|
||||
mapOnNode2.set("hello", "from node 2");
|
||||
|
||||
expect(mapOnNode2.get("hello")).toEqual("from node 2");
|
||||
});
|
||||
|
||||
test("a user should be able to extend a group when his role on the parent group is writeOnly", async () => {
|
||||
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
||||
|
||||
@@ -315,6 +343,257 @@ describe("extend", () => {
|
||||
|
||||
expect(childGroup.roleOf(alice.id)).toBe("writer");
|
||||
});
|
||||
|
||||
test("should be possible to extend a group after getting revoked from the parent group", async () => {
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const parentGroup = node1.node.createGroup();
|
||||
|
||||
const alice = await loadCoValueOrFail(node1.node, node3.accountID);
|
||||
const bob = await loadCoValueOrFail(node1.node, node2.accountID);
|
||||
parentGroup.addMember(alice, "writer");
|
||||
parentGroup.addMember(bob, "reader");
|
||||
parentGroup.removeMember(bob);
|
||||
|
||||
const parentGroupOnNode2 = await loadCoValueOrFail(
|
||||
node2.node,
|
||||
parentGroup.id,
|
||||
);
|
||||
|
||||
const childGroup = node2.node.createGroup();
|
||||
childGroup.extend(parentGroupOnNode2);
|
||||
|
||||
expect(childGroup.roleOf(alice.id)).toBe("writer");
|
||||
});
|
||||
|
||||
test("should be possible to extend when access is everyone reader and the account is revoked from the parent group", async () => {
|
||||
const { node1, node2, node3 } = await createThreeConnectedNodes(
|
||||
"server",
|
||||
"server",
|
||||
"server",
|
||||
);
|
||||
|
||||
const parentGroup = node1.node.createGroup();
|
||||
parentGroup.addMember("everyone", "reader");
|
||||
const alice = await loadCoValueOrFail(node1.node, node3.accountID);
|
||||
const bob = await loadCoValueOrFail(node1.node, node2.accountID);
|
||||
parentGroup.addMember(alice, "writer");
|
||||
parentGroup.addMember(bob, "reader");
|
||||
parentGroup.removeMember(bob);
|
||||
|
||||
const parentGroupOnNode2 = await loadCoValueOrFail(
|
||||
node2.node,
|
||||
parentGroup.id,
|
||||
);
|
||||
|
||||
const childGroup = node2.node.createGroup();
|
||||
childGroup.extend(parentGroupOnNode2);
|
||||
|
||||
expect(childGroup.roleOf(alice.id)).toBe("writer");
|
||||
});
|
||||
|
||||
test("should be able to extend when the last read key is healed", async () => {
|
||||
const clientWithAccess = setupTestNode({
|
||||
secret:
|
||||
"sealerSecret_zBTPp7U58Fzq9o7EvJpu4KEziepi8QVf2Xaxuy5xmmXFx/signerSecret_z62DuviZdXCjz4EZWofvr9vaLYFXDeTaC9KWhoQiQjzKk",
|
||||
connected: true,
|
||||
});
|
||||
const clientWithoutAccess = setupTestNode({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const brokenGroupContent = {
|
||||
action: "content",
|
||||
id: "co_zW7F36Nnop9A7Er4gUzBcUXnZCK",
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "group",
|
||||
initialAdmin:
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv",
|
||||
},
|
||||
meta: null,
|
||||
createdAt: "2025-08-06T10:14:39.617Z",
|
||||
uniqueness: "z3LJjnuPiPJaf5Qb9A",
|
||||
},
|
||||
priority: 0,
|
||||
new: {
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv_session_zYLsz2CiW9pW":
|
||||
{
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279619,
|
||||
changes:
|
||||
'[{"key":"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"admin"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UCg4UkytXF-W8PaIvaDffO3pZ3d9hdXUuNkQQEikPTAuOD9us92Pqb5Vgu7lx1Fpb0X8V5BJ2yxz6_D5WOzK3qjWBSsc7J1xDJA=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z5CVahfMkEWPj1B3zH"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279622,
|
||||
changes: '[{"key":"everyone","op":"set","value":"reader"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_everyone","op":"set","value":"keySecret_z9U9gzkahQXCxDoSw7isiUnbobXwuLdcSkL9Ci6ZEEkaL"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z4Fi7hZNBx7XoVAKkP_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UuCBBfZkTnRTrGraqWWlzm9JE-VFduhsfu7WaZjpCbJYOTXpPhSNOnzGeS8qVuIsG6dORbME22lc5ltLxPjRqofQdDCNGQehCeQ=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_key_z4Fi7hZNBx7XoVAKkP","op":"set","value":"encrypted_USTrBuobwTCORwy5yHxy4sFZ7swfrafP6k5ZwcTf76f0MBu9Ie-JmsX3mNXad4mluI47gvGXzi8I_"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z4Fi7hZNBx7XoVAKkP"}]',
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
"signature_z3tsE7U1JaeNeUmZ4EY3Xq5uQ9jq9jDi6Rkhdt7T7b7z4NCnpMgB4bo8TwLXYVCrRdBm6PoyyPdK8fYFzHJUh5EzA",
|
||||
},
|
||||
},
|
||||
} as unknown as NewContentMessage;
|
||||
|
||||
clientWithAccess.node.syncManager.handleNewContent(
|
||||
brokenGroupContent,
|
||||
"import",
|
||||
);
|
||||
|
||||
// Load the CoValue to recover the key_for_everyone
|
||||
await loadCoValueOrFail(
|
||||
clientWithAccess.node,
|
||||
brokenGroupContent.id as CoID<RawGroup>,
|
||||
);
|
||||
|
||||
const group = await loadCoValueOrFail(
|
||||
clientWithoutAccess.node,
|
||||
brokenGroupContent.id as CoID<RawGroup>,
|
||||
);
|
||||
const childGroup = clientWithoutAccess.node.createGroup();
|
||||
childGroup.extend(group);
|
||||
|
||||
expect(childGroup.getParentGroups()).toEqual([group]);
|
||||
});
|
||||
|
||||
test("should be able to extend when the last read key is missing", async () => {
|
||||
const clientWithoutAccess = setupTestNode({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const brokenGroupContent = {
|
||||
action: "content",
|
||||
id: "co_zW7F36Nnop9A7Er4gUzBcUXnZCK",
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "group",
|
||||
initialAdmin:
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv",
|
||||
},
|
||||
meta: null,
|
||||
createdAt: "2025-08-06T10:14:39.617Z",
|
||||
uniqueness: "z3LJjnuPiPJaf5Qb9A",
|
||||
},
|
||||
priority: 0,
|
||||
new: {
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv_session_zYLsz2CiW9pW":
|
||||
{
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279619,
|
||||
changes:
|
||||
'[{"key":"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"admin"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UCg4UkytXF-W8PaIvaDffO3pZ3d9hdXUuNkQQEikPTAuOD9us92Pqb5Vgu7lx1Fpb0X8V5BJ2yxz6_D5WOzK3qjWBSsc7J1xDJA=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z5CVahfMkEWPj1B3zH"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279622,
|
||||
changes: '[{"key":"everyone","op":"set","value":"reader"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_everyone","op":"set","value":"keySecret_z9U9gzkahQXCxDoSw7isiUnbobXwuLdcSkL9Ci6ZEEkaL"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z4Fi7hZNBx7XoVAKkP_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UuCBBfZkTnRTrGraqWWlzm9JE-VFduhsfu7WaZjpCbJYOTXpPhSNOnzGeS8qVuIsG6dORbME22lc5ltLxPjRqofQdDCNGQehCeQ=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_key_z4Fi7hZNBx7XoVAKkP","op":"set","value":"encrypted_USTrBuobwTCORwy5yHxy4sFZ7swfrafP6k5ZwcTf76f0MBu9Ie-JmsX3mNXad4mluI47gvGXzi8I_"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z4Fi7hZNBx7XoVAKkP"}]',
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
"signature_z3tsE7U1JaeNeUmZ4EY3Xq5uQ9jq9jDi6Rkhdt7T7b7z4NCnpMgB4bo8TwLXYVCrRdBm6PoyyPdK8fYFzHJUh5EzA",
|
||||
},
|
||||
},
|
||||
} as unknown as NewContentMessage;
|
||||
|
||||
clientWithoutAccess.node.syncManager.handleNewContent(
|
||||
brokenGroupContent,
|
||||
"import",
|
||||
);
|
||||
|
||||
const group = await loadCoValueOrFail(
|
||||
clientWithoutAccess.node,
|
||||
brokenGroupContent.id as CoID<RawGroup>,
|
||||
);
|
||||
const childGroup = clientWithoutAccess.node.createGroup();
|
||||
childGroup.extend(group);
|
||||
|
||||
expect(childGroup.getParentGroups()).toEqual([group]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unextend", () => {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { setCoValueLoadingRetryDelay } from "../config.js";
|
||||
import {
|
||||
SyncMessagesLog,
|
||||
TEST_NODE_CONFIG,
|
||||
blockMessageTypeOnOutgoingPeer,
|
||||
loadCoValueOrFail,
|
||||
setupTestAccount,
|
||||
setupTestNode,
|
||||
} from "./testUtils.js";
|
||||
|
||||
setCoValueLoadingRetryDelay(10);
|
||||
|
||||
let jazzCloud: ReturnType<typeof setupTestNode>;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -15,6 +18,65 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
describe("Group.removeMember", () => {
|
||||
test("revoking a member access should not affect everyone access", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
group.addMember("everyone", "writer");
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
group.addMember(aliceOnAdminNode, "writer");
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
const groupOnAliceNode = await loadCoValueOrFail(alice.node, group.id);
|
||||
expect(groupOnAliceNode.myRole()).toEqual("writer");
|
||||
|
||||
const map = groupOnAliceNode.createMap();
|
||||
|
||||
map.set("test", "test");
|
||||
expect(map.get("test")).toEqual("test");
|
||||
});
|
||||
|
||||
test("revoking a member access should not affect everyone access when everyone access is gained through a group extension", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const parentGroup = admin.node.createGroup();
|
||||
const group = admin.node.createGroup();
|
||||
parentGroup.addMember("everyone", "reader");
|
||||
group.extend(parentGroup);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
group.addMember(aliceOnAdminNode, "writer");
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("test", "test");
|
||||
|
||||
const groupOnAliceNode = await loadCoValueOrFail(alice.node, group.id);
|
||||
expect(groupOnAliceNode.myRole()).toEqual("reader");
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toEqual("test");
|
||||
});
|
||||
|
||||
test("a reader member should be able to revoke themselves", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
@@ -294,4 +356,185 @@ describe("Group.removeMember", () => {
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("removing a member should rotate the readKey on available child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
const childGroup = admin.node.createGroup();
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
childGroup.extend(group);
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removing a member should rotate the readKey on unloaded child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bob = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
|
||||
const childGroup = bob.node.createGroup();
|
||||
group.addMember(bobOnAdminNode, "reader");
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
|
||||
|
||||
childGroup.extend(groupOnBobNode);
|
||||
|
||||
await childGroup.core.waitForSync();
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
// Rotating the child group keys is async when the child group is not loaded
|
||||
await admin.node.getCoValue(childGroup.id).waitForAvailableOrUnavailable();
|
||||
await admin.node.syncManager.waitForAllCoValuesSync();
|
||||
|
||||
const map = childGroup.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removing a member should work even if there are partially available child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bob = await setupTestAccount();
|
||||
const { peer } = bob.connectToSyncServer();
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
const childGroup = bob.node.createGroup();
|
||||
|
||||
group.addMember(bobOnAdminNode, "reader");
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
blockMessageTypeOnOutgoingPeer(peer, "content", {
|
||||
id: childGroup.id,
|
||||
});
|
||||
|
||||
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
|
||||
|
||||
childGroup.extend(groupOnBobNode);
|
||||
|
||||
await groupOnBobNode.core.waitForSync();
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
await admin.node.syncManager.waitForAllCoValuesSync();
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("removing a member should work even if there are unavailable child groups", async () => {
|
||||
const admin = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const { peerOnServer } = admin.connectToSyncServer();
|
||||
|
||||
const bob = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const alice = await setupTestAccount({
|
||||
connected: true,
|
||||
});
|
||||
|
||||
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
|
||||
|
||||
const aliceOnAdminNode = await loadCoValueOrFail(
|
||||
admin.node,
|
||||
alice.accountID,
|
||||
);
|
||||
|
||||
const group = admin.node.createGroup();
|
||||
const childGroup = bob.node.createGroup();
|
||||
|
||||
blockMessageTypeOnOutgoingPeer(peerOnServer, "content", {
|
||||
id: childGroup.id,
|
||||
});
|
||||
|
||||
group.addMember(bobOnAdminNode, "reader");
|
||||
group.addMember(aliceOnAdminNode, "reader");
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
|
||||
|
||||
childGroup.extend(groupOnBobNode);
|
||||
|
||||
await groupOnBobNode.core.waitForSync();
|
||||
|
||||
group.removeMember(aliceOnAdminNode);
|
||||
|
||||
await group.core.waitForSync();
|
||||
|
||||
const map = group.createMap();
|
||||
map.set("test", "Not readable by alice");
|
||||
|
||||
await map.core.waitForSync();
|
||||
|
||||
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
|
||||
expect(mapOnAliceNode.get("test")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("roleOf", () => {
|
||||
const [agent2] = randomAgentAndSessionID();
|
||||
|
||||
group.addMember(agent2, "writer");
|
||||
group.removeMemberInternal(agent2);
|
||||
group.removeMember(agent2);
|
||||
expect(group.roleOfInternal(agent2.id)).toEqual(undefined);
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("roleOf", () => {
|
||||
|
||||
group.addMemberInternal("everyone", "reader");
|
||||
group.addMember(agent2, "writer");
|
||||
group.removeMemberInternal("everyone");
|
||||
group.removeMember("everyone");
|
||||
expect(group.roleOfInternal(agent2.id)).toEqual("writer");
|
||||
expect(group.roleOfInternal("123" as RawAccountID)).toEqual(undefined);
|
||||
});
|
||||
|
||||
@@ -3,18 +3,27 @@ import { RawCoList } from "../coValues/coList.js";
|
||||
import { RawCoMap } from "../coValues/coMap.js";
|
||||
import { RawCoStream } from "../coValues/coStream.js";
|
||||
import { RawBinaryCoStream } from "../coValues/coStream.js";
|
||||
import { WasmCrypto } from "../crypto/WasmCrypto.js";
|
||||
import { RawAccountID } from "../exports.js";
|
||||
import type { RawCoValue, RawGroup } from "../exports.js";
|
||||
import type { NewContentMessage } from "../sync.js";
|
||||
import {
|
||||
createThreeConnectedNodes,
|
||||
createTwoConnectedNodes,
|
||||
loadCoValueOrFail,
|
||||
nodeWithRandomAgentAndSessionID,
|
||||
randomAgentAndSessionID,
|
||||
waitFor,
|
||||
setupTestNode,
|
||||
} from "./testUtils.js";
|
||||
|
||||
const Crypto = await WasmCrypto.create();
|
||||
function expectGroup(content: RawCoValue): RawGroup {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected group");
|
||||
}
|
||||
|
||||
if (content.core.verified.header.ruleset.type !== "group") {
|
||||
throw new Error("Expected group ruleset in group");
|
||||
}
|
||||
|
||||
return content as RawGroup;
|
||||
}
|
||||
|
||||
test("Can create a RawCoMap in a group", () => {
|
||||
const node = nodeWithRandomAgentAndSessionID();
|
||||
@@ -307,6 +316,97 @@ test("Invites should have access to the new keys", async () => {
|
||||
expect(mapOnNode2.get("test")).toEqual("Written from node1");
|
||||
});
|
||||
|
||||
test("Should heal the missing key_for_everyone", async () => {
|
||||
const client = setupTestNode({
|
||||
secret:
|
||||
"sealerSecret_zBTPp7U58Fzq9o7EvJpu4KEziepi8QVf2Xaxuy5xmmXFx/signerSecret_z62DuviZdXCjz4EZWofvr9vaLYFXDeTaC9KWhoQiQjzKk",
|
||||
});
|
||||
|
||||
const brokenGroupContent = {
|
||||
action: "content",
|
||||
id: "co_zW7F36Nnop9A7Er4gUzBcUXnZCK",
|
||||
header: {
|
||||
type: "comap",
|
||||
ruleset: {
|
||||
type: "group",
|
||||
initialAdmin:
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv",
|
||||
},
|
||||
meta: null,
|
||||
createdAt: "2025-08-06T10:14:39.617Z",
|
||||
uniqueness: "z3LJjnuPiPJaf5Qb9A",
|
||||
},
|
||||
priority: 0,
|
||||
new: {
|
||||
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv_session_zYLsz2CiW9pW":
|
||||
{
|
||||
after: 0,
|
||||
newTransactions: [
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279619,
|
||||
changes:
|
||||
'[{"key":"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"admin"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UCg4UkytXF-W8PaIvaDffO3pZ3d9hdXUuNkQQEikPTAuOD9us92Pqb5Vgu7lx1Fpb0X8V5BJ2yxz6_D5WOzK3qjWBSsc7J1xDJA=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279621,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z5CVahfMkEWPj1B3zH"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279622,
|
||||
changes: '[{"key":"everyone","op":"set","value":"reader"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_everyone","op":"set","value":"keySecret_z9U9gzkahQXCxDoSw7isiUnbobXwuLdcSkL9Ci6ZEEkaL"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279623,
|
||||
changes:
|
||||
'[{"key":"key_z4Fi7hZNBx7XoVAKkP_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UuCBBfZkTnRTrGraqWWlzm9JE-VFduhsfu7WaZjpCbJYOTXpPhSNOnzGeS8qVuIsG6dORbME22lc5ltLxPjRqofQdDCNGQehCeQ=="}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_key_z4Fi7hZNBx7XoVAKkP","op":"set","value":"encrypted_USTrBuobwTCORwy5yHxy4sFZ7swfrafP6k5ZwcTf76f0MBu9Ie-JmsX3mNXad4mluI47gvGXzi8I_"}]',
|
||||
},
|
||||
{
|
||||
privacy: "trusting",
|
||||
madeAt: 1754475279624,
|
||||
changes:
|
||||
'[{"key":"readKey","op":"set","value":"key_z4Fi7hZNBx7XoVAKkP"}]',
|
||||
},
|
||||
],
|
||||
lastSignature:
|
||||
"signature_z3tsE7U1JaeNeUmZ4EY3Xq5uQ9jq9jDi6Rkhdt7T7b7z4NCnpMgB4bo8TwLXYVCrRdBm6PoyyPdK8fYFzHJUh5EzA",
|
||||
},
|
||||
},
|
||||
} as unknown as NewContentMessage;
|
||||
|
||||
client.node.syncManager.handleNewContent(brokenGroupContent, "import");
|
||||
|
||||
const group = expectGroup(
|
||||
client.node.getCoValue(brokenGroupContent.id).getCurrentContent(),
|
||||
);
|
||||
|
||||
expect(group.get(`${group.get("readKey")!}_for_everyone`)).toBe(
|
||||
group.core.getCurrentReadKey()?.secret,
|
||||
);
|
||||
});
|
||||
|
||||
describe("writeOnly", () => {
|
||||
test("Admins can invite writeOnly members", async () => {
|
||||
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
|
||||
|
||||
@@ -590,11 +590,14 @@ describe("loading coValues from server", () => {
|
||||
"client -> server | LOAD Map sessions: empty",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | KNOWN Group sessions: header/5",
|
||||
"client -> server | LOAD Map sessions: empty",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"server -> client | CONTENT Group header: true new: After: 0 New: 5",
|
||||
"client -> server | KNOWN Group sessions: header/5",
|
||||
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
|
||||
"client -> server | LOAD Group sessions: header/5",
|
||||
"client -> server | KNOWN Map sessions: header/1",
|
||||
"client -> server | LOAD Group sessions: header/5",
|
||||
"server -> client | KNOWN Group sessions: header/5",
|
||||
"client -> server | LOAD Map sessions: header/1",
|
||||
"server -> client | KNOWN Map sessions: header/1",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -657,6 +657,11 @@ export async function setupTestAccount(
|
||||
connectToSyncServer,
|
||||
addStorage,
|
||||
addAsyncStorage,
|
||||
disconnect: () => {
|
||||
ctx.node.syncManager.getPeers().forEach((peer) => {
|
||||
peer.gracefulShutdown();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RawAccountID } from "../coValues/account.js";
|
||||
import { AgentID, SessionID } from "../ids.js";
|
||||
import type { RawAccountID } from "../coValues/account.js";
|
||||
import type { AgentID, SessionID } from "../ids.js";
|
||||
|
||||
export function accountOrAgentIDfromSessionID(
|
||||
sessionID: SessionID,
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { type RawCoValue, expectMap } from "../coValue.js";
|
||||
import { type RawCoValue } from "../coValue.js";
|
||||
import { RawGroup } from "../coValues/group.js";
|
||||
|
||||
export function expectGroup(content: RawCoValue): RawGroup {
|
||||
const map = expectMap(content);
|
||||
if (map.core.verified.header.ruleset.type !== "group") {
|
||||
throw new Error("Expected group ruleset in group");
|
||||
}
|
||||
|
||||
if (!(map instanceof RawGroup)) {
|
||||
if (content.type !== "comap") {
|
||||
throw new Error("Expected group");
|
||||
}
|
||||
|
||||
return map;
|
||||
if (content.core.verified.header.ruleset.type !== "group") {
|
||||
throw new Error("Expected group ruleset in group");
|
||||
}
|
||||
|
||||
if (!(content instanceof RawGroup)) {
|
||||
throw new Error("Expected group");
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# jazz-auth-betterauth
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
- cojson@0.16.5
|
||||
- jazz-betterauth-client-plugin@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-auth-betterauth",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.5",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# jazz-betterauth-client-plugin
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- jazz-betterauth-server-plugin@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-betterauth-client-plugin",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.5",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# jazz-betterauth-server-plugin
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
- cojson@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-betterauth-server-plugin",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.5",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# jazz-react-auth-betterauth
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
- cojson@0.16.5
|
||||
- jazz-auth-betterauth@0.16.5
|
||||
- jazz-betterauth-client-plugin@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jazz-react-auth-betterauth",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.5",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.tsx",
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# jazz-run
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
- cojson@0.16.5
|
||||
- cojson-storage-sqlite@0.16.5
|
||||
- cojson-transport-ws@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"bin": "./dist/index.js",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.5",
|
||||
"exports": {
|
||||
"./startSyncServer": {
|
||||
"types": "./dist/startSyncServer.d.ts",
|
||||
@@ -28,11 +28,11 @@
|
||||
"@effect/printer-ansi": "^0.34.5",
|
||||
"@effect/schema": "^0.71.1",
|
||||
"@effect/typeclass": "^0.25.5",
|
||||
"cojson": "workspace:0.16.4",
|
||||
"cojson-storage-sqlite": "workspace:0.16.4",
|
||||
"cojson-transport-ws": "workspace:0.16.4",
|
||||
"cojson": "workspace:0.16.5",
|
||||
"cojson-storage-sqlite": "workspace:0.16.5",
|
||||
"cojson-transport-ws": "workspace:0.16.5",
|
||||
"effect": "^3.6.5",
|
||||
"jazz-tools": "workspace:0.16.4",
|
||||
"jazz-tools": "workspace:0.16.5",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# jazz-tools
|
||||
|
||||
## 0.16.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3cd1586: Makes the key rotation not fail when child groups are unavailable or their readkey is not accessible.
|
||||
|
||||
Also changes the Group.removeMember method to not return a Promise, because:
|
||||
|
||||
- All the locally available child groups are rotated immediately
|
||||
- All the remote child groups are rotated in background, but since they are not locally available the user won't need the new key immediately
|
||||
|
||||
- 33ebbf0: Fix error when using nested discriminatedUnion
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [267f689]
|
||||
- cojson@0.16.5
|
||||
- cojson-storage-indexeddb@0.16.5
|
||||
- cojson-transport-ws@0.16.5
|
||||
|
||||
## 0.16.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "0.16.4",
|
||||
"version": "0.16.5",
|
||||
"dependencies": {
|
||||
"@manuscripts/prosemirror-recreate-steps": "^0.1.4",
|
||||
"@scure/base": "1.2.1",
|
||||
|
||||
@@ -167,15 +167,15 @@ export class Group extends CoValueBase implements CoValue {
|
||||
}
|
||||
}
|
||||
|
||||
removeMember(member: Everyone | Account): Promise<void>;
|
||||
removeMember(member: Everyone | Account): void;
|
||||
/** @category Identity & Permissions
|
||||
* Revokes membership from members a parent group.
|
||||
* @param member The group that will lose access to this group.
|
||||
*/
|
||||
removeMember(member: Group): Promise<void>;
|
||||
removeMember(member: Group): void;
|
||||
removeMember(member: Group | Everyone | Account) {
|
||||
if (member !== "everyone" && member._type === "Group") {
|
||||
return this._raw.revokeExtend(member._raw);
|
||||
this._raw.revokeExtend(member._raw);
|
||||
} else {
|
||||
return this._raw.removeMember(
|
||||
member === "everyone" ? member : member._raw,
|
||||
|
||||
@@ -81,8 +81,7 @@ export function schemaUnionDiscriminatorFor(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (discriminatorDef._zod.def.type !== "literal") {
|
||||
if (discriminatorDef._zod?.def.type !== "literal") {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -141,3 +140,9 @@ export function isUnionOfPrimitivesDeeply(schema: AnyZodOrCoValueSchema) {
|
||||
return !isAnyCoValueSchema(schema);
|
||||
}
|
||||
}
|
||||
|
||||
function isCoDiscriminatedUnion(
|
||||
def: any,
|
||||
): def is CoreCoDiscriminatedUnionSchema<any> {
|
||||
return def.builtin === "CoDiscriminatedUnion";
|
||||
}
|
||||
|
||||
@@ -308,4 +308,54 @@ describe("co.discriminatedUnion", () => {
|
||||
|
||||
expect(updates[0]?.name).toEqual("Rex");
|
||||
});
|
||||
|
||||
test("should work when one of the options has a dicriminated union field", async () => {
|
||||
const Collie = co.map({
|
||||
type: z.literal("collie"),
|
||||
});
|
||||
const BorderCollie = co.map({
|
||||
type: z.literal("border-collie"),
|
||||
});
|
||||
const Breed = co.discriminatedUnion("type", [Collie, BorderCollie]);
|
||||
|
||||
const Dog = co.map({
|
||||
type: z.literal("dog"),
|
||||
breed: Breed,
|
||||
});
|
||||
|
||||
const Animal = co.discriminatedUnion("type", [Dog]);
|
||||
|
||||
const animal = Dog.create({
|
||||
type: "dog",
|
||||
breed: {
|
||||
type: "collie",
|
||||
},
|
||||
});
|
||||
|
||||
const loadedAnimal = await Animal.load(animal.id);
|
||||
|
||||
expect(loadedAnimal?.breed?.type).toEqual("collie");
|
||||
});
|
||||
|
||||
test("should work with a nested co.discriminatedUnion", async () => {
|
||||
const Collie = co.map({
|
||||
type: z.literal("collie"),
|
||||
});
|
||||
const BorderCollie = co.map({
|
||||
type: z.literal("border-collie"),
|
||||
});
|
||||
const Breed = co.discriminatedUnion("type", [Collie, BorderCollie]);
|
||||
|
||||
const Dog = co.discriminatedUnion("type", [Breed]);
|
||||
|
||||
const Animal = co.discriminatedUnion("type", [Dog]);
|
||||
|
||||
const animal = Collie.create({
|
||||
type: "collie",
|
||||
});
|
||||
|
||||
const loadedAnimal = await Animal.load(animal.id);
|
||||
|
||||
expect(loadedAnimal?.type).toEqual("collie");
|
||||
});
|
||||
});
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -1845,19 +1845,19 @@ importers:
|
||||
specifier: ^0.25.5
|
||||
version: 0.25.8(effect@3.11.9)
|
||||
cojson:
|
||||
specifier: workspace:0.16.4
|
||||
specifier: workspace:0.16.5
|
||||
version: link:../cojson
|
||||
cojson-storage-sqlite:
|
||||
specifier: workspace:0.16.4
|
||||
specifier: workspace:0.16.5
|
||||
version: link:../cojson-storage-sqlite
|
||||
cojson-transport-ws:
|
||||
specifier: workspace:0.16.4
|
||||
specifier: workspace:0.16.5
|
||||
version: link:../cojson-transport-ws
|
||||
effect:
|
||||
specifier: ^3.6.5
|
||||
version: 3.11.9
|
||||
jazz-tools:
|
||||
specifier: workspace:0.16.4
|
||||
specifier: workspace:0.16.5
|
||||
version: link:../jazz-tools
|
||||
ws:
|
||||
specifier: ^8.14.2
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# jazz-react-tailwind-starter
|
||||
|
||||
## 0.0.142
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
|
||||
## 0.0.141
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jazz-react-passkey-auth-starter",
|
||||
"private": true,
|
||||
"version": "0.0.141",
|
||||
"version": "0.0.142",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# svelte-passkey-auth
|
||||
|
||||
## 0.0.116
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3cd1586]
|
||||
- Updated dependencies [33ebbf0]
|
||||
- jazz-tools@0.16.5
|
||||
|
||||
## 0.0.115
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "svelte-passkey-auth",
|
||||
"version": "0.0.115",
|
||||
"version": "0.0.116",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user