=
* the authentication endpoint or connect via WebSocket.
*/
autoConnect?: boolean;
-
- /**
- * @deprecated This flag no longer has any effect and will be removed in
- * a future version. All rooms now use the v2 storage engine by default.
- */
- engine?: 1 | 2;
}
// Initial presence is only mandatory if the custom type requires it to be
@@ -536,12 +530,6 @@ export type ClientOptions = {
lostConnectionTimeout?: number; // in milliseconds
backgroundKeepAliveTimeout?: number; // in milliseconds
polyfills?: Polyfills;
- /**
- * @deprecated All rooms will be migrated to the v2 storage engine in the
- * future, which has native support for streaming. After that migration, this
- * flag will no longer have any effect and will be removed in a future version.
- */
- unstable_streamData?: boolean;
/**
* A function that returns a list of mention suggestions matching a string.
*/
@@ -674,7 +662,6 @@ export function createClient(
const httpClient = createApiClient({
baseUrl,
fetchPolyfill,
- currentUserId,
authManager,
});
@@ -699,7 +686,8 @@ export function createClient(
),
authenticate: async () => {
const resp = await authManager.getAuthValue({
- requestedScope: "room:read",
+ resource: "personal",
+ access: "write",
});
if (resp.type === "public") {
throw new StopRetrying(
@@ -820,7 +808,6 @@ export function createClient(
enableDebugLogging: clientOptions.enableDebugLogging,
baseUrl,
errorEventSource: liveblocksErrorSource,
- unstable_streamData: !!clientOptions.unstable_streamData,
roomHttpClient: httpClient as LiveblocksHttpApi (
conn: Connection,
presence: P
): User {
- const { connectionId, id, info } = conn;
- const canWrite = canWriteStorage(conn.scopes);
+ const { connectionId, id, info, access } = conn;
+ const { canWrite, canComment } = access;
return freeze(
compactObject({
connectionId,
id,
info,
canWrite,
- canComment: canComment(conn.scopes),
+ canComment,
isReadOnly: !canWrite, // Deprecated, kept for backward-compatibility
presence,
})
@@ -118,7 +122,7 @@ export class ManagedOthers {
connectionId: number,
metaUserId: U["id"],
metaUserInfo: U["info"],
- scopes: string[]
+ access: ConnectionAccess
): void {
this.#internal.mutate((state) => {
state.connections.set(
@@ -127,7 +131,7 @@ export class ManagedOthers {
connectionId,
id: metaUserId,
info: metaUserInfo,
- scopes,
+ access,
})
);
if (!state.presences.has(connectionId)) {
diff --git a/packages/liveblocks-core/src/refs/__tests__/ManagedOthers.test.ts b/packages/liveblocks-core/src/refs/__tests__/ManagedOthers.test.ts
index cc155a8f1fa..46280a7fa51 100644
--- a/packages/liveblocks-core/src/refs/__tests__/ManagedOthers.test.ts
+++ b/packages/liveblocks-core/src/refs/__tests__/ManagedOthers.test.ts
@@ -30,7 +30,10 @@ describe('Read-only "others" ref cache', () => {
// information is known for this user. Normally, this information is
// known before the .setOther() call is made, unlike how this test case
// is structured.
- others.setConnection(2, "user-123", undefined, ["room:write"]);
+ others.setConnection(2, "user-123", undefined, {
+ canWrite: true,
+ canComment: true,
+ });
expect(others.get()).toStrictEqual([
{
connectionId: 2,
@@ -45,8 +48,14 @@ describe('Read-only "others" ref cache', () => {
test("setting other", () => {
const others = new ManagedOthers ();
- others.setConnection(2, "user-123", undefined, ["room:write"]);
- others.setConnection(3, "user-567", undefined, ["room:write"]);
+ others.setConnection(2, "user-123", undefined, {
+ canWrite: true,
+ canComment: true,
+ });
+ others.setConnection(3, "user-567", undefined, {
+ canWrite: true,
+ canComment: true,
+ });
others.setOther(2, { x: 2, y: 2 });
others.setOther(3, { x: 3, y: 3 });
@@ -74,8 +83,14 @@ describe('Read-only "others" ref cache', () => {
test("setting other as read-only", () => {
const others = new ManagedOthers ();
- others.setConnection(2, "user-123", undefined, ["room:read"]);
- others.setConnection(3, "user-567", undefined, ["room:write"]);
+ others.setConnection(2, "user-123", undefined, {
+ canWrite: false,
+ canComment: false,
+ });
+ others.setConnection(3, "user-567", undefined, {
+ canWrite: true,
+ canComment: true,
+ });
others.setOther(2, { x: 2, y: 2 });
others.setOther(3, { x: 3, y: 3 });
@@ -102,7 +117,10 @@ describe('Read-only "others" ref cache', () => {
test("setting others removes explicitly-undefined keys", () => {
const others = new ManagedOthers ();
- others.setConnection(2, "user-123", undefined, ["room:write"]);
+ others.setConnection(2, "user-123", undefined, {
+ canWrite: true,
+ canComment: true,
+ });
others.setOther(2, { x: 2, y: 2, z: undefined });
// ^^^^^^^^^ 🔑
@@ -121,7 +139,10 @@ describe('Read-only "others" ref cache', () => {
test("patching others ignores patches for unknown users", () => {
const others = new ManagedOthers ();
- others.setConnection(2, "user-123", undefined, ["room:write"]);
+ others.setConnection(2, "user-123", undefined, {
+ canWrite: true,
+ canComment: true,
+ });
others.patchOther(2, { y: 1, z: 2 }); // .setOther() not called yet for actor 2
expect(others.get()).toStrictEqual([]);
@@ -129,7 +150,10 @@ describe('Read-only "others" ref cache', () => {
test("patching others", () => {
const others = new ManagedOthers ();
- others.setConnection(2, "user-123", undefined, ["room:write"]);
+ others.setConnection(2, "user-123", undefined, {
+ canWrite: true,
+ canComment: true,
+ });
others.setOther(2, { x: 2, y: 2 });
expect(others.get()).toStrictEqual([
{
@@ -169,7 +193,10 @@ describe('Read-only "others" ref cache', () => {
test("removing connections", () => {
const others = new ManagedOthers ();
- others.setConnection(2, "user-123", undefined, ["room:write"]);
+ others.setConnection(2, "user-123", undefined, {
+ canWrite: true,
+ canComment: true,
+ });
others.setOther(2, { x: 2, y: 2 });
expect(others.getUser(2)).toStrictEqual({
@@ -195,7 +222,10 @@ describe('Read-only "others" ref cache', () => {
describe("caching", () => {
test("caches immutable results (others)", () => {
const others = new ManagedOthers ();
- others.setConnection(2, "user-123", undefined, ["room:write"]);
+ others.setConnection(2, "user-123", undefined, {
+ canWrite: true,
+ canComment: true,
+ });
others.setOther(2, { x: 2, y: 2 });
const others1 = others.get();
@@ -224,7 +254,10 @@ describe('Read-only "others" ref cache', () => {
test("getUser() returns stable cache results", () => {
const others = new ManagedOthers ();
- others.setConnection(2, "user-123", undefined, ["room:write"]);
+ others.setConnection(2, "user-123", undefined, {
+ canWrite: true,
+ canComment: true,
+ });
others.setOther(2, { x: 2, y: 2 });
expect(others.getUser(2)).toBe(others.getUser(2));
diff --git a/packages/liveblocks-core/src/room.ts b/packages/liveblocks-core/src/room.ts
index c74579b929f..92d6241eefc 100644
--- a/packages/liveblocks-core/src/room.ts
+++ b/packages/liveblocks-core/src/room.ts
@@ -8,8 +8,8 @@ import type { ApplyResult, ManagedPool } from "./crdts/AbstractCrdt";
import { createManagedPool, OpSource } from "./crdts/AbstractCrdt";
import {
cloneLson,
+ diffNodeMap,
dumpPool,
- getTreesDiffOperations,
isLiveList,
isLiveNode,
isSameNodeOrChildOf,
@@ -52,12 +52,16 @@ import {
partition,
tryParseJson,
} from "./lib/utils";
+import type { PermissionMatrix, RoomPermissions } from "./permissions";
+import {
+ hasPermissionAccess,
+ normalizeRoomPermissions,
+ permissionMatrixFromScopes,
+} from "./permissions";
import type {
ContextualPromptContext,
ContextualPromptResponse,
} from "./protocol/Ai";
-import type { Permission } from "./protocol/AuthToken";
-import { canComment, canWriteStorage } from "./protocol/AuthToken";
import type { BaseUserMeta, IUserInfo } from "./protocol/BaseUserMeta";
import type {
AddFeedClientMsg,
@@ -113,11 +117,7 @@ import type {
YDocUpdateServerMsg,
} from "./protocol/ServerMsg";
import { ServerMsgCode } from "./protocol/ServerMsg";
-import type {
- NodeMap,
- NodeStream,
- SerializedCrdt,
-} from "./protocol/StorageNode";
+import type { NodeMap, NodeStream } from "./protocol/StorageNode";
import { compactNodesToNodeStream } from "./protocol/StorageNode";
import type {
SubscriptionData,
@@ -772,10 +772,16 @@ export type Room<
/**
* Get the room's storage synchronously.
- * The storage's root is a {@link LiveObject}.
+ * The storage's root is a LiveObject.
*
* @example
- * const root = room.getStorageSnapshot();
+ * const root = room.getStorageOrNull();
+ */
+ getStorageOrNull(): LiveObject ;
@@ -1360,7 +1362,7 @@ function useMutableStorageRoot_withRoomContext =
* only on the client side.
*/
autoConnect?: boolean;
-
- /**
- * @deprecated This flag no longer has any effect and will be removed in
- * a future version. All rooms now use the v2 storage engine by default.
- */
- engine?: 1 | 2;
}
// Initial presence is only mandatory if the custom type requires it to be
diff --git a/packages/liveblocks-react/src/umbrella-store.ts b/packages/liveblocks-react/src/umbrella-store.ts
index 4c8f17110b0..652c49b5257 100644
--- a/packages/liveblocks-react/src/umbrella-store.ts
+++ b/packages/liveblocks-react/src/umbrella-store.ts
@@ -21,8 +21,9 @@ import type {
OpaqueClient,
PartialNotificationSettings,
Patchable,
- Permission,
+ PermissionMatrix,
Resolve,
+ RoomPermissions,
RoomSubscriptionSettings,
SubscriptionData,
SubscriptionDeleteInfo,
@@ -47,6 +48,7 @@ import {
nanoid,
nn,
patchNotificationSettings,
+ permissionMatrixFromScopes,
shallow,
shallow2,
Signal,
@@ -954,26 +956,51 @@ function createStore_forUrlsMetadata() {
};
}
+type PermissionHint = {
+ requestedAt: Date;
+ permissions: PermissionMatrix;
+};
+
function createStore_forPermissionHints() {
const permissionsByRoomId = new DefaultMap(
- () => new Signal | null;
+
+ /**
+ * @deprecated Renamed to `Room.getStorageOrNull`. This alias will be
+ * removed in the future.
*/
getStorageSnapshot(): LiveObject | null;
@@ -921,7 +927,7 @@ export type Room<
subscriptions: SubscriptionData[];
requestedAt: Date;
nextCursor: string | null;
- permissionHints: Record | null {
+ function getStorageOrNull(): LiveObject | null {
const root = context.root;
if (root !== undefined) {
// Done loading
@@ -3470,7 +3465,7 @@ export function createRoom<
}
function isStorageReady() {
- return getStorageSnapshot() !== null;
+ return getStorageOrNull() !== null;
}
async function waitUntilStorageReady(): Promise(
RoomContext
);
const subscribe = room.events.storageDidLoad.subscribeOnce;
- const getSnapshot = room.getStorageSnapshot;
+ const getSnapshot = room.getStorageOrNull;
const getServerSnapshot = alwaysNull;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
@@ -3709,7 +3711,61 @@ function useAttachmentUrlSuspense(attachmentId: string) {
function useRoomPermissions(roomId: string) {
const client = useClient();
const store = getRoomExtrasForClient(client).store;
- return useSignal(store.permissionHints.getPermissionForRoomΣ(roomId));
+ return useSignal(store.permissionHints.getPermissionForRoomΣ(roomId))
+ ?.permissions;
+}
+
+/**
+ * @private For internal use only. Do not rely on this hook.
+ */
+function useHasPermissionAccess(
+ roomId: string,
+ resource: PermissionResources,
+ requiredAccess: RequiredAccessLevel
+): boolean {
+ const permissions = useRoomPermissions(roomId);
+ const fallback = useSelfAccessFallback(roomId, resource, requiredAccess);
+
+ return permissions !== undefined
+ ? hasPermissionAccess(permissions, resource, requiredAccess)
+ : fallback;
+}
+
+// Permission hints come from REST; until they exist, fall back to scopes from
+// the room connection (same optimistic defaults as isStorageWritable).
+function useSelfAccessFallback(
+ roomId: string,
+ resource: PermissionResources,
+ requiredAccess: RequiredAccessLevel
+): boolean {
+ const room = useRoom_withRoomContext(GlobalRoomContext, {
+ allowOutsideRoom: true,
+ });
+
+ const subscribe = useCallback(
+ (callback: () => void) => {
+ if (room === null || room.id !== roomId) {
+ return () => {};
+ }
+
+ return room.events.self.subscribe(callback);
+ },
+ [room, roomId]
+ );
+
+ const getSnapshot = useCallback(() => {
+ const self = room?.id === roomId ? room.getSelf() : null;
+ if (resource === "comments" && requiredAccess === "write") {
+ return self?.canComment ?? true;
+ }
+ if (resource === "storage" && requiredAccess === "write") {
+ return self?.canWrite ?? true;
+ }
+
+ return false;
+ }, [resource, requiredAccess, room, roomId]);
+
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/**
@@ -4949,6 +5005,7 @@ export {
_useFeedMessagesSuspense as useFeedMessagesSuspense,
_useFeeds as useFeeds,
_useFeedsSuspense as useFeedsSuspense,
+ useHasPermissionAccess,
useHistory,
useHistoryVersionData,
_useHistoryVersions as useHistoryVersions,
diff --git a/packages/liveblocks-react/src/types/index.ts b/packages/liveblocks-react/src/types/index.ts
index 48c3feca217..7fd0f6eddf7 100644
--- a/packages/liveblocks-react/src/types/index.ts
+++ b/packages/liveblocks-react/src/types/index.ts
@@ -390,12 +390,6 @@ export type RoomProviderProps