Compare commits
51 Commits
create-obs
...
docs/examp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90aa3f42ea | ||
|
|
b0072e63e3 | ||
|
|
a479ece032 | ||
|
|
e0dbe46d64 | ||
|
|
1f1fc56720 | ||
|
|
64fa74a6d9 | ||
|
|
66538fdcf5 | ||
|
|
543f91277d | ||
|
|
42112ec46c | ||
|
|
f4170eb879 | ||
|
|
8278055e33 | ||
|
|
65c236571e | ||
|
|
b53cc9930e | ||
|
|
9c5b34d91c | ||
|
|
a6c119b98d | ||
|
|
6e3565d20f | ||
|
|
e5e21718f9 | ||
|
|
70a93ab093 | ||
|
|
dacaa02a01 | ||
|
|
55cc248d91 | ||
|
|
534fce6796 | ||
|
|
357698f4fb | ||
|
|
9b80278b71 | ||
|
|
6b4cb357ce | ||
|
|
d6638742b0 | ||
|
|
ddb158d5fa | ||
|
|
8b87117e0f | ||
|
|
e5000c2b6b | ||
|
|
0347e52118 | ||
|
|
bb9ba33e73 | ||
|
|
92c63d94b9 | ||
|
|
75339c0939 | ||
|
|
22102deabc | ||
|
|
043e2acae4 | ||
|
|
1b7ef1c2c0 | ||
|
|
996092c26f | ||
|
|
3c794bba0a | ||
|
|
2d3d53d144 | ||
|
|
69d05c8c15 | ||
|
|
d11aeee083 | ||
|
|
4d5848161d | ||
|
|
8ee456d4e4 | ||
|
|
7b2c2e6084 | ||
|
|
133f75d34e | ||
|
|
68c9114896 | ||
|
|
57ff9e2d1f | ||
|
|
1cac820ec6 | ||
|
|
eb4646beca | ||
|
|
5447d6f10b | ||
|
|
dbb040eb07 | ||
|
|
9960320645 |
8
.changeset/real-steaks-drum.md
Normal file
8
.changeset/real-steaks-drum.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"cojson-storage-indexeddb": patch
|
||||
"cojson-storage-sqlite": patch
|
||||
"jazz-tools": patch
|
||||
"cojson": patch
|
||||
---
|
||||
|
||||
Implement Group Inheritance
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.5"
|
||||
"@types/ws": "8.5.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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_");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.5",
|
||||
"@types/ws": "8.5.10",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.5",
|
||||
"@types/ws": "8.5.10",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} */
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
568
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 />,
|
||||
|
||||
@@ -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";
|
||||
|
||||
155
tests/e2e/src/pages/Sharing.tsx
Normal file
155
tests/e2e/src/pages/Sharing.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
396
tests/e2e/tests/Sharing.test.ts
Normal file
396
tests/e2e/tests/Sharing.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user