Compare commits

...

51 Commits

Author SHA1 Message Date
Trisha Lim
90aa3f42ea Examples: add id to headings 2024-12-04 17:37:27 +00:00
Anselm Eickhoff
b0072e63e3 Merge pull request #934 from garden-co/jazz-555-use-vercel-adapter
Use Vercel adapter for Svelte
2024-12-04 17:34:56 +00:00
Anselm Eickhoff
a479ece032 Merge pull request #601 from garden-co/aeplay-jazz-12
Group Inheritance
2024-12-04 17:27:58 +00:00
Benjamin S. Leveritt
e0dbe46d64 Use Vercel adapter for Svelte 2024-12-04 17:09:02 +00:00
Guido D'Orsi
1f1fc56720 test(sharing): cover the out-of-sync children 2024-12-04 18:04:10 +01:00
Guido D'Orsi
64fa74a6d9 chore: update tests 2024-12-04 17:52:09 +01:00
Anselm Eickhoff
66538fdcf5 Merge pull request #931 from garden-co/jazz-554-pin-typesws5810
Pin @types/ws to 8.5.10
2024-12-04 16:47:36 +00:00
Benjamin S. Leveritt
543f91277d Remove e2e workspace from package 2024-12-04 16:43:46 +00:00
Benjamin S. Leveritt
42112ec46c Pin @types/ws 2024-12-04 16:36:27 +00:00
Guido D'Orsi
f4170eb879 fix: fix parent key revelation when the key used isn't the current one 2024-12-04 17:15:54 +01:00
Guido D'Orsi
8278055e33 fix: correctly rotate keys on child groups 2024-12-04 16:31:20 +01:00
Guido D'Orsi
65c236571e test: cover role inheritance in the transactions checks 2024-12-04 15:07:47 +01:00
Guido D'Orsi
b53cc9930e fix(group-extend): resolve member state from parents when checking permissions 2024-12-04 12:40:35 +01:00
Guido D'Orsi
9c5b34d91c test(Sharing): improve the test and cover more logic 2024-12-04 12:40:01 +01:00
Guido D'Orsi
a6c119b98d fix: load all the child groups before starting to revoke access 2024-12-03 19:00:33 +01:00
Guido D'Orsi
6e3565d20f Merge remote-tracking branch 'origin/main' into aeplay-jazz-12 2024-12-03 16:57:28 +01:00
Guido D'Orsi
e5e21718f9 Merge pull request #918 from garden-co/create-obs-value-test
chore: merge subscribe and createCoValueObservable suites
2024-12-03 16:57:10 +01:00
Guido D'Orsi
70a93ab093 fix(group-extend): a user should be an admin in both groups to extend 2024-12-03 15:38:58 +01:00
Guido D'Orsi
dacaa02a01 chore(types): create the ParentGroupReference and ChildGroupReference type 2024-12-03 14:54:04 +01:00
Guido D'Orsi
55cc248d91 Merge remote-tracking branch 'origin/main' into aeplay-jazz-12 2024-12-03 14:45:48 +01:00
Guido D'Orsi
534fce6796 test(e2e): cover Group extension with a e2e test 2024-12-03 14:42:40 +01:00
Guido D'Orsi
357698f4fb chore: refactor parent/child keys extraction 2024-12-03 12:58:33 +01:00
Guido D'Orsi
9b80278b71 Merge remote-tracking branch 'origin/main' into aeplay-jazz-12 2024-12-03 11:32:18 +01:00
Guido D'Orsi
6b4cb357ce Merge pull request #751 from gardencmp/music-player-with-group-inheritance
feat(music-player): use group inheritance for the Playlist sharing
2024-11-15 11:43:43 +01:00
Guido D'Orsi
d6638742b0 feat(music-player): use group inheritance for the Playlist sharing 2024-11-13 18:13:37 +01:00
Anselm
ddb158d5fa Pre-release 2024-11-13 13:53:00 +00:00
Anselm
8b87117e0f Changeset (pre-release) 2024-11-13 13:45:44 +00:00
Anselm
e5000c2b6b Ensure storage implementations send parent group content first 2024-11-13 13:43:29 +00:00
Anselm
0347e52118 Remove unused imports 2024-11-13 11:48:36 +00:00
Anselm
bb9ba33e73 Formatting 2024-11-13 11:47:43 +00:00
Anselm
92c63d94b9 Treat extended groups (parents) as depended on CoValues for syncing 2024-11-13 11:46:24 +00:00
Anselm
75339c0939 Merge branch 'main' into aeplay-jazz-12 2024-11-12 15:27:29 +00:00
Anselm
22102deabc Merge branch 'main' into aeplay-jazz-12 2024-11-08 17:21:06 +00:00
Anselm
043e2acae4 Format 2024-11-06 16:03:58 +00:00
Anselm
1b7ef1c2c0 Merge branch 'main' into aeplay-jazz-12 2024-11-06 16:03:48 +00:00
Anselm
996092c26f Failing high-level test 2024-11-04 17:37:46 +00:00
Anselm
3c794bba0a Test for extending more than one level deep 2024-11-04 17:35:52 +00:00
Anselm
2d3d53d144 Test key rotation more than one level deep 2024-11-04 17:31:56 +00:00
Anselm
69d05c8c15 More high-level tests 2024-11-04 17:00:26 +00:00
Anselm
d11aeee083 Test revocation in high-level test 2024-11-04 15:54:56 +00:00
Anselm
4d5848161d Failing high-level test 2024-11-01 16:53:08 +00:00
Anselm
8ee456d4e4 Implement and test extend() 2024-11-01 16:44:50 +00:00
Anselm
7b2c2e6084 Rotate child keys on parent group key rotation 2024-11-01 16:38:26 +00:00
Anselm
133f75d34e Reveal new child read keys to parent read key 2024-11-01 16:08:57 +00:00
Anselm
68c9114896 Merge branch 'main' into aeplay-jazz-12 2024-11-01 15:42:27 +00:00
Anselm
57ff9e2d1f Implement and test lookup/revelation of parent read keys 2024-10-18 14:18:33 +01:00
Anselm
1cac820ec6 Add test for grand-parent inheritance 2024-10-18 13:16:43 +01:00
Anselm
eb4646beca Role inheritance (one level) 2024-10-18 12:05:43 +01:00
Anselm
5447d6f10b Start accepting parent and child extension transactions 2024-10-18 11:49:39 +01:00
Anselm
dbb040eb07 Prepare role getter that will traverse parent groups 2024-10-18 11:18:22 +01:00
Anselm
9960320645 Small refactors for valid transactions 2024-10-18 11:10:34 +01:00
29 changed files with 3220 additions and 374 deletions

View File

@@ -0,0 +1,8 @@
---
"cojson-storage-indexeddb": patch
"cojson-storage-sqlite": patch
"jazz-tools": patch
"cojson": patch
---
Implement Group Inheritance

View File

@@ -26,13 +26,13 @@ export async function uploadMusicTracks(
account: MusicaAccount,
files: Iterable<File>,
) {
// The ownership object defines the user that owns the created coValues
// by setting the ownership with "account" we configure the coValues to be private
const ownership = {
owner: account,
};
for (const file of files) {
// The ownership object defines the user that owns the created coValues
// We are creating a group for each CoValue in order to be able to share them via Playlist
const ownership = {
owner: Group.create({ owner: account }),
};
const data = await getAudioFileData(file);
// We transform the file blob into a FileStream
@@ -86,16 +86,31 @@ export async function addTrackToPlaylist(
) {
if (!account) return;
if (playlist.tracks?.some((t) => t?._refs.sourceTrack.id === track.id))
const alreadyAdded = playlist.tracks?.some(
(t) => t?.id === track.id || t?._refs.sourceTrack?.id === track.id,
);
if (alreadyAdded) return;
// Check if the track has been created after the Group inheritance was introduced
if (track._owner._type === "Group" && playlist._owner._type === "Group") {
/**
* Extending the track with the Playlist group in order to make the music track
* visible to the Playlist user
*/
const trackGroup = track._owner;
trackGroup.extend(playlist._owner);
playlist.tracks?.push(track);
return;
}
/**
* Since musicTracks are created as private values (see uploadMusicTracks)
* to make them shareable as part of the playlist we are cloning them
* and setting the playlist group as owner of the clone
*
* In the future it will be possible to "inherit" the parent group so you
* won't need to clone values to have this kind of sharing granularity
* Doing this for backwards compatibility for when the Group inheritance wasn't possible
*/
const ownership = { owner: playlist._owner };
const blob = await FileStream.loadAsBlob(track._refs.file.id, account);

View File

@@ -3,11 +3,13 @@ import clsx from "clsx";
interface HeadingProps {
children: React.ReactNode;
className?: string;
id?: string;
}
export function H1({ children, className }: HeadingProps) {
export function H1({ children, className, id }: HeadingProps) {
return (
<h1
id={id}
className={clsx(
className,
"font-display",
@@ -23,9 +25,10 @@ export function H1({ children, className }: HeadingProps) {
);
}
export function H2({ children, className }: HeadingProps) {
export function H2({ children, className, id }: HeadingProps) {
return (
<h2
id={id}
className={clsx(
className,
"font-display",
@@ -41,9 +44,10 @@ export function H2({ children, className }: HeadingProps) {
);
}
export function H3({ children, className }: HeadingProps) {
export function H3({ children, className, id }: HeadingProps) {
return (
<h3
id={id}
className={clsx(
className,
"font-display",
@@ -59,8 +63,12 @@ export function H3({ children, className }: HeadingProps) {
);
}
export function H4({ children, className }: HeadingProps) {
return <h4 className={clsx(className, "text-bold")}>{children}</h4>;
export function H4({ children, className, id }: HeadingProps) {
return (
<h4 id={id} className={clsx(className, "text-bold")}>
{children}
</h4>
);
}
export function Kicker({ children, className }: HeadingProps) {

View File

@@ -361,21 +361,25 @@ const vueExamples: Example[] = [
const categories = [
{
name: "React",
id: "react",
logo: ReactLogo,
examples: reactExamples,
},
{
name: "Next.js",
id: "next",
logo: NextjsLogo,
examples: nextExamples,
},
{
name: "React Native",
id: "react-native",
logo: ReactNativeLogo,
examples: rnExamples,
},
{
name: "Vue",
id: "vue",
logo: VueLogo,
examples: vueExamples,
},
@@ -443,7 +447,9 @@ export default function Page() {
<div key={category.name}>
<div className="flex items-center gap-3 mb-5">
<category.logo className="h-8 w-8" />
<H2 className="!mb-0">{category.name}</H2>
<H2 id={category.id} className="!mb-0">
{category.name}
</H2>
</div>
<GappedGrid>

View File

@@ -2,7 +2,7 @@
"name": "jazz-monorepo",
"private": true,
"type": "module",
"workspaces": ["packages/*", "examples/*", "e2e/*"],
"workspaces": ["packages/*", "examples/*"],
"packageManager": "pnpm@9.1.4",
"devDependencies": {
"@biomejs/biome": "1.9.4",

View File

@@ -1,7 +1,8 @@
import {
CojsonInternalTypes,
RawAccountID,
JsonValue,
SessionID,
Stringified,
cojsonInternals,
} from "cojson";
import {
@@ -63,46 +64,83 @@ export function getDependedOnCoValues({
newContentMessages: CojsonInternalTypes.NewContentMessage[];
}) {
return coValueRow.header.ruleset.type === "group"
? newContentMessages
.flatMap((piece) => Object.values(piece.new))
.flatMap((sessionEntry) =>
sessionEntry.newTransactions.flatMap((tx) => {
if (tx.privacy !== "trusting") return [];
return cojsonInternals
.parseJSON(tx.changes)
.map(
(change) =>
change &&
typeof change === "object" &&
"op" in change &&
change.op === "set" &&
"key" in change &&
change.key,
)
.filter(
(key): key is CojsonInternalTypes.RawCoID =>
typeof key === "string" && key.startsWith("co_"),
);
}),
)
? getGroupDependedOnCoValues(newContentMessages)
: coValueRow.header.ruleset.type === "ownedByGroup"
? [
coValueRow.header.ruleset.group,
...new Set(
newContentMessages.flatMap((piece) =>
Object.keys(piece.new)
.map((sessionID) =>
cojsonInternals.accountOrAgentIDfromSessionID(
sessionID as SessionID,
),
)
.filter(
(accountID): accountID is RawAccountID =>
cojsonInternals.isAccountID(accountID) &&
accountID !== coValueRow.id,
),
),
),
]
? getOwnedByGroupDependedOnCoValues(coValueRow, newContentMessages)
: [];
}
function getGroupDependedOnCoValues(
newContentMessages: CojsonInternalTypes.NewContentMessage[],
) {
const keys: CojsonInternalTypes.RawCoID[] = [];
/**
* Collect all the signing keys inside the transactions to list all the
* dependencies required to correctly access the CoValue.
*/
for (const piece of newContentMessages) {
for (const sessionEntry of Object.values(piece.new)) {
for (const tx of sessionEntry.newTransactions) {
if (tx.privacy !== "trusting") continue;
const changes = safeParseChanges(tx.changes);
for (const change of changes) {
if (
change &&
typeof change === "object" &&
"op" in change &&
change.op === "set" &&
"key" in change &&
change.key
) {
const key = cojsonInternals.getGroupDependentKey(change.key);
if (key) {
keys.push(key);
}
}
}
}
}
}
return keys;
}
function getOwnedByGroupDependedOnCoValues(
coValueRow: StoredCoValueRow,
newContentMessages: CojsonInternalTypes.NewContentMessage[],
) {
if (coValueRow.header.ruleset.type !== "ownedByGroup") return [];
const keys: CojsonInternalTypes.RawCoID[] = [coValueRow.header.ruleset.group];
/**
* Collect all the signing keys inside the transactions to list all the
* dependencies required to correctly access the CoValue.
*/
for (const piece of newContentMessages) {
for (const sessionID of Object.keys(piece.new) as SessionID[]) {
const accountId =
cojsonInternals.accountOrAgentIDfromSessionID(sessionID);
if (
cojsonInternals.isAccountID(accountId) &&
accountId !== coValueRow.id
) {
keys.push(accountId);
}
}
}
return keys;
}
function safeParseChanges(changes: Stringified<JsonValue[]>) {
try {
return cojsonInternals.parseJSON(changes);
} catch (e) {
return [];
}
}

View File

@@ -0,0 +1,179 @@
import { CojsonInternalTypes, SessionID, Stringified } from "cojson";
import { describe, expect, it, vi } from "vitest";
import { getDependedOnCoValues } from "../syncUtils";
function getMockedSessionID(accountId?: `co_z${string}`) {
return `${accountId ?? getMockedCoValueId()}_session_z${Math.random().toString(36).substring(2, 15)}`;
}
function getMockedCoValueId() {
return `co_z${Math.random().toString(36).substring(2, 15)}` as const;
}
function generateNewContentMessage(
privacy: "trusting" | "private",
changes: any[],
accountId?: `co_z${string}`,
) {
return {
action: "content",
id: getMockedCoValueId(),
new: {
[getMockedSessionID(accountId)]: {
after: 0,
lastSignature: "signature_z123",
newTransactions: [
{
privacy,
madeAt: 0,
changes: JSON.stringify(changes) as any,
},
],
},
},
priority: 0,
} as CojsonInternalTypes.NewContentMessage;
}
describe("getDependedOnCoValues", () => {
it("should return dependencies for group ruleset", () => {
const coValueRow = {
id: "co_test",
header: {
ruleset: {
type: "group",
},
},
} as any;
const result = getDependedOnCoValues({
coValueRow,
newContentMessages: [
generateNewContentMessage("trusting", [
{ op: "set", key: "co_zabc123", value: "test" },
{ op: "set", key: "parent_co_zdef456", value: "test" },
{ op: "set", key: "normal_key", value: "test" },
]),
],
});
expect(result).toEqual(["co_zabc123", "co_zdef456"]);
});
it("should not throw on malformed JSON", () => {
const coValueRow = {
id: "co_test",
header: {
ruleset: {
type: "group",
},
},
} as any;
const message = generateNewContentMessage("trusting", [
{ op: "set", key: "co_zabc123", value: "test" },
]);
message.new["invalid_session" as SessionID] = {
after: 0,
lastSignature: "signature_z123",
newTransactions: [
{
privacy: "trusting",
madeAt: 0,
changes: "}{-:)" as Stringified<CojsonInternalTypes.JsonObject[]>,
},
],
};
const result = getDependedOnCoValues({
coValueRow,
newContentMessages: [message],
});
expect(result).toEqual(["co_zabc123"]);
});
it("should return dependencies for ownedByGroup ruleset", () => {
const groupId = getMockedCoValueId();
const coValueRow = {
id: "co_owner",
header: {
ruleset: {
type: "ownedByGroup",
group: groupId,
},
},
} as any;
const accountId = getMockedCoValueId();
const message = generateNewContentMessage(
"trusting",
[
{ op: "set", key: "co_zabc123", value: "test" },
{ op: "set", key: "parent_co_zdef456", value: "test" },
{ op: "set", key: "normal_key", value: "test" },
],
accountId,
);
message.new["invalid_session" as SessionID] = {
after: 0,
lastSignature: "signature_z123",
newTransactions: [],
};
const result = getDependedOnCoValues({
coValueRow,
newContentMessages: [message],
});
expect(result).toEqual([groupId, accountId]);
});
it("should return empty array for other ruleset types", () => {
const coValueRow = {
id: "co_test",
header: {
ruleset: {
type: "other",
},
},
} as any;
const result = getDependedOnCoValues({
coValueRow,
newContentMessages: [
generateNewContentMessage("trusting", [
{ op: "set", key: "co_zabc123", value: "test" },
{ op: "set", key: "parent_co_zdef456", value: "test" },
{ op: "set", key: "normal_key", value: "test" },
]),
],
});
expect(result).toEqual([]);
});
it("should ignore non-trusting transactions in group ruleset", () => {
const coValueRow = {
id: "co_test",
header: {
ruleset: {
type: "group",
},
},
} as any;
const result = getDependedOnCoValues({
coValueRow,
newContentMessages: [
generateNewContentMessage("private", [
{ op: "set", key: "co_zabc123", value: "test" },
]),
],
});
expect(result).toEqual([]);
});
});

View File

@@ -382,8 +382,8 @@ export class SQLiteStorage {
return [];
}
return parsedChanges
.map(
return cojsonInternals.getGroupDependentKeyList(
parsedChanges.map(
(change) =>
change &&
typeof change === "object" &&
@@ -391,11 +391,8 @@ export class SQLiteStorage {
change.op === "set" &&
"key" in change &&
change.key,
)
.filter(
(key): key is CojsonInternalTypes.RawCoID =>
typeof key === "string" && key.startsWith("co_"),
);
),
);
}),
)
: parsedHeader?.ruleset.type === "ownedByGroup"

View File

@@ -19,6 +19,6 @@
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@types/ws": "^8.5.5"
"@types/ws": "8.5.10"
}
}

View File

@@ -13,7 +13,14 @@ import {
SignerID,
StreamingHash,
} from "./crypto/crypto.js";
import { RawCoID, SessionID, TransactionID } from "./ids.js";
import {
RawCoID,
SessionID,
TransactionID,
getGroupDependentKeyList,
getParentGroupId,
isParentGroupReference,
} from "./ids.js";
import { Stringified, parseJSON, stableStringify } from "./jsonStringify.js";
import { JsonObject, JsonValue } from "./jsonValue.js";
import { LocalNode, ResolveAccountAgentError } from "./localNode.js";
@@ -790,6 +797,48 @@ export class CoValueCore {
}
}
// 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 {
console.error(
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
);
}
}
}
}
}
return undefined;
} else if (this.header.ruleset.type === "ownedByGroup") {
return this.node
@@ -802,6 +851,28 @@ 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.header.ruleset.type !== "ownedByGroup") {
throw new Error("Only values owned by groups have groups");
@@ -955,9 +1026,7 @@ export class CoValueCore {
/** @internal */
getDependedOnCoValuesUncached(): RawCoID[] {
return this.header.ruleset.type === "group"
? expectGroup(this.getCurrentContent())
.keys()
.filter((k): k is RawAccountID => k.startsWith("co_"))
? getGroupDependentKeyList(expectGroup(this.getCurrentContent()).keys())
: this.header.ruleset.type === "ownedByGroup"
? [
this.header.ruleset.group,

View File

@@ -2,9 +2,19 @@ import { base58 } from "@scure/base";
import { CoID } from "../coValue.js";
import { CoValueUniqueness } from "../coValueCore.js";
import { Encrypted, KeyID, KeySecret, Sealed } from "../crypto/crypto.js";
import { AgentID, isAgentID } from "../ids.js";
import {
AgentID,
ChildGroupReference,
ParentGroupReference,
getChildGroupId,
getParentGroupId,
isAgentID,
isChildGroupReference,
isParentGroupReference,
} from "../ids.js";
import { JsonObject } from "../jsonValue.js";
import { Role } from "../permissions.js";
import { expectGroup } from "../typeUtils/expectGroup.js";
import {
ControlledAccountOrAgent,
RawAccount,
@@ -29,6 +39,8 @@ export type GroupShape = {
KeySecret,
{ encryptedID: KeyID; encryptingID: KeyID }
>;
[parent: ParentGroupReference]: "extend";
[child: ChildGroupReference]: "extend";
};
/** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
@@ -61,12 +73,109 @@ export class RawGroup<
* @category 1. Role reading
*/
roleOf(accountID: RawAccountID): Role | undefined {
return this.roleOfInternal(accountID);
return this.roleOfInternal(accountID)?.role;
}
/** @internal */
roleOfInternal(accountID: RawAccountID | AgentID): Role | undefined {
return this.get(accountID);
roleOfInternal(
accountID: RawAccountID | AgentID | typeof EVERYONE,
): { role: Role; via: CoID<RawGroup> | undefined } | undefined {
const roleHere = this.get(accountID);
if (roleHere === "revoked") {
return undefined;
}
let roleInfo:
| {
role: Exclude<Role, "revoked">;
via: CoID<RawGroup> | undefined;
}
| undefined = roleHere && { role: roleHere, via: undefined };
const parentGroups = this.getParentGroups();
for (const parentGroup of parentGroups) {
const roleInParent = parentGroup.roleOfInternal(accountID);
if (
roleInParent &&
roleInParent.role !== "revoked" &&
isMorePermissiveAndShouldInherit(roleInParent.role, roleInfo?.role)
) {
roleInfo = { role: roleInParent.role, via: parentGroup.id };
}
}
return roleInfo;
}
getParentGroups() {
const groups: RawGroup[] = [];
for (const key of this.keys()) {
if (isParentGroupReference(key)) {
const parent = this.core.node.expectCoValueLoaded(
getParentGroupId(key),
"Expected parent group to be loaded",
);
groups.push(expectGroup(parent.getCurrentContent()));
}
}
return groups;
}
loadAllChildGroups() {
const requests: Promise<unknown>[] = [];
const store = this.core.node.coValuesStore;
const peers = this.core.node.syncManager.getServerAndStoragePeers();
for (const key of this.keys()) {
if (!isChildGroupReference(key)) {
continue;
}
const id = getChildGroupId(key);
const child = store.get(id);
if (
child.state.type === "unknown" ||
child.state.type === "unavailable"
) {
child.loadFromPeers(peers).catch(() => {
console.error(`Failed to load child group ${id}`);
});
}
requests.push(
child.getCoValue().then((coValue) => {
if (coValue === "unavailable") {
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[] = [];
for (const key of this.keys()) {
if (isChildGroupReference(key)) {
const child = this.core.node.expectCoValueLoaded(
getChildGroupId(key),
"Expected child group to be loaded",
);
groups.push(expectGroup(child.getCurrentContent()));
}
}
return groups;
}
/**
@@ -75,7 +184,7 @@ export class RawGroup<
* @category 1. Role reading
*/
myRole(): Role | undefined {
return this.roleOfInternal(this.core.node.account.id);
return this.roleOfInternal(this.core.node.account.id)?.role;
}
/**
@@ -158,6 +267,10 @@ export class RawGroup<
}
}) as (RawAccountID | AgentID)[];
// Get these early, so we fail fast if they are unavailable
const parentGroups = this.getParentGroups();
const childGroups = this.getChildGroups();
const maybeCurrentReadKey = this.core.getCurrentReadKey();
if (!maybeCurrentReadKey.secret) {
@@ -204,6 +317,73 @@ export class RawGroup<
);
this.set("readKey", newReadKey.id, "trusting");
// when we rotate our readKey (because someone got kicked out), we also need to (recursively)
// rotate the readKeys of all child groups (so they are kicked out there as well)
for (const parent of parentGroups) {
const { id: parentReadKeyID, secret: parentReadKeySecret } =
parent.core.getCurrentReadKey();
if (!parentReadKeySecret) {
throw new Error(
"Can't reveal new child key to parent where we don't have access to the parent read key",
);
}
this.set(
`${newReadKey.id}_for_${parentReadKeyID}`,
this.core.crypto.encryptKeySecret({
encrypting: {
id: parentReadKeyID,
secret: parentReadKeySecret,
},
toEncrypt: newReadKey,
}).encrypted,
"trusting",
);
}
for (const child of childGroups) {
child.rotateReadKey();
}
}
extend(parent: RawGroup) {
if (parent.myRole() !== "admin" || this.myRole() !== "admin") {
throw new Error(
"To extend a group, the current account must have admin role in both groups",
);
}
this.set(`parent_${parent.id}`, "extend", "trusting");
parent.set(`child_${this.id}`, "extend", "trusting");
const { id: parentReadKeyID, secret: parentReadKeySecret } =
parent.core.getCurrentReadKey();
if (!parentReadKeySecret) {
throw new Error("Can't extend group without parent read key secret");
}
const { id: childReadKeyID, secret: childReadKeySecret } =
this.core.getCurrentReadKey();
if (!childReadKeySecret) {
throw new Error("Can't extend group without child read key secret");
}
this.set(
`${childReadKeyID}_for_${parentReadKeyID}`,
this.core.crypto.encryptKeySecret({
encrypting: {
id: parentReadKeyID,
secret: parentReadKeySecret,
},
toEncrypt: {
id: childReadKeyID,
secret: childReadKeySecret,
},
}).encrypted,
"trusting",
);
}
/**
@@ -213,7 +393,12 @@ export class RawGroup<
*
* @category 2. Role changing
*/
removeMember(account: RawAccount | ControlledAccountOrAgent | Everyone) {
async removeMember(
account: RawAccount | ControlledAccountOrAgent | Everyone,
) {
// Ensure all child groups are loaded before removing a member
await this.loadAllChildGroups();
this.removeMemberInternal(account);
}
@@ -347,6 +532,34 @@ export class RawGroup<
}
}
function isMorePermissiveAndShouldInherit(
roleInParent: Role,
roleInChild: Exclude<Role, "revoked"> | undefined,
) {
// invites should never be inherited
if (
roleInParent === "adminInvite" ||
roleInParent === "writerInvite" ||
roleInParent === "readerInvite"
) {
return false;
}
if (roleInParent === "admin") {
return !roleInChild || roleInChild !== "admin";
}
if (roleInParent === "writer") {
return !roleInChild || roleInChild === "reader";
}
if (roleInParent === "reader") {
return !roleInChild;
}
return false;
}
export type InviteSecret = `inviteSecret_z${string}`;
function inviteSecretFromSecretSeed(secretSeed: Uint8Array): InviteSecret {

View File

@@ -23,8 +23,14 @@ import {
secretSeedLength,
shortHashLength,
} from "./crypto/crypto.js";
import { isRawCoID, rawCoIDfromBytes, rawCoIDtoBytes } from "./ids.js";
import { parseJSON } from "./jsonStringify.js";
import {
getGroupDependentKey,
getGroupDependentKeyList,
isRawCoID,
rawCoIDfromBytes,
rawCoIDtoBytes,
} from "./ids.js";
import { Stringified, parseJSON } from "./jsonStringify.js";
import { LocalNode } from "./localNode.js";
import type { Role } from "./permissions.js";
import { Channel, connectedPeers } from "./streamUtils.js";
@@ -83,6 +89,8 @@ export const cojsonInternals = {
StreamingHash,
Channel,
getPriorityFromHeader,
getGroupDependentKeyList,
getGroupDependentKey,
};
export {
@@ -133,6 +141,7 @@ export type {
DisconnectedError,
PingTimeoutError,
CoValueUniqueness,
Stringified,
};
// eslint-disable-next-line @typescript-eslint/no-namespace

View File

@@ -1,8 +1,12 @@
import { base58 } from "@scure/base";
import { CoID } from "./coValue.js";
import { RawAccountID } from "./coValues/account.js";
import { shortHashLength } from "./crypto/crypto.js";
import { RawGroup } from "./exports.js";
export type RawCoID = `co_z${string}`;
export type ParentGroupReference = `parent_${CoID<RawGroup>}`;
export type ChildGroupReference = `child_${CoID<RawGroup>}`;
export function isRawCoID(id: unknown): id is RawCoID {
return typeof id === "string" && id.startsWith("co_z");
@@ -29,3 +33,47 @@ export function isAgentID(id: string): id is AgentID {
}
export type SessionID = `${RawAccountID | AgentID}_session_z${string}`;
export function isParentGroupReference(
key: string,
): key is ParentGroupReference {
return key.startsWith("parent_");
}
export function getParentGroupId(key: ParentGroupReference): CoID<RawGroup> {
return key.slice("parent_".length) as CoID<RawGroup>;
}
export function isChildGroupReference(key: string): key is ChildGroupReference {
return key.startsWith("child_");
}
export function getChildGroupId(key: ChildGroupReference): CoID<RawGroup> {
return key.slice("child_".length) as CoID<RawGroup>;
}
export function getGroupDependentKey(key: unknown) {
if (typeof key !== "string") return undefined;
if (isParentGroupReference(key)) {
return getParentGroupId(key);
} else if (key.startsWith("co_")) {
return key as RawCoID;
}
return undefined;
}
export function getGroupDependentKeyList(keys: unknown[]) {
const groupDependentKeys: RawCoID[] = [];
for (const key of keys) {
const value = getGroupDependentKey(key);
if (value) {
groupDependentKeys.push(value);
}
}
return groupDependentKeys;
}

View File

@@ -2,9 +2,16 @@ import { CoID } from "./coValue.js";
import { CoValueCore, Transaction } from "./coValueCore.js";
import { RawAccount, RawAccountID, RawProfile } from "./coValues/account.js";
import { MapOpPayload } from "./coValues/coMap.js";
import { EVERYONE, Everyone } from "./coValues/group.js";
import { EVERYONE, Everyone, RawGroup } from "./coValues/group.js";
import { KeyID } from "./crypto/crypto.js";
import { AgentID, RawCoID, SessionID, TransactionID } from "./ids.js";
import {
AgentID,
ParentGroupReference,
RawCoID,
SessionID,
TransactionID,
getParentGroupId,
} from "./ids.js";
import { parseJSON } from "./jsonStringify.js";
import { JsonValue } from "./jsonValue.js";
import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.js";
@@ -24,202 +31,20 @@ export type Role =
| "writerInvite"
| "readerInvite";
type ValidTransactionsResult = { txID: TransactionID; tx: Transaction };
type MemberState = { [agent: RawAccountID | AgentID]: Role; [EVERYONE]?: Role };
export function determineValidTransactions(
coValue: CoValueCore,
): { txID: TransactionID; tx: Transaction }[] {
if (coValue.header.ruleset.type === "group") {
const allTransactionsSorted = [...coValue.sessionLogs.entries()].flatMap(
([sessionID, sessionLog]) => {
return sessionLog.transactions.map((tx, txIndex) => ({
sessionID,
txIndex,
tx,
})) as {
sessionID: SessionID;
txIndex: number;
tx: Transaction;
}[];
},
);
allTransactionsSorted.sort((a, b) => {
return a.tx.madeAt - b.tx.madeAt;
});
const initialAdmin = coValue.header.ruleset.initialAdmin;
if (!initialAdmin) {
throw new Error("Group must have initialAdmin");
}
const memberState: {
[agent: RawAccountID | AgentID]: Role;
[EVERYONE]?: Role;
} = {};
const validTransactions: { txID: TransactionID; tx: Transaction }[] = [];
for (const { sessionID, txIndex, tx } of allTransactionsSorted) {
// console.log("before", { memberState, validTransactions });
const transactor = accountOrAgentIDfromSessionID(sessionID);
if (tx.privacy === "private") {
if (memberState[transactor] === "admin") {
validTransactions.push({
txID: { sessionID, txIndex },
tx,
});
continue;
} else {
console.warn("Only admins can make private transactions in groups");
continue;
}
}
let changes;
try {
changes = parseJSON(tx.changes);
} catch (e) {
console.warn(
coValue.id,
"Invalid JSON in transaction",
e,
tx,
JSON.stringify(tx.changes, (k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
),
);
continue;
}
const change = changes[0] as
| MapOpPayload<RawAccountID | AgentID | Everyone, Role>
| MapOpPayload<"readKey", JsonValue>
| MapOpPayload<"profile", CoID<RawProfile>>;
if (changes.length !== 1) {
console.warn("Group transaction must have exactly one change");
continue;
}
if (change.op !== "set") {
console.warn("Group transaction must set a role or readKey");
continue;
}
if (change.key === "readKey") {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set readKeys");
continue;
}
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (change.key === "profile") {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set profile");
continue;
}
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (
isKeyForKeyField(change.key) ||
isKeyForAccountField(change.key)
) {
if (
memberState[transactor] !== "admin" &&
memberState[transactor] !== "adminInvite" &&
memberState[transactor] !== "writerInvite" &&
memberState[transactor] !== "readerInvite"
) {
console.warn("Only admins can reveal keys");
continue;
}
// TODO: check validity of agents who the key is revealed to?
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
}
const affectedMember = change.key;
const assignedRole = change.value;
if (
change.value !== "admin" &&
change.value !== "writer" &&
change.value !== "reader" &&
change.value !== "revoked" &&
change.value !== "adminInvite" &&
change.value !== "writerInvite" &&
change.value !== "readerInvite"
) {
console.warn("Group transaction must set a valid role");
continue;
}
if (
affectedMember === EVERYONE &&
!(
change.value === "reader" ||
change.value === "writer" ||
change.value === "revoked"
)
) {
console.warn("Everyone can only be set to reader, writer or revoked");
continue;
}
const isFirstSelfAppointment =
!memberState[transactor] &&
transactor === initialAdmin &&
change.op === "set" &&
change.key === transactor &&
change.value === "admin";
if (!isFirstSelfAppointment) {
if (memberState[transactor] === "admin") {
if (
memberState[affectedMember] === "admin" &&
affectedMember !== transactor &&
assignedRole !== "admin"
) {
console.warn("Admins can only demote themselves.");
continue;
}
} else if (memberState[transactor] === "adminInvite") {
if (change.value !== "admin") {
console.warn("AdminInvites can only create admins.");
continue;
}
} else if (memberState[transactor] === "writerInvite") {
if (change.value !== "writer") {
console.warn("WriterInvites can only create writers.");
continue;
}
} else if (memberState[transactor] === "readerInvite") {
if (change.value !== "reader") {
console.warn("ReaderInvites can only create reader.");
continue;
}
} else {
console.warn(
"Group transaction must be made by current admin or invite",
);
continue;
}
}
memberState[affectedMember] = change.value;
validTransactions.push({ txID: { sessionID, txIndex }, tx });
// console.log("after", { memberState, validTransactions });
}
return validTransactions;
return determineValidTransactionsForGroup(coValue, initialAdmin)
.validTransactions;
} else if (coValue.header.ruleset.type === "ownedByGroup") {
const groupContent = expectGroup(
coValue.node
@@ -241,27 +66,18 @@ export function determineValidTransactions(
return sessionLog.transactions
.filter((tx) => {
const groupAtTime = groupContent.atTime(tx.madeAt);
const effectiveTransactor =
transactor === groupContent.id &&
groupAtTime instanceof RawAccount
? groupAtTime.currentAgentID().match(
(agentID) => agentID,
(e) => {
console.error(
"Error while determining current agent ID in valid transactions",
e,
);
return undefined;
},
)
: transactor;
const effectiveTransactor = agentInAccountOrMemberInGroup(
transactor,
groupAtTime,
);
if (!effectiveTransactor) {
return false;
}
const transactorRoleAtTxTime =
groupAtTime.get(effectiveTransactor) || groupAtTime.get(EVERYONE);
groupAtTime.roleOfInternal(effectiveTransactor)?.role ||
groupAtTime.roleOfInternal(EVERYONE)?.role;
return (
transactorRoleAtTxTime === "admin" ||
@@ -291,6 +107,275 @@ export function determineValidTransactions(
}
}
function isHigherRole(a: Role, b: Role | undefined) {
if (a === undefined) return false;
if (b === undefined) return true;
if (b === "admin") return false;
if (a === "admin") return true;
return a === "writer" && b === "reader";
}
function resolveMemberStateFromParentReference(
coValue: CoValueCore,
memberState: MemberState,
parentReference: ParentGroupReference,
) {
const parentGroup = coValue.node.expectCoValueLoaded(
getParentGroupId(parentReference),
"Expected parent group to be loaded",
);
if (parentGroup.header.ruleset.type !== "group") {
return;
}
const initialAdmin = parentGroup.header.ruleset.initialAdmin;
if (!initialAdmin) {
throw new Error("Group must have initialAdmin");
}
const { memberState: parentGroupMemberState } =
determineValidTransactionsForGroup(parentGroup, initialAdmin);
for (const agent of Object.keys(parentGroupMemberState) as Array<
keyof MemberState
>) {
const parentRole = parentGroupMemberState[agent];
const currentRole = memberState[agent];
if (parentRole && isHigherRole(parentRole, currentRole)) {
memberState[agent] = parentRole;
}
}
}
function determineValidTransactionsForGroup(
coValue: CoValueCore,
initialAdmin: RawAccountID | AgentID,
): { validTransactions: ValidTransactionsResult[]; memberState: MemberState } {
const allTransactionsSorted = [...coValue.sessionLogs.entries()].flatMap(
([sessionID, sessionLog]) => {
return sessionLog.transactions.map((tx, txIndex) => ({
sessionID,
txIndex,
tx,
})) as {
sessionID: SessionID;
txIndex: number;
tx: Transaction;
}[];
},
);
allTransactionsSorted.sort((a, b) => {
return a.tx.madeAt - b.tx.madeAt;
});
const memberState: MemberState = {};
const validTransactions: ValidTransactionsResult[] = [];
for (const { sessionID, txIndex, tx } of allTransactionsSorted) {
// console.log("before", { memberState, validTransactions });
const transactor = accountOrAgentIDfromSessionID(sessionID);
if (tx.privacy === "private") {
if (memberState[transactor] === "admin") {
validTransactions.push({
txID: { sessionID, txIndex },
tx,
});
continue;
} else {
console.warn("Only admins can make private transactions in groups");
continue;
}
}
let changes;
try {
changes = parseJSON(tx.changes);
} catch (e) {
console.warn(
coValue.id,
"Invalid JSON in transaction",
e,
tx,
JSON.stringify(tx.changes, (k, v) =>
k === "changes" || k === "encryptedChanges"
? v.slice(0, 20) + "..."
: v,
),
);
continue;
}
const change = changes[0] as
| MapOpPayload<RawAccountID | AgentID | Everyone, Role>
| MapOpPayload<"readKey", JsonValue>
| MapOpPayload<"profile", CoID<RawProfile>>
| MapOpPayload<`parent_${CoID<RawGroup>}`, CoID<RawGroup>>
| MapOpPayload<`child_${CoID<RawGroup>}`, CoID<RawGroup>>;
if (changes.length !== 1) {
console.warn("Group transaction must have exactly one change");
continue;
}
if (change.op !== "set") {
console.warn("Group transaction must set a role or readKey");
continue;
}
if (change.key === "readKey") {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set readKeys");
continue;
}
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (change.key === "profile") {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set profile");
continue;
}
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (
isKeyForKeyField(change.key) ||
isKeyForAccountField(change.key)
) {
if (
memberState[transactor] !== "admin" &&
memberState[transactor] !== "adminInvite" &&
memberState[transactor] !== "writerInvite" &&
memberState[transactor] !== "readerInvite"
) {
console.warn("Only admins can reveal keys");
continue;
}
// TODO: check validity of agents who the key is revealed to?
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (isParentExtension(change.key)) {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set parent extensions");
continue;
}
resolveMemberStateFromParentReference(coValue, memberState, change.key);
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (isChildExtension(change.key)) {
if (memberState[transactor] !== "admin") {
console.warn("Only admins can set child extensions");
continue;
}
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
}
const affectedMember = change.key;
const assignedRole = change.value;
if (
change.value !== "admin" &&
change.value !== "writer" &&
change.value !== "reader" &&
change.value !== "revoked" &&
change.value !== "adminInvite" &&
change.value !== "writerInvite" &&
change.value !== "readerInvite"
) {
console.warn("Group transaction must set a valid role");
continue;
}
if (
affectedMember === EVERYONE &&
!(
change.value === "reader" ||
change.value === "writer" ||
change.value === "revoked"
)
) {
console.warn("Everyone can only be set to reader, writer or revoked");
continue;
}
const isFirstSelfAppointment =
!memberState[transactor] &&
transactor === initialAdmin &&
change.op === "set" &&
change.key === transactor &&
change.value === "admin";
if (!isFirstSelfAppointment) {
if (memberState[transactor] === "admin") {
if (
memberState[affectedMember] === "admin" &&
affectedMember !== transactor &&
assignedRole !== "admin"
) {
console.warn("Admins can only demote themselves.");
continue;
}
} else if (memberState[transactor] === "adminInvite") {
if (change.value !== "admin") {
console.warn("AdminInvites can only create admins.");
continue;
}
} else if (memberState[transactor] === "writerInvite") {
if (change.value !== "writer") {
console.warn("WriterInvites can only create writers.");
continue;
}
} else if (memberState[transactor] === "readerInvite") {
if (change.value !== "reader") {
console.warn("ReaderInvites can only create reader.");
continue;
}
} else {
console.warn(
"Group transaction must be made by current admin or invite",
);
continue;
}
}
memberState[affectedMember] = change.value;
validTransactions.push({ txID: { sessionID, txIndex }, tx });
// console.log("after", { memberState, validTransactions });
}
return { validTransactions, memberState };
}
function agentInAccountOrMemberInGroup(
transactor: RawAccountID | AgentID,
groupAtTime: RawGroup,
): RawAccountID | AgentID | undefined {
if (transactor === groupAtTime.id && groupAtTime instanceof RawAccount) {
return groupAtTime.currentAgentID().match(
(agentID) => agentID,
(e) => {
console.error(
"Error while determining current agent ID in valid transactions",
e,
);
return undefined;
},
);
}
return transactor;
}
export function isKeyForKeyField(co: string): co is `${KeyID}_for_${KeyID}` {
return co.startsWith("key_") && co.includes("_for_key");
}
@@ -304,3 +389,11 @@ export function isKeyForAccountField(
co.includes("_for_everyone")
);
}
function isParentExtension(key: string): key is `parent_${CoID<RawGroup>}` {
return key.startsWith("parent_");
}
function isChildExtension(key: string): key is `child_${CoID<RawGroup>}` {
return key.startsWith("child_");
}

View File

@@ -146,7 +146,7 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
asDependencyOf || id,
);
} else if (!known?.header && coValue.header?.ruleset.type === "group") {
const dependedOnAccounts = new Set();
const dependedOnAccountsAndGroups = new Set();
for (const session of Object.values(coValue.sessionEntries)) {
for (const entry of session) {
for (const tx of entry.transactions) {
@@ -154,16 +154,24 @@ export class LSMStorage<WH, RH, FS extends FileSystem<WH, RH>> {
const parsedChanges = JSON.parse(tx.changes);
for (const change of parsedChanges) {
if (change.op === "set" && change.key.startsWith("co_")) {
dependedOnAccounts.add(change.key);
dependedOnAccountsAndGroups.add(change.key);
}
if (
change.op === "set" &&
change.key.startsWith("parent_co_")
) {
dependedOnAccountsAndGroups.add(
change.key.replace("parent_", ""),
);
}
}
}
}
}
}
for (const account of dependedOnAccounts) {
for (const accountOrGroup of dependedOnAccountsAndGroups) {
await this.sendNewContent(
account as CoID<RawCoValue>,
accountOrGroup as CoID<RawCoValue>,
undefined,
asDependencyOf || id,
);

View File

@@ -5,7 +5,13 @@ import { RawCoStream } from "../coValues/coStream.js";
import { RawBinaryCoStream } from "../coValues/coStream.js";
import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { LocalNode } from "../localNode.js";
import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
import {
createThreeConnectedNodes,
createTwoConnectedNodes,
loadCoValueOrFail,
randomAnonymousAccountAndSessionID,
waitFor,
} from "./testUtils.js";
const Crypto = await WasmCrypto.create();
@@ -53,3 +59,148 @@ test("Can create a FileStream in a group", () => {
expect(stream.headerMeta.type).toEqual("binary");
expect(stream instanceof RawBinaryCoStream).toEqual(true);
});
test("Remove a member from a group where the admin role is inherited", async () => {
const { node1, node2, node3, node1ToNode2Peer, node2ToNode3Peer } =
createThreeConnectedNodes("server", "server", "server");
const group = node1.createGroup();
group.addMember(node2.account, "admin");
group.addMember(node3.account, "reader");
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
// The account of node2 create a child group and extend the initial group
// This way the node1 account should become "admin" of the child group
// by inheriting the admin role from the initial group
const childGroup = node2.createGroup();
childGroup.extend(groupOnNode2);
const map = childGroup.createMap();
map.set("test", "Available to everyone");
const mapOnNode3 = await loadCoValueOrFail(node3, map.id);
// Check that the sync between node2 and node3 worked
expect(mapOnNode3.get("test")).toEqual("Available to everyone");
// The node1 account removes the reader from the group
// The reader should be automatically kicked out of the child group
await group.removeMember(node3.account);
await node1.syncManager.waitForUploadIntoPeer(node1ToNode2Peer.id, group.id);
// Update the map to check that node3 can't read updates anymore
map.set("test", "Hidden to node3");
await node2.syncManager.waitForUploadIntoPeer(node2ToNode3Peer.id, map.id);
// Check that the value has not been updated on node3
expect(mapOnNode3.get("test")).toEqual("Available to everyone");
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
expect(mapOnNode1.get("test")).toEqual("Hidden to node3");
});
test("An admin should be able to rotate the readKey on child groups and keep access to new coValues", async () => {
const { node1, node2, node3, node1ToNode2Peer, node2ToNode1Peer } =
createThreeConnectedNodes("server", "server", "server");
const group = node1.createGroup();
group.addMember(node2.account, "admin");
group.addMember(node3.account, "reader");
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
// The account of node2 create a child group and extend the initial group
// This way the node1 account should become "admin" of the child group
// by inheriting the admin role from the initial group
const childGroup = node2.createGroup();
childGroup.extend(groupOnNode2);
await node2.syncManager.waitForUploadIntoPeer(
node2ToNode1Peer.id,
childGroup.id,
);
// The node1 account removes the reader from the group
// In this case we want to ensure that node1 is still able to read new coValues
// Even if some childs are not available when the readKey is rotated
await group.removeMember(node3.account);
await node1.syncManager.waitForUploadIntoPeer(node1ToNode2Peer.id, group.id);
const map = childGroup.createMap();
map.set("test", "Available to node1");
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
expect(mapOnNode1.get("test")).toEqual("Available to node1");
});
test("An admin should be able to rotate the readKey on child groups even if it was unavailable when kicking out a member from a parent group", async () => {
const { node1, node2, node3, node1ToNode2Peer, node2ToNode1Peer } =
createThreeConnectedNodes("server", "server", "server");
const group = node1.createGroup();
group.addMember(node2.account, "admin");
group.addMember(node3.account, "reader");
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
// The account of node2 create a child group and extend the initial group
// This way the node1 account should become "admin" of the child group
// by inheriting the admin role from the initial group
const childGroup = node2.createGroup();
childGroup.extend(groupOnNode2);
// The node1 account removes the reader from the group
// In this case we want to ensure that node1 is still able to read new coValues
// Even if some childs are not available when the readKey is rotated
await group.removeMember(node3.account);
await node1.syncManager.waitForUploadIntoPeer(node1ToNode2Peer.id, group.id);
const map = childGroup.createMap();
map.set("test", "Available to node1");
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
expect(mapOnNode1.get("test")).toEqual("Available to node1");
});
test("An admin should be able to rotate the readKey on child groups even if it was unavailable when kicking out a member from a parent group (grandChild)", async () => {
const { node1, node2, node3, node1ToNode2Peer } = createThreeConnectedNodes(
"server",
"server",
"server",
);
const group = node1.createGroup();
group.addMember(node2.account, "admin");
group.addMember(node3.account, "reader");
const groupOnNode2 = await loadCoValueOrFail(node2, group.id);
// The account of node2 create a child group and extend the initial group
// This way the node1 account should become "admin" of the child group
// by inheriting the admin role from the initial group
const childGroup = node2.createGroup();
childGroup.extend(groupOnNode2);
const grandChildGroup = node2.createGroup();
grandChildGroup.extend(childGroup);
// The node1 account removes the reader from the group
// In this case we want to ensure that node1 is still able to read new coValues
// Even if some childs are not available when the readKey is rotated
await group.removeMember(node3.account);
await node1.syncManager.waitForUploadIntoPeer(node1ToNode2Peer.id, group.id);
const map = childGroup.createMap();
map.set("test", "Available to node1");
const mapOnNode1 = await loadCoValueOrFail(node1, map.id);
expect(mapOnNode1.get("test")).toEqual("Available to node1");
});

View File

@@ -4,6 +4,7 @@ import { ControlledAgent } from "../coValues/account.js";
import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { expectGroup } from "../typeUtils/expectGroup.js";
import {
createTwoConnectedNodes,
groupWithTwoAdmins,
groupWithTwoAdminsHighLevel,
newGroup,
@@ -1033,7 +1034,7 @@ test("Admins can set group read rey, make a private transaction in an owned obje
).toEqual("bar2");
});
test("Admins can set group read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions (high level)", () => {
test("Admins can set group read rey, make a private transaction in an owned object, rotate the read key, add two readers, rotate the read key again to kick out one reader, make another private transaction in the owned object, and only the remaining reader can read both transactions (high level)", async () => {
const { node, group } = newGroupHighLevel();
const childObject = group.createMap();
@@ -1057,7 +1058,7 @@ test("Admins can set group read rey, make a private transaction in an owned obje
childObject.set("foo2", "bar2", "private");
expect(childObject.get("foo2")).toEqual("bar2");
group.removeMember(reader);
await group.removeMember(reader);
expect(childObject.core.getCurrentReadKey()).not.toEqual(secondReadKey);
@@ -1708,3 +1709,785 @@ test("Can give write permissions to 'everyone' (high-level)", async () => {
childContent2.set("foo", "bar2", "private");
expect(childContent2.get("foo")).toEqual("bar2");
});
test("Admins can set parent extensions", () => {
const { group, node } = newGroupHighLevel();
const parentGroup = node.createGroup();
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
expect(group.get(`parent_${parentGroup.id}`)).toEqual("extend");
});
test("Writers, readers and invitees can not set parent extensions", () => {
const { group, node } = newGroupHighLevel();
const parentGroup = node.createGroup();
const writer = node.createAccount();
const reader = node.createAccount();
const adminInvite = node.createAccount();
const writerInvite = node.createAccount();
const readerInvite = node.createAccount();
group.addMember(writer, "writer");
group.addMember(reader, "reader");
group.addMember(adminInvite, "adminInvite");
group.addMember(writerInvite, "writerInvite");
group.addMember(readerInvite, "readerInvite");
const groupAsWriter = expectGroup(
group.core
.testWithDifferentAccount(writer, Crypto.newRandomSessionID(writer.id))
.getCurrentContent(),
);
groupAsWriter.set(`parent_${parentGroup.id}`, "extend", "trusting");
expect(groupAsWriter.get(`parent_${parentGroup.id}`)).toBeUndefined();
const groupAsReader = expectGroup(
group.core
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
.getCurrentContent(),
);
groupAsReader.set(`parent_${parentGroup.id}`, "extend", "trusting");
expect(groupAsReader.get(`parent_${parentGroup.id}`)).toBeUndefined();
const groupAsAdminInvite = expectGroup(
group.core
.testWithDifferentAccount(
adminInvite,
Crypto.newRandomSessionID(adminInvite.currentAgentID()._unsafeUnwrap()),
)
.getCurrentContent(),
);
groupAsAdminInvite.set(`parent_${parentGroup.id}`, "extend", "trusting");
expect(groupAsAdminInvite.get(`parent_${parentGroup.id}`)).toBeUndefined();
const groupAsWriterInvite = expectGroup(
group.core
.testWithDifferentAccount(
writerInvite,
Crypto.newRandomSessionID(
writerInvite.currentAgentID()._unsafeUnwrap(),
),
)
.getCurrentContent(),
);
groupAsWriterInvite.set(`parent_${parentGroup.id}`, "extend", "trusting");
expect(groupAsWriterInvite.get(`parent_${parentGroup.id}`)).toBeUndefined();
const groupAsReaderInvite = expectGroup(
group.core
.testWithDifferentAccount(
readerInvite,
Crypto.newRandomSessionID(
readerInvite.currentAgentID()._unsafeUnwrap(),
),
)
.getCurrentContent(),
);
groupAsReaderInvite.set(`parent_${parentGroup.id}`, "extend", "trusting");
expect(groupAsReaderInvite.get(`parent_${parentGroup.id}`)).toBeUndefined();
});
test("Admins can set child extensions", () => {
const { group, node } = newGroupHighLevel();
const childGroup = node.createGroup();
group.set(`child_${childGroup.id}`, "extend", "trusting");
expect(group.get(`child_${childGroup.id}`)).toEqual("extend");
});
test("Admins can set child extensions when the admin role is inherited", async () => {
const { node1, node2 } = createTwoConnectedNodes("server", "server");
const node2Account = node2.account;
const group = node1.createGroup();
group.addMember(node2Account, "admin");
const groupOnNode2 = await node2.load(group.id);
if (groupOnNode2 === "unavailable") {
throw new Error("Group not found on node2");
}
const childGroup = node2.createGroup();
childGroup.extend(groupOnNode2);
const childGroupOnNode1 = await node1.load(childGroup.id);
if (childGroupOnNode1 === "unavailable") {
throw new Error("Child group not found on node1");
}
const grandChildGroup = node2.createGroup();
grandChildGroup.extend(childGroupOnNode1);
expect(childGroupOnNode1.get(`child_${grandChildGroup.id}`)).toEqual(
"extend",
);
expect(grandChildGroup.get(`parent_${childGroupOnNode1.id}`)).toEqual(
"extend",
);
});
test("Writers, readers and invitees can not set child extensions", () => {
const { group, node } = newGroupHighLevel();
const childGroup = node.createGroup();
const writer = node.createAccount();
const reader = node.createAccount();
const adminInvite = node.createAccount();
const writerInvite = node.createAccount();
const readerInvite = node.createAccount();
group.addMember(writer, "writer");
group.addMember(reader, "reader");
group.addMember(adminInvite, "adminInvite");
group.addMember(writerInvite, "writerInvite");
group.addMember(readerInvite, "readerInvite");
const groupAsWriter = expectGroup(
group.core
.testWithDifferentAccount(writer, Crypto.newRandomSessionID(writer.id))
.getCurrentContent(),
);
groupAsWriter.set(`child_${childGroup.id}`, "extend", "trusting");
expect(groupAsWriter.get(`child_${childGroup.id}`)).toBeUndefined();
const groupAsReader = expectGroup(
group.core
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
.getCurrentContent(),
);
groupAsReader.set(`child_${childGroup.id}`, "extend", "trusting");
expect(groupAsReader.get(`child_${childGroup.id}`)).toBeUndefined();
const groupAsAdminInvite = expectGroup(
group.core
.testWithDifferentAccount(
adminInvite,
Crypto.newRandomSessionID(adminInvite.currentAgentID()._unsafeUnwrap()),
)
.getCurrentContent(),
);
groupAsAdminInvite.set(`child_${childGroup.id}`, "extend", "trusting");
expect(groupAsAdminInvite.get(`child_${childGroup.id}`)).toBeUndefined();
const groupAsWriterInvite = expectGroup(
group.core
.testWithDifferentAccount(
writerInvite,
Crypto.newRandomSessionID(
writerInvite.currentAgentID()._unsafeUnwrap(),
),
)
.getCurrentContent(),
);
groupAsWriterInvite.set(`child_${childGroup.id}`, "extend", "trusting");
expect(groupAsWriterInvite.get(`child_${childGroup.id}`)).toBeUndefined();
const groupAsReaderInvite = expectGroup(
group.core
.testWithDifferentAccount(
readerInvite,
Crypto.newRandomSessionID(
readerInvite.currentAgentID()._unsafeUnwrap(),
),
)
.getCurrentContent(),
);
groupAsReaderInvite.set(`child_${childGroup.id}`, "extend", "trusting");
expect(groupAsReaderInvite.get(`child_${childGroup.id}`)).toBeUndefined();
});
test("Member roles are inherited by child groups (except invites)", () => {
const { group, node, admin } = newGroupHighLevel();
const parentGroup = node.createGroup();
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
const writer = node.createAccount();
const reader = node.createAccount();
const adminInvite = node.createAccount();
const writerInvite = node.createAccount();
const readerInvite = node.createAccount();
parentGroup.addMember(writer, "writer");
parentGroup.addMember(reader, "reader");
parentGroup.addMember(adminInvite, "adminInvite");
parentGroup.addMember(writerInvite, "writerInvite");
parentGroup.addMember(readerInvite, "readerInvite");
expect(group.roleOfInternal(admin.id)).toEqual({
role: "admin",
via: undefined,
});
expect(group.roleOfInternal(writer.id)).toEqual({
role: "writer",
via: parentGroup.id,
});
expect(group.roleOf(writer.id)).toEqual("writer");
expect(group.roleOfInternal(reader.id)).toEqual({
role: "reader",
via: parentGroup.id,
});
expect(group.roleOf(reader.id)).toEqual("reader");
expect(group.roleOfInternal(adminInvite.id)).toEqual(undefined);
expect(group.roleOf(adminInvite.id)).toEqual(undefined);
expect(group.roleOfInternal(writerInvite.id)).toEqual(undefined);
expect(group.roleOf(writerInvite.id)).toEqual(undefined);
expect(group.roleOfInternal(readerInvite.id)).toEqual(undefined);
expect(group.roleOf(readerInvite.id)).toEqual(undefined);
});
test("Member roles are inherited by grand-children groups (except invites)", () => {
const { group, node, admin } = newGroupHighLevel();
const parentGroup = node.createGroup();
const grandParentGroup = node.createGroup();
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
parentGroup.set(`parent_${grandParentGroup.id}`, "extend", "trusting");
const writer = node.createAccount();
const reader = node.createAccount();
const adminInvite = node.createAccount();
const writerInvite = node.createAccount();
const readerInvite = node.createAccount();
grandParentGroup.addMember(writer, "writer");
grandParentGroup.addMember(reader, "reader");
grandParentGroup.addMember(adminInvite, "adminInvite");
grandParentGroup.addMember(writerInvite, "writerInvite");
grandParentGroup.addMember(readerInvite, "readerInvite");
expect(group.roleOfInternal(admin.id)).toEqual({
role: "admin",
via: undefined,
});
expect(group.roleOfInternal(writer.id)).toEqual({
role: "writer",
via: parentGroup.id,
});
expect(group.roleOf(writer.id)).toEqual("writer");
expect(group.roleOfInternal(reader.id)).toEqual({
role: "reader",
via: parentGroup.id,
});
expect(group.roleOf(reader.id)).toEqual("reader");
expect(group.roleOfInternal(adminInvite.id)).toEqual(undefined);
expect(group.roleOf(adminInvite.id)).toEqual(undefined);
expect(group.roleOfInternal(writerInvite.id)).toEqual(undefined);
expect(group.roleOf(writerInvite.id)).toEqual(undefined);
expect(group.roleOfInternal(readerInvite.id)).toEqual(undefined);
expect(group.roleOf(readerInvite.id)).toEqual(undefined);
});
test("Admins can reveal parent read keys to child groups", () => {
const { group, node } = newGroupHighLevel();
const parentGroup = node.createGroup();
const parentReadKeyID = parentGroup.get("readKey");
if (!parentReadKeyID) {
throw new Error("Can't get parent group read key");
}
const readKeyID = group.get("readKey");
if (!readKeyID) {
throw new Error("Can't get group read key");
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const encrypted = "fake_encrypted_key_secret" as any;
group.set(`${readKeyID}_for_${parentReadKeyID}`, encrypted, "trusting");
expect(group.get(`${readKeyID}_for_${parentReadKeyID}`)).toEqual(encrypted);
});
test("Writers, readers and invites can't reveal parent read keys to child groups", () => {
const { group, node } = newGroupHighLevel();
const parentGroup = node.createGroup();
const parentReadKeyID = parentGroup.get("readKey");
if (!parentReadKeyID) {
throw new Error("Can't get parent group read key");
}
const readKeyID = group.get("readKey");
if (!readKeyID) {
throw new Error("Can't get group read key");
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const encrypted = "fake_encrypted_key_secret" as any;
const writer = node.createAccount();
const reader = node.createAccount();
const adminInvite = node.createAccount();
const writerInvite = node.createAccount();
const readerInvite = node.createAccount();
group.addMember(writer, "writer");
group.addMember(reader, "reader");
group.addMember(adminInvite, "adminInvite");
group.addMember(writerInvite, "writerInvite");
group.addMember(readerInvite, "readerInvite");
const groupAsWriter = expectGroup(
group.core
.testWithDifferentAccount(writer, Crypto.newRandomSessionID(writer.id))
.getCurrentContent(),
);
groupAsWriter.set(
`${readKeyID}_for_${parentReadKeyID}`,
encrypted,
"trusting",
);
expect(
groupAsWriter.get(`${readKeyID}_for_${parentReadKeyID}`),
).toBeUndefined();
const groupAsReader = expectGroup(
group.core
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
.getCurrentContent(),
);
groupAsReader.set(
`${readKeyID}_for_${parentReadKeyID}`,
encrypted,
"trusting",
);
expect(
groupAsReader.get(`${readKeyID}_for_${parentReadKeyID}`),
).toBeUndefined();
const groupAsAdminInvite = expectGroup(
group.core
.testWithDifferentAccount(
adminInvite,
Crypto.newRandomSessionID(adminInvite.currentAgentID()._unsafeUnwrap()),
)
.getCurrentContent(),
);
groupAsAdminInvite.set(
`${readKeyID}_for_${parentReadKeyID}`,
encrypted,
"trusting",
);
expect(
groupAsAdminInvite.get(`${readKeyID}_for_${parentReadKeyID}`),
).toBeUndefined();
const groupAsWriterInvite = expectGroup(
group.core
.testWithDifferentAccount(
writerInvite,
Crypto.newRandomSessionID(
writerInvite.currentAgentID()._unsafeUnwrap(),
),
)
.getCurrentContent(),
);
groupAsWriterInvite.set(
`${readKeyID}_for_${parentReadKeyID}`,
encrypted,
"trusting",
);
expect(
groupAsWriterInvite.get(`${readKeyID}_for_${parentReadKeyID}`),
).toBeUndefined();
const groupAsReaderInvite = expectGroup(
group.core
.testWithDifferentAccount(
readerInvite,
Crypto.newRandomSessionID(
readerInvite.currentAgentID()._unsafeUnwrap(),
),
)
.getCurrentContent(),
);
groupAsReaderInvite.set(
`${readKeyID}_for_${parentReadKeyID}`,
encrypted,
"trusting",
);
expect(
groupAsReaderInvite.get(`${readKeyID}_for_${parentReadKeyID}`),
).toBeUndefined();
});
test("Writers and readers in a parent group can read from an object owned by a child group", () => {
const { group, node } = newGroupHighLevel();
const parentGroup = node.createGroup();
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
const parentReadKeyID = parentGroup.get("readKey");
const parentKey =
parentReadKeyID && parentGroup.core.getReadKey(parentReadKeyID);
if (!parentReadKeyID || !parentKey) {
throw new Error("Can't get parent group read key");
}
const readKeyID = group.get("readKey");
const readKey = readKeyID && group.core.getReadKey(readKeyID);
if (!readKeyID || !readKey) {
throw new Error("Can't get group read key");
}
const encrypted = node.crypto.encryptKeySecret({
toEncrypt: {
id: readKeyID,
secret: readKey,
},
encrypting: {
id: parentReadKeyID,
secret: parentKey,
},
}).encrypted;
group.set(`${readKeyID}_for_${parentReadKeyID}`, encrypted, "trusting");
const writer = node.createAccount();
const reader = node.createAccount();
parentGroup.addMember(writer, "writer");
parentGroup.addMember(reader, "reader");
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByGroup", group: group.id },
meta: null,
...Crypto.createdNowUnique(),
});
const childContent = expectMap(childObject.getCurrentContent());
childContent.set("foo", "bar", "private");
expect(childContent.get("foo")).toEqual("bar");
const childContentAsWriter = expectMap(
childObject
.testWithDifferentAccount(writer, Crypto.newRandomSessionID(writer.id))
.getCurrentContent(),
);
expect(childContentAsWriter.get("foo")).toEqual("bar");
const childContentAsReader = expectMap(
childObject
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
.getCurrentContent(),
);
expect(childContentAsReader.get("foo")).toEqual("bar");
});
test("Writers in a parent group can write to an object owned by a child group", () => {
const { group, node } = newGroupHighLevel();
const parentGroup = node.createGroup();
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
const parentReadKeyID = parentGroup.get("readKey");
const parentKey =
parentReadKeyID && parentGroup.core.getReadKey(parentReadKeyID);
if (!parentReadKeyID || !parentKey) {
throw new Error("Can't get parent group read key");
}
const readKeyID = group.get("readKey");
const readKey = readKeyID && group.core.getReadKey(readKeyID);
if (!readKeyID || !readKey) {
throw new Error("Can't get group read key");
}
const encrypted = node.crypto.encryptKeySecret({
toEncrypt: {
id: readKeyID,
secret: readKey,
},
encrypting: {
id: parentReadKeyID,
secret: parentKey,
},
}).encrypted;
group.set(`${readKeyID}_for_${parentReadKeyID}`, encrypted, "trusting");
const writer = node.createAccount();
parentGroup.addMember(writer, "writer");
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByGroup", group: group.id },
meta: null,
...Crypto.createdNowUnique(),
});
const childContentAsWriter = expectMap(
childObject
.testWithDifferentAccount(writer, Crypto.newRandomSessionID(writer.id))
.getCurrentContent(),
);
childContentAsWriter.set("foo", "bar", "private");
expect(childContentAsWriter.get("foo")).toEqual("bar");
});
test("When rotating the key of a child group, the new child key is exposed to the parent group", () => {
const { group, node } = newGroupHighLevel();
const parentGroup = node.createGroup();
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
const currentReadKeyID = group.get("readKey");
if (!currentReadKeyID) {
throw new Error("Can't get group read key");
}
group.rotateReadKey();
const newReadKeyID = group.get("readKey");
if (!newReadKeyID) {
throw new Error("Can't get new group read key");
}
expect(newReadKeyID).not.toEqual(currentReadKeyID);
const parentReadKeyID = parentGroup.get("readKey");
if (!parentReadKeyID) {
throw new Error("Can't get parent group read key");
}
console.log("Checking", `${newReadKeyID}_for_${parentReadKeyID}`);
expect(group.get(`${newReadKeyID}_for_${parentReadKeyID}`)).toBeDefined();
});
test("When rotating the key of a parent group, the keys of all child groups are also rotated", () => {
const { group, node } = newGroupHighLevel();
const parentGroup = node.createGroup();
parentGroup.set(`child_${group.id}`, "extend", "trusting");
group.set(`parent_${parentGroup.id}`, "extend", "trusting");
group.rotateReadKey();
const currentChildReadKeyID = group.get("readKey");
if (!currentChildReadKeyID) {
throw new Error("Can't get group read key");
}
console.log("child id", group.id);
parentGroup.rotateReadKey();
const newChildReadKeyID = expectGroup(group.core.getCurrentContent()).get(
"readKey",
);
if (!newChildReadKeyID) {
throw new Error("Can't get new group read key");
}
expect(newChildReadKeyID).not.toEqual(currentChildReadKeyID);
});
test("When rotating the key of a grand-parent group, the keys of all child and grand-child groups are also rotated", () => {
const { group, node } = newGroupHighLevel();
const grandParentGroup = node.createGroup();
const parentGroup = node.createGroup();
grandParentGroup.set(`child_${parentGroup.id}`, "extend", "trusting");
parentGroup.set(`child_${group.id}`, "extend", "trusting");
parentGroup.set(`parent_${grandParentGroup.id}`, "extend", "trusting");
group.set(`parent_${grandParentGroup.id}`, "extend", "trusting");
const currentGrandParentReadKeyID = grandParentGroup.get("readKey");
if (!currentGrandParentReadKeyID) {
throw new Error("Can't get grand-parent group read key");
}
const currentParentReadKeyID = parentGroup.get("readKey");
if (!currentParentReadKeyID) {
throw new Error("Can't get parent group read key");
}
const currentChildReadKeyID = group.get("readKey");
if (!currentChildReadKeyID) {
throw new Error("Can't get group read key");
}
grandParentGroup.rotateReadKey();
const newGrandParentReadKeyID = grandParentGroup.get("readKey");
if (!newGrandParentReadKeyID) {
throw new Error("Can't get new grand-parent group read key");
}
expect(newGrandParentReadKeyID).not.toEqual(currentGrandParentReadKeyID);
const newParentReadKeyID = expectGroup(
parentGroup.core.getCurrentContent(),
).get("readKey");
if (!newParentReadKeyID) {
throw new Error("Can't get new parent group read key");
}
expect(newParentReadKeyID).not.toEqual(currentParentReadKeyID);
const newChildReadKeyID = expectGroup(group.core.getCurrentContent()).get(
"readKey",
);
if (!newChildReadKeyID) {
throw new Error("Can't get new group read key");
}
expect(newChildReadKeyID).not.toEqual(currentChildReadKeyID);
});
test("Calling extend on group sets up parent and child references and reveals child key to parent", () => {
const { group, node } = newGroupHighLevel();
const parentGroup = node.createGroup();
group.extend(parentGroup);
expect(group.get(`parent_${parentGroup.id}`)).toEqual("extend");
expect(parentGroup.get(`child_${group.id}`)).toEqual("extend");
const parentReadKeyID = parentGroup.get("readKey");
if (!parentReadKeyID) {
throw new Error("Can't get parent group read key");
}
const childReadKeyID = group.get("readKey");
if (!childReadKeyID) {
throw new Error("Can't get group read key");
}
expect(group.get(`${childReadKeyID}_for_${parentReadKeyID}`)).toBeDefined();
const reader = node.createAccount();
parentGroup.addMember(reader, "reader");
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByGroup", group: group.id },
meta: null,
...Crypto.createdNowUnique(),
});
const childMap = expectMap(childObject.getCurrentContent());
childMap.set("foo", "bar", "private");
const childContentAsReader = expectMap(
childObject
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
.getCurrentContent(),
);
expect(childContentAsReader.get("foo")).toEqual("bar");
});
test("Calling extend to create grand-child groups parent and child references and reveals child key to parent(s)", () => {
const { group, node } = newGroupHighLevel();
const parentGroup = node.createGroup();
const grandParentGroup = node.createGroup();
group.extend(parentGroup);
parentGroup.extend(grandParentGroup);
expect(group.get(`parent_${parentGroup.id}`)).toEqual("extend");
expect(parentGroup.get(`parent_${grandParentGroup.id}`)).toEqual("extend");
expect(parentGroup.get(`child_${group.id}`)).toEqual("extend");
expect(grandParentGroup.get(`child_${parentGroup.id}`)).toEqual("extend");
const reader = node.createAccount();
grandParentGroup.addMember(reader, "reader");
const childObject = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByGroup", group: group.id },
meta: null,
...Crypto.createdNowUnique(),
});
const childMap = expectMap(childObject.getCurrentContent());
childMap.set("foo", "bar", "private");
const childContentAsReader = expectMap(
childObject
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
.getCurrentContent(),
);
expect(childContentAsReader.get("foo")).toEqual("bar");
});
test("High-level permissions work correctly when a group is extended", async () => {
const { group, node } = newGroupHighLevel();
const parentGroup = node.createGroup();
group.extend(parentGroup);
const reader = node.createAccount();
parentGroup.addMember(reader, "reader");
const mapCore = node.createCoValue({
type: "comap",
ruleset: { type: "ownedByGroup", group: group.id },
meta: null,
...Crypto.createdNowUnique(),
});
const map = expectMap(mapCore.getCurrentContent());
map.set("foo", "bar", "private");
const mapAsReader = expectMap(
mapCore
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
.getCurrentContent(),
);
expect(mapAsReader.get("foo")).toEqual("bar");
const groupKeyBeforeRemove = group.core.getCurrentReadKey().id;
await parentGroup.removeMember(reader);
const groupKeyAfterRemove = group.core.getCurrentReadKey().id;
expect(groupKeyAfterRemove).not.toEqual(groupKeyBeforeRemove);
map.set("foo", "baz", "private");
const mapAsReaderAfterRemove = expectMap(
mapCore
.testWithDifferentAccount(reader, Crypto.newRandomSessionID(reader.id))
.getCurrentContent(),
);
expect(mapAsReaderAfterRemove.get("foo")).not.toEqual("baz");
});

View File

@@ -1,8 +1,11 @@
import { expect } from "vitest";
import { ControlledAgent } from "../coValues/account.js";
import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { CoID, RawCoValue } from "../exports.js";
import { SessionID } from "../ids.js";
import { LocalNode } from "../localNode.js";
import { connectedPeers } from "../streamUtils.js";
import { Peer } from "../sync.js";
import { expectGroup } from "../typeUtils/expectGroup.js";
const Crypto = await WasmCrypto.create();
@@ -23,6 +26,93 @@ export function createTestNode() {
return new LocalNode(admin, session, Crypto);
}
export function createTwoConnectedNodes(
node1Role: Peer["role"],
node2Role: Peer["role"],
) {
// Setup nodes
const node1 = createTestNode();
const node2 = createTestNode();
// Connect nodes initially
const [node1ToNode2Peer, node2ToNode1Peer] = connectedPeers(
"node1ToNode2",
"node2ToNode1",
{
peer1role: node2Role,
peer2role: node1Role,
},
);
node1.syncManager.addPeer(node1ToNode2Peer);
node2.syncManager.addPeer(node2ToNode1Peer);
return {
node1,
node2,
node1ToNode2Peer,
node2ToNode1Peer,
};
}
export function createThreeConnectedNodes(
node1Role: Peer["role"],
node2Role: Peer["role"],
node3Role: Peer["role"],
) {
// Setup nodes
const node1 = createTestNode();
const node2 = createTestNode();
const node3 = createTestNode();
// Connect nodes initially
const [node1ToNode2Peer, node2ToNode1Peer] = connectedPeers(
"node1ToNode2",
"node2ToNode1",
{
peer1role: node2Role,
peer2role: node1Role,
},
);
const [node1ToNode3Peer, node3ToNode1Peer] = connectedPeers(
"node1ToNode3",
"node3ToNode1",
{
peer1role: node3Role,
peer2role: node1Role,
},
);
const [node2ToNode3Peer, node3ToNode2Peer] = connectedPeers(
"node2ToNode3",
"node3ToNode2",
{
peer1role: node3Role,
peer2role: node2Role,
},
);
node1.syncManager.addPeer(node1ToNode2Peer);
node1.syncManager.addPeer(node1ToNode3Peer);
node2.syncManager.addPeer(node2ToNode1Peer);
node2.syncManager.addPeer(node2ToNode3Peer);
node3.syncManager.addPeer(node3ToNode1Peer);
node3.syncManager.addPeer(node3ToNode2Peer);
return {
node1,
node2,
node3,
node1ToNode2Peer,
node2ToNode1Peer,
node1ToNode3Peer,
node3ToNode1Peer,
node2ToNode3Peer,
node3ToNode2Peer,
};
}
export function newGroup() {
const [admin, sessionID] = randomAnonymousAccountAndSessionID();
@@ -58,7 +148,7 @@ export function groupWithTwoAdmins() {
}
expect(group.get(otherAdmin.id)).toEqual("admin");
return { groupCore, admin, otherAdmin, node };
return { group, groupCore, admin, otherAdmin, node };
}
export function newGroupHighLevel() {
@@ -126,3 +216,14 @@ export function waitFor(callback: () => boolean | void) {
}, 100);
});
}
export async function loadCoValueOrFail<V extends RawCoValue>(
node: LocalNode,
id: CoID<V>,
): Promise<V> {
const value = await node.load(id);
if (value === "unavailable") {
throw new Error("CoValue not found");
}
return value;
}

View File

@@ -13,7 +13,7 @@
"ws": "^8.14.2"
},
"devDependencies": {
"@types/ws": "^8.5.5",
"@types/ws": "8.5.10",
"typescript": "^5.3.3"
},
"scripts": {

View File

@@ -26,7 +26,7 @@
"ws": "^8.14.2"
},
"devDependencies": {
"@types/ws": "^8.5.5",
"@types/ws": "8.5.10",
"typescript": "^5.3.3"
}
}

View File

@@ -29,7 +29,7 @@
"svelte": "^5.0.0"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-vercel": "^5.5.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/package": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",

View File

@@ -1,4 +1,4 @@
import adapter from "@sveltejs/adapter-auto";
import adapter from "@sveltejs/adapter-vercel";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */

View File

@@ -142,6 +142,11 @@ export class Group extends CoValueBase implements CoValue {
return this;
}
removeMember(member: Everyone | Account) {
this._raw.removeMember(member === "everyone" ? member : member._raw);
return this;
}
get members() {
return this._raw
.keys()
@@ -172,6 +177,11 @@ export class Group extends CoValueBase implements CoValue {
});
}
extend(parent: Group) {
this._raw.extend(parent._raw);
return this;
}
/** @category Subscription & Loading */
static load<G extends Group, Depth>(
this: CoValueClass<G>,

View File

@@ -1,5 +1,7 @@
import { RawGroup } from "cojson";
import { describe, expect, test } from "vitest";
import { Account, CoMap, Group, WasmCrypto, co } from "../index.web.js";
import { waitFor } from "./utils.js";
const Crypto = await WasmCrypto.create();
@@ -85,3 +87,144 @@ describe("Custom accounts and groups", async () => {
expect(map._owner.castAs(CustomGroup).nMembers).toBe(2);
});
});
describe("Group inheritance", () => {
class TestMap extends CoMap {
title = co.string;
}
test("Group inheritance", async () => {
const me = await Account.create({
creationProps: { name: "Hermes Puggington" },
crypto: Crypto,
});
const parentGroup = Group.create({ owner: me });
const group = Group.create({ owner: me });
group.extend(parentGroup);
const reader = await Account.createAs(me, {
creationProps: { name: "Reader" },
});
parentGroup.addMember(reader, "reader");
const mapInChild = TestMap.create({ title: "In Child" }, { owner: group });
const mapAsReader = await TestMap.load(mapInChild.id, reader, {});
expect(mapAsReader?.title).toBe("In Child");
parentGroup.removeMember(reader);
mapInChild.title = "In Child (updated)";
const mapAsReaderAfterUpdate = await TestMap.load(
mapInChild.id,
reader,
{},
);
expect(mapAsReaderAfterUpdate?.title).toBe("In Child");
});
test("Group inheritance with grand-children", async () => {
const me = await Account.create({
creationProps: { name: "Hermes Puggington" },
crypto: Crypto,
});
const grandParentGroup = Group.create({ owner: me });
const parentGroup = Group.create({ owner: me });
const group = Group.create({ owner: me });
group.extend(parentGroup);
parentGroup.extend(grandParentGroup);
const reader = await Account.createAs(me, {
creationProps: { name: "Reader" },
});
grandParentGroup.addMember(reader, "reader");
const mapInGrandChild = TestMap.create(
{ title: "In Grand Child" },
{ owner: group },
);
const mapAsReader = await TestMap.load(mapInGrandChild.id, reader, {});
expect(mapAsReader?.title).toBe("In Grand Child");
grandParentGroup.removeMember(reader);
mapInGrandChild.title = "In Grand Child (updated)";
const mapAsReaderAfterUpdate = await TestMap.load(
mapInGrandChild.id,
reader,
{},
);
expect(mapAsReaderAfterUpdate?.title).toBe("In Grand Child");
});
test("Group inheritance should fail if the current account doesn't have admin role in both groups", async () => {
const me = await Account.create({
creationProps: { name: "Hermes Puggington" },
crypto: Crypto,
});
const other = await Account.createAs(me, {
creationProps: { name: "Another user" },
});
const parentGroup = Group.create({ owner: me });
parentGroup.addMember(other, "writer");
const group = Group.create({ owner: me });
group.addMember(other, "admin");
const parentGroupOnTheOtherSide = await Group.load(
parentGroup.id,
other,
{},
);
const groupOnTheOtherSide = await Group.load(group.id, other, {});
if (!groupOnTheOtherSide || !parentGroupOnTheOtherSide) {
throw new Error("CoValue not available");
}
expect(() => groupOnTheOtherSide.extend(parentGroupOnTheOtherSide)).toThrow(
"To extend a group, the current account must have admin role in both groups",
);
});
test("Group inheritance should work if the current account has admin role in both groups", async () => {
const me = await Account.create({
creationProps: { name: "Hermes Puggington" },
crypto: Crypto,
});
const other = await Account.createAs(me, {
creationProps: { name: "Another user" },
});
const parentGroup = Group.create({ owner: me });
parentGroup.addMember(other, "admin");
const group = Group.create({ owner: me });
group.addMember(other, "admin");
const parentGroupOnTheOtherSide = await Group.load(
parentGroup.id,
other,
{},
);
const groupOnTheOtherSide = await Group.load(group.id, other, {});
if (!groupOnTheOtherSide || !parentGroupOnTheOtherSide) {
throw new Error("CoValue not available");
}
expect(() =>
groupOnTheOtherSide.extend(parentGroupOnTheOtherSide),
).not.toThrow();
});
});

568
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { AuthAndJazz } from "./jazz";
import { FileStreamTest } from "./pages/FileStream";
import { ResumeSyncState } from "./pages/ResumeSyncState";
import { RetryUnavailable } from "./pages/RetryUnavailable";
import { Sharing } from "./pages/Sharing";
import { TestInput } from "./pages/TestInput";
function Index() {
@@ -22,6 +23,9 @@ function Index() {
<li>
<Link to="/retry-unavailable">Retry Unavailable</Link>
</li>
<li>
<Link to="/sharing">Sharing</Link>
</li>
</ul>
);
}
@@ -43,6 +47,10 @@ const router = createBrowserRouter([
path: "/file-stream",
element: <FileStreamTest />,
},
{
path: "/sharing",
element: <Sharing />,
},
{
path: "/",
element: <Index />,

View File

@@ -15,7 +15,7 @@ if (url.searchParams.has("local")) {
const Jazz = createJazzReactApp();
export const { useAccount, useCoState } = Jazz;
export const { useAccount, useCoState, useAcceptInvite } = Jazz;
function getUserInfo() {
return url.searchParams.get("userName") ?? "Mister X";

View File

@@ -0,0 +1,155 @@
import { createInviteLink } from "jazz-react";
import { CoMap, Group, ID, co } from "jazz-tools";
import { useState } from "react";
import { useAcceptInvite, useAccount, useCoState } from "../jazz";
class SharedCoMap extends CoMap {
value = co.string;
child = co.optional.ref(SharedCoMap);
}
export function Sharing() {
const { me } = useAccount();
const [id, setId] = useState<ID<SharedCoMap> | undefined>(undefined);
const [revealLevels, setRevealLevels] = useState(1);
const [inviteLinks, setInviteLinks] = useState<Record<string, string>>({});
const coMap = useCoState(SharedCoMap, id, {});
const createCoMap = () => {
if (!me || id) return;
const group = Group.create({ owner: me });
const coMap = SharedCoMap.create(
{
value: "CoValue root",
},
{ owner: group },
);
setInviteLinks({
writer: createInviteLink(coMap, "writer"),
reader: createInviteLink(coMap, "reader"),
admin: createInviteLink(coMap, "admin"),
});
setId(coMap.id);
};
const revokeAccess = () => {
if (!coMap) return;
const coMapGroup = coMap._owner as Group;
for (const member of coMapGroup.members) {
if (
member.account &&
member.role !== "admin" &&
member.account.id !== me.id
) {
coMapGroup.removeMember(member.account);
}
}
};
useAcceptInvite({
invitedObjectSchema: SharedCoMap,
onAccept: (id) => {
setId(id);
},
});
return (
<div>
<h1>Sharing</h1>
<p data-testid="id">{coMap?.id}</p>
{Object.entries(inviteLinks).map(([role, inviteLink]) => (
<div key={role} style={{ display: "flex", gap: 5 }}>
<p style={{ fontWeight: "bold" }}>{role} invitation:</p>
<p data-testid={`invite-link-${role}`}>{inviteLink}</p>
</div>
))}
<pre data-testid="values">
{coMap?.value && (
<SharedCoMapWithChildren
id={coMap.id}
level={0}
revealLevels={revealLevels}
/>
)}
</pre>
{!id && <button onClick={createCoMap}>Create the root</button>}
{coMap && <button onClick={revokeAccess}>Revoke access</button>}
<button onClick={() => setRevealLevels(revealLevels + 1)}>
Reveal next level
</button>
</div>
);
}
function SharedCoMapWithChildren(props: {
id: ID<SharedCoMap>;
level: number;
revealLevels: number;
}) {
const coMap = useCoState(SharedCoMap, props.id, {});
const { me } = useAccount();
const nextLevel = props.level + 1;
const addChild = () => {
if (!me || !coMap) return;
const group = Group.create({ owner: me });
const child = SharedCoMap.create(
{ value: "CoValue child " + nextLevel },
{ owner: group },
);
coMap.child = child;
};
const extendParentGroup = async () => {
if (!coMap || !coMap.child) return;
let node: SharedCoMap | undefined = coMap;
while (node?._refs.child?.id) {
const parentGroup = node._owner as Group;
node = await SharedCoMap.load(node._refs.child.id, me, {});
if (node) {
const childGroup = node._owner as Group;
childGroup.extend(parentGroup);
}
}
};
const shouldRenderChild = props.level < props.revealLevels;
if (!coMap?.value) return null;
return (
<>
{coMap.value}
{coMap._refs.child ? (
shouldRenderChild ? (
<>
{" ---> "}
<SharedCoMapWithChildren
id={coMap._refs.child.id}
level={nextLevel}
revealLevels={props.revealLevels}
/>
</>
) : (
" ---> Level hidden"
)
) : (
<button onClick={addChild}>Add a child</button>
)}
{props.level === 0 && (
<button onClick={extendParentGroup}>Share the children</button>
)}
</>
);
}

View File

@@ -0,0 +1,396 @@
import { setTimeout } from "node:timers/promises";
import { expect, test } from "@playwright/test";
test.describe("Sharing", () => {
test("should share simple coValues", async ({ page, browser }) => {
await page.goto("/sharing");
await page.getByRole("button", { name: "Create the root" }).click();
const id = await page.getByTestId("id").textContent();
const inviteLink = await page
.getByTestId("invite-link-reader")
.textContent();
// Create a new incognito instance and accept the invite
const newUserPage = await (await browser.newContext()).newPage();
await newUserPage.goto(inviteLink!);
await expect(newUserPage.getByTestId("id")).toHaveText(id ?? "", {
timeout: 20_000,
});
});
test("should reveal internal values on group extension", async ({
page,
browser,
}) => {
await page.goto("/sharing");
await page.getByRole("button", { name: "Create the root" }).click();
await page.getByRole("button", { name: "Add a child" }).click();
await expect(page.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1",
);
const id = await page.getByTestId("id").textContent();
const inviteLink = await page
.getByTestId("invite-link-reader")
.textContent();
// Create a new incognito instance and accept the invite
const newUserPage = await (await browser.newContext()).newPage();
await newUserPage.goto(inviteLink!);
await expect(newUserPage.getByTestId("id")).toHaveText(id ?? "", {
timeout: 20_000,
});
// The user should not have access to the internal values
// because they are part of a different group
await expect(newUserPage.getByTestId("values")).toContainText(
"CoValue root",
);
await expect(newUserPage.getByTestId("values")).not.toContainText(
"CoValue root ---> CoValue child 1",
);
// Extend the coMaps group with the coList group
await page.getByRole("button", { name: "Share the children" }).click();
// The user should now have access to the internal values
await expect(newUserPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1",
);
});
test("should reveal internal values if another user extends the group", async ({
page,
browser,
}) => {
await page.goto("/sharing");
await page.getByRole("button", { name: "Create the root" }).click();
const id = await page.getByTestId("id").textContent();
const inviteLink = await page
.getByTestId("invite-link-admin")
.textContent();
// Create a new incognito instance and accept the invite
const newUserPage = await (await browser.newContext()).newPage();
await newUserPage.goto(inviteLink!);
await expect(newUserPage.getByTestId("id")).toHaveText(id ?? "", {
timeout: 20_000,
});
await newUserPage.getByRole("button", { name: "Add a child" }).click();
await newUserPage.getByRole("button", { name: "Add a child" }).click();
await newUserPage
.getByRole("button", { name: "Reveal next level" })
.click();
await newUserPage
.getByRole("button", { name: "Share the children" })
.click();
await page.getByRole("button", { name: "Reveal next level" }).click();
// The user should now have access to the internal values
await expect(page.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2",
);
});
test("admin role is required to extend a group", async ({
page,
browser,
}) => {
await page.goto("/sharing");
await page.getByRole("button", { name: "Create the root" }).click();
const id = await page.getByTestId("id").textContent();
const inviteLink = await page
.getByTestId("invite-link-writer")
.textContent();
// Create a new incognito instance and accept the invite
const newUserPage = await (await browser.newContext()).newPage();
await newUserPage.goto(inviteLink!);
await expect(newUserPage.getByTestId("id")).toHaveText(id ?? "", {
timeout: 20_000,
});
await newUserPage.getByRole("button", { name: "Add a child" }).click();
await newUserPage
.getByRole("button", { name: "Share the children" })
.click();
// The group extension should fail
await expect(page.getByTestId("values")).toContainText("CoValue root");
await expect(page.getByTestId("values")).not.toContainText(
"CoValue root ---> CoValue child 1",
);
});
test("should not reveal new values after revoking access", async ({
page,
browser,
}) => {
await page.goto("/sharing");
await page.getByRole("button", { name: "Create the root" }).click();
await page.getByRole("button", { name: "Add a child" }).click();
await page.getByRole("button", { name: "Share the children" }).click();
const id = await page.getByTestId("id").textContent();
const inviteLink = await page
.getByTestId("invite-link-reader")
.textContent();
// Create a new incognito instance and accept the invite
const newUserPage = await (await browser.newContext()).newPage();
await newUserPage.goto(inviteLink!);
await expect(newUserPage.getByTestId("id")).toHaveText(id ?? "", {
timeout: 20_000,
});
await page.getByRole("button", { name: "Revoke access" }).click();
await page.getByRole("button", { name: "Add a child" }).click();
await page.getByRole("button", { name: "Reveal next level" }).click();
await page.getByRole("button", { name: "Share the children" }).click();
await newUserPage
.getByRole("button", { name: "Reveal next level" })
.click();
await expect(page.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2",
);
await expect(newUserPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1",
);
await expect(newUserPage.getByTestId("values")).not.toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2",
);
});
test("should load the missing childs when rotating keys", async ({
page,
browser,
}) => {
await page.goto("/sharing?userName=InitialOwner");
const initialOwnerPage = page;
const otherAdminPage = await (await browser.newContext()).newPage();
otherAdminPage.goto("/?userName=OtherAdmin");
const readerPage = await (await browser.newContext()).newPage();
readerPage.goto("/?userName=Reader");
await initialOwnerPage
.getByRole("button", { name: "Create the root" })
.click();
const adminInviteLink = await page
.getByTestId("invite-link-admin")
.textContent();
const readerInviteLink = await page
.getByTestId("invite-link-reader")
.textContent();
await otherAdminPage.goto(adminInviteLink!);
await readerPage.goto(readerInviteLink!);
await initialOwnerPage.getByRole("button", { name: "Add a child" }).click();
await initialOwnerPage
.getByRole("button", { name: "Share the children" })
.click();
await expect(initialOwnerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1",
);
await expect(otherAdminPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1",
);
await expect(readerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1",
);
await otherAdminPage.getByRole("button", { name: "Add a child" }).click();
await otherAdminPage
.getByRole("button", { name: "Reveal next level" })
.click();
await otherAdminPage
.getByRole("button", { name: "Share the children" })
.click();
await readerPage.getByRole("button", { name: "Reveal next level" }).click();
await expect(initialOwnerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> Level hidden",
);
await expect(otherAdminPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2",
);
await expect(readerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2",
);
// At this point, the initial owner should not know about the "CoValue child 2"
// group, and to make things work it should load it before rotating the keys
await initialOwnerPage
.getByRole("button", { name: "Revoke access" })
.click();
// We add a new child from the other admin by extending "CoValue child 2" group
// if the key has been rotated this new value should not be revealed to the reader
await otherAdminPage.getByRole("button", { name: "Add a child" }).click();
await otherAdminPage
.getByRole("button", { name: "Reveal next level" })
.click();
await otherAdminPage
.getByRole("button", { name: "Share the children" })
.click();
await readerPage.getByRole("button", { name: "Reveal next level" }).click();
await expect(readerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2",
);
// The new child should not be revealed to the reader because it has been kicked out
await expect(readerPage.getByTestId("values")).not.toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2 ---> CoValue child 3",
);
await expect(otherAdminPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2 ---> CoValue child 3",
);
await initialOwnerPage
.getByRole("button", { name: "Reveal next level" })
.click();
await initialOwnerPage
.getByRole("button", { name: "Reveal next level" })
.click();
// The new childs should be revealed to the initial owner
await expect(initialOwnerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2 ---> CoValue child 3",
);
});
test("should kick out access from child groups even if they are not available when rotating keys", async ({
page,
browser,
context,
}) => {
await page.goto("/sharing?userName=InitialOwner");
const initialOwnerPage = page;
const otherAdminPage = await (await browser.newContext()).newPage();
otherAdminPage.goto("/?userName=OtherAdmin");
const readerPage = await (await browser.newContext()).newPage();
readerPage.goto("/?userName=Reader");
await initialOwnerPage
.getByRole("button", { name: "Create the root" })
.click();
const adminInviteLink = await page
.getByTestId("invite-link-admin")
.textContent();
const readerInviteLink = await page
.getByTestId("invite-link-reader")
.textContent();
await otherAdminPage.goto(adminInviteLink!);
await readerPage.goto(readerInviteLink!);
await initialOwnerPage.getByRole("button", { name: "Add a child" }).click();
await initialOwnerPage
.getByRole("button", { name: "Share the children" })
.click();
await expect(initialOwnerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1",
);
await expect(otherAdminPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1",
);
await expect(readerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1",
);
await context.setOffline(true);
await otherAdminPage.getByRole("button", { name: "Add a child" }).click();
await otherAdminPage
.getByRole("button", { name: "Reveal next level" })
.click();
await otherAdminPage
.getByRole("button", { name: "Share the children" })
.click();
await readerPage.getByRole("button", { name: "Reveal next level" }).click();
await expect(otherAdminPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2",
);
await expect(readerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2",
);
// At this point, the initial owner should not know about the "CoValue child 2"
// group, and to make things work it should load it before rotating the keys
await initialOwnerPage
.getByRole("button", { name: "Revoke access" })
.click();
await initialOwnerPage.waitForTimeout(1000);
await context.setOffline(false);
await initialOwnerPage
.getByRole("button", { name: "Reveal next level" })
.click();
await expect(initialOwnerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2",
);
await initialOwnerPage
.getByRole("button", { name: "Reveal next level" })
.click();
// We add a new child from the other admin by extending "CoValue child 2" group
// if the key has been rotated this new value should not be revealed to the reader
await initialOwnerPage.getByRole("button", { name: "Add a child" }).click();
await initialOwnerPage
.getByRole("button", { name: "Share the children" })
.click();
await readerPage.getByRole("button", { name: "Reveal next level" }).click();
await expect(readerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2",
);
// The new child should not be revealed to the reader because it has been kicked out
await expect(readerPage.getByTestId("values")).not.toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2 ---> CoValue child 3",
);
await otherAdminPage
.getByRole("button", { name: "Reveal next level" })
.click();
await expect(otherAdminPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2 ---> CoValue child 3",
);
await expect(initialOwnerPage.getByTestId("values")).toContainText(
"CoValue root ---> CoValue child 1 ---> CoValue child 2 ---> CoValue child 3",
);
});
});