diff --git a/client/packages/admin/src/index.ts b/client/packages/admin/src/index.ts index cbb8d94ca1..80b2985e06 100644 --- a/client/packages/admin/src/index.ts +++ b/client/packages/admin/src/index.ts @@ -358,7 +358,7 @@ class Rooms> { this.config = config; } - async getPresence>( + async getPresence>( roomType: RoomType, roomId: string, ): Promise>> { diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js index ffcb8ec10d..8a9760774e 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -251,7 +251,7 @@ export default class Reactor { /** @type BroadcastChannel | undefined */ _broadcastChannel; - /** @type {Record} */ + /** @type {Record} */ _rooms = {}; /** @type {Record} */ _roomsPendingLeave = {}; @@ -618,7 +618,8 @@ export default class Reactor { for (const roomId of Object.keys(this._rooms)) { const enqueuedUserPresence = this._presence[roomId]?.result?.user; - this._tryJoinRoom(roomId, enqueuedUserPresence); + const roomType = this._rooms[roomId]?.roomType; + this._tryJoinRoom(roomType, roomId, enqueuedUserPresence); } break; } @@ -2227,15 +2228,17 @@ export default class Reactor { // Rooms /** + * @param {string} roomType * @param {string} roomId * @param {any | null | undefined} [initialPresence] -- initial presence data to send when joining the room * @returns () => void */ - joinRoom(roomId, initialPresence) { + joinRoom(roomType, roomId, initialPresence) { let needsToSendJoin = false; if (!this._rooms[roomId]) { needsToSendJoin = true; this._rooms[roomId] = { + roomType, isConnected: false, error: undefined, }; @@ -2250,7 +2253,7 @@ export default class Reactor { } if (needsToSendJoin) { - this._tryJoinRoom(roomId, initialPresence); + this._tryJoinRoom(roomType, roomId, initialPresence); } return () => { @@ -2284,6 +2287,15 @@ export default class Reactor { getPresence(roomType, roomId, opts = {}) { const room = this._rooms[roomId]; const presence = this._presence[roomId]; + + if (room?.error) { + return { + ...buildPresenceSlice(presence?.result || {}, opts, this._sessionId), + isLoading: false, + error: room.error, + }; + } + if (!room || !presence || !presence.result) return null; return { @@ -2326,8 +2338,13 @@ export default class Reactor { }); } - _tryJoinRoom(roomId, data) { - this._trySendAuthed(uuid(), { op: 'join-room', 'room-id': roomId, data }); + _tryJoinRoom(roomType, roomId, data) { + this._trySendAuthed(uuid(), { + op: 'join-room', + 'room-type': roomType, + 'room-id': roomId, + data, + }); delete this._roomsPendingLeave[roomId]; } @@ -2345,6 +2362,7 @@ export default class Reactor { // TODO: look into typing again subscribePresence(roomType, roomId, opts, cb) { const leaveRoom = this.joinRoom( + roomType, roomId, // Oct 28, 2025 // Note: initialData is deprecated. @@ -2459,8 +2477,8 @@ export default class Reactor { }); } - subscribeTopic(roomId, topic, cb) { - const leaveRoom = this.joinRoom(roomId); + subscribeTopic(roomType, roomId, topic, cb) { + const leaveRoom = this.joinRoom(roomType, roomId); this._broadcastSubs[roomId] = this._broadcastSubs[roomId] || {}; this._broadcastSubs[roomId][topic] = diff --git a/client/packages/core/src/index.ts b/client/packages/core/src/index.ts index a3a636ce20..e9ef3afec5 100644 --- a/client/packages/core/src/index.ts +++ b/client/packages/core/src/index.ts @@ -689,19 +689,23 @@ class InstantCoreDatabase< * unsubscribeTopic(); * room.leaveRoom(); */ - joinRoom>( + joinRoom>( roomType: RoomType = '_defaultRoomType' as RoomType, roomId: string = '_defaultRoomId', opts?: { initialPresence?: Partial>; }, ): RoomHandle, TopicsOf> { - const leaveRoom = this._reactor.joinRoom(roomId, opts?.initialPresence); + const leaveRoom = this._reactor.joinRoom( + roomType, + roomId, + opts?.initialPresence, + ); return { leaveRoom, subscribeTopic: (topic, onEvent) => - this._reactor.subscribeTopic(roomId, topic, onEvent), + this._reactor.subscribeTopic(roomType, roomId, topic, onEvent), subscribePresence: (opts, onChange) => this._reactor.subscribePresence(roomType, roomId, opts, onChange), publishTopic: (topic, data) => diff --git a/client/packages/core/src/presence.ts b/client/packages/core/src/presence.ts index 97ed572e91..bff40e9ec3 100644 --- a/client/packages/core/src/presence.ts +++ b/client/packages/core/src/presence.ts @@ -45,7 +45,7 @@ export type PresenceResponse< Keys extends keyof PresenceShape, > = PresenceSlice & { isLoading: boolean; - error?: string; + error?: { message: string; type: string }; }; export function buildPresenceSlice< diff --git a/client/packages/core/src/rulesTypes.ts b/client/packages/core/src/rulesTypes.ts index c0a65d5d5b..0668343947 100644 --- a/client/packages/core/src/rulesTypes.ts +++ b/client/packages/core/src/rulesTypes.ts @@ -8,6 +8,11 @@ type InstantRulesAttrsAllowBlock = { delete?: string | null | undefined; }; +type InstantRoomRulesAllowBlock = { + $default?: string | null | undefined; + join?: string | null | undefined; +}; + export type InstantRulesAllowBlock = InstantRulesAttrsAllowBlock & { link?: { [key: string]: string } | null | undefined; unlink?: { [key: string]: string } | null | undefined; @@ -24,6 +29,12 @@ export type InstantRules< bind?: string[] | Record; allow: InstantRulesAttrsAllowBlock; }; + $rooms?: { + [RoomType: string]: { + bind?: string[] | Record; + allow: InstantRoomRulesAllowBlock; + }; + }; } & { [EntityName in keyof Schema['entities']]?: { bind?: string[] | Record; diff --git a/client/packages/react-common/src/InstantReactAbstractDatabase.tsx b/client/packages/react-common/src/InstantReactAbstractDatabase.tsx index 3da8026321..414ce159a4 100644 --- a/client/packages/react-common/src/InstantReactAbstractDatabase.tsx +++ b/client/packages/react-common/src/InstantReactAbstractDatabase.tsx @@ -137,7 +137,7 @@ export default abstract class InstantReactAbstractDatabase< * const room = db.room('chat', roomId); * const { peers } = db.rooms.usePresence(room); */ - room( + room( type: RoomType = '_defaultRoomType' as RoomType, id: string = '_defaultRoomId', ) { diff --git a/client/packages/react-common/src/InstantReactRoom.ts b/client/packages/react-common/src/InstantReactRoom.ts index 8a2addfe3f..73c9df2400 100644 --- a/client/packages/react-common/src/InstantReactRoom.ts +++ b/client/packages/react-common/src/InstantReactRoom.ts @@ -61,7 +61,7 @@ export const defaultActivityStopTimeout = 1_000; */ export function useTopicEffect< RoomSchema extends RoomSchemaShape, - RoomType extends keyof RoomSchema, + RoomType extends string & keyof RoomSchema, TopicType extends keyof RoomSchema[RoomType]['topics'], >( room: InstantReactRoom, @@ -76,6 +76,7 @@ export function useTopicEffect< useEffect(() => { const unsub = room.core._reactor.subscribeTopic( + room.type, room.id, topic, (event, peer) => { @@ -104,13 +105,13 @@ export function useTopicEffect< */ export function usePublishTopic< RoomSchema extends RoomSchemaShape, - RoomType extends keyof RoomSchema, + RoomType extends string & keyof RoomSchema, TopicType extends keyof RoomSchema[RoomType]['topics'], >( room: InstantReactRoom, topic: TopicType, ): (data: RoomSchema[RoomType]['topics'][TopicType]) => void { - useEffect(() => room.core._reactor.joinRoom(room.id), [room.id]); + useEffect(() => room.core._reactor.joinRoom(room.type, room.id), [room.id]); const publishTopic = useCallback( (data) => { @@ -146,7 +147,7 @@ export function usePublishTopic< */ export function usePresence< RoomSchema extends RoomSchemaShape, - RoomType extends keyof RoomSchema, + RoomType extends string & keyof RoomSchema, Keys extends keyof RoomSchema[RoomType]['presence'], >( room: InstantReactRoom, @@ -203,13 +204,16 @@ export function usePresence< */ export function useSyncPresence< RoomSchema extends RoomSchemaShape, - RoomType extends keyof RoomSchema, + RoomType extends string & keyof RoomSchema, >( room: InstantReactRoom, data: Partial, deps?: any[], ): void { - useEffect(() => room.core._reactor.joinRoom(room.id, data), [room.id]); + useEffect( + () => room.core._reactor.joinRoom(room.type, room.id, data), + [room.id], + ); useEffect(() => { return room.core._reactor.publishPresence(room.type, room.id, data); }, [room.type, room.id, deps ?? JSON.stringify(data)]); @@ -236,7 +240,7 @@ export function useSyncPresence< */ export function useTypingIndicator< RoomSchema extends RoomSchemaShape, - RoomType extends keyof RoomSchema, + RoomType extends string & keyof RoomSchema, >( room: InstantReactRoom, inputName: string, @@ -320,7 +324,7 @@ export const rooms = { export class InstantReactRoom< Schema extends InstantSchemaDef, RoomSchema extends RoomSchemaShape, - RoomType extends keyof RoomSchema, + RoomType extends string & keyof RoomSchema, > { core: InstantCoreDatabase; /** @deprecated use `core` instead */ diff --git a/client/packages/react/src/Cursors.tsx b/client/packages/react/src/Cursors.tsx index 169ca56ed5..9381bcaf9c 100644 --- a/client/packages/react/src/Cursors.tsx +++ b/client/packages/react/src/Cursors.tsx @@ -10,7 +10,7 @@ import type { RoomSchemaShape } from '@instantdb/core'; export function Cursors< RoomSchema extends RoomSchemaShape, - RoomType extends keyof RoomSchema, + RoomType extends string & keyof RoomSchema, >({ as = 'div', spaceId: _spaceId, diff --git a/client/sandbox/react-nextjs/pages/play/room-perms.tsx b/client/sandbox/react-nextjs/pages/play/room-perms.tsx new file mode 100644 index 0000000000..11af7b2c36 --- /dev/null +++ b/client/sandbox/react-nextjs/pages/play/room-perms.tsx @@ -0,0 +1,146 @@ +import { i, InstantReactAbstractDatabase } from '@instantdb/react'; +import EphemeralAppPage from '../../components/EphemeralAppPage'; +import { useState } from 'react'; + +const schema = i.schema({ + entities: {}, + rooms: { + chat: { + presence: i.entity({ name: i.string() }), + }, + video: { + presence: i.entity({ name: i.string() }), + }, + }, +}); + +const perms = { + $rooms: { + chat: { + allow: { + join: 'auth.id != null', + }, + }, + $default: { + allow: { + join: 'false', + }, + }, + }, +}; + +type DB = InstantReactAbstractDatabase; + +function ChatRoomTest({ db }: { db: DB }) { + const room = db.room('chat', 'test-room'); + const presence = db.rooms.usePresence(room, { + initialPresence: { name: 'guest' }, + }); + + return ( +
+

chat room (should succeed)

+ {presence.isLoading ? ( +

Joining...

+ ) : presence.error ? ( +

+ Error: {presence.error.message || JSON.stringify(presence.error)} +

+ ) : ( +

Connected!

+ )} +
+ ); +} + +function VideoRoomTest({ db }: { db: DB }) { + const room = db.room('video', 'test-room-video'); + const presence = db.rooms.usePresence(room, { + initialPresence: { name: 'guest' }, + }); + + return ( +
+

video room (should fail - $default)

+ {presence.isLoading ? ( +

Joining...

+ ) : presence.error ? ( +

+ Error: {presence.error.message || JSON.stringify(presence.error)} +

+ ) : ( +

Connected!

+ )} +
+ ); +} + +function App({ db, appId }: { db: DB; appId: string }) { + const { isLoading: authLoading, user } = db.useAuth(); + const [showChat, setShowChat] = useState(false); + const [showVideo, setShowVideo] = useState(false); + + return ( +
+

Room Permissions Test

+

+ chat = auth.id != null, $default = false +

+ +
+

1. Auth

+ {authLoading ? ( +

Loading...

+ ) : user ? ( +
+

Signed in: {user.email || user.id}

+ +
+ ) : ( + + )} +
+ + {user && ( +
+

2. Join Rooms

+
+ + {showChat && } + + + {showVideo && } +
+
+ )} +
+ ); +} + +export default function Page() { + return ( +
+ +
+ ); +} diff --git a/client/www/pages/docs/permissions.md b/client/www/pages/docs/permissions.md index 9f0282fa67..0459259e95 100644 --- a/client/www/pages/docs/permissions.md +++ b/client/www/pages/docs/permissions.md @@ -233,6 +233,74 @@ But we would not be able to create goals with new attr types: db.transact(db.tx.goals[id()].update({title: "Hello World", priority: "high"}) ``` +## Rooms + +You can define permissions for room joins using the `$rooms` key. Rules are keyed by room type, and each room type supports a `join` action. The CEL context for room rules exposes `auth` and `data.id`. + +```json +{ + "$rooms": { + "chat": { + "allow": { + "join": "auth.id != null" + } + } + } +} +``` + +If `$rooms` is not defined, all room joins succeed. If `$rooms` is defined, room types not listed fall back to `$rooms.$default` if present, otherwise the join is allowed. + +```json +{ + "$rooms": { + "chat": { + "allow": { + "join": "auth.id != null" + } + }, + "$default": { + "allow": { + "join": "false" + } + } + } +} +``` + +In this example, authenticated users can join `chat` rooms, but all other room types are denied. + +You can use `bind` in room rules just like namespace rules: + +```json +{ + "$rooms": { + "chat": { + "allow": { + "join": "isMember" + }, + "bind": { + "isMember": "auth.id != null" + } + } + } +} +``` + +You can also use `auth.ref` to check relations: + +```json +{ + "$rooms": { + "chat": { + "allow": { + "join": "data.id in auth.ref('$user.chatRooms.id')" + } + } + } +} +``` + ## CEL expressions Inside each rule, you can write CEL code that evaluates to either `true` or `false`. diff --git a/server/src/instant/db/cel.clj b/server/src/instant/db/cel.clj index 78513feee7..756be94272 100644 --- a/server/src/instant/db/cel.clj +++ b/server/src/instant/db/cel.clj @@ -410,6 +410,20 @@ (-> (runtime-compiler-builder) (.build))) +(def ^:private ^CelCompiler cel-join-compiler + (-> (CelCompilerFactory/standardCelCompilerBuilder) + (.addMessageTypes (ucoll/array-of Descriptors$Descriptor [proto/request-descriptor])) + (.addVar "data" type-obj) + (.addVar "auth" type-obj) + (.addVar "request" ^StructTypeReference request-cel-type) + (.addFunctionDeclarations (ucoll/array-of CelFunctionDecl custom-fn-decls)) + (.setOptions cel-options) + (.setStandardMacros CelStandardMacro/STANDARD_MACROS) + (.addLibraries (ucoll/array-of CelCompilerLibrary [(CelExtensions/bindings) + (CelExtensions/strings) + (CelExtensions/math cel-options)])) + (.build))) + (def ^:private ^CelCompiler cel-create-update-compiler (-> (runtime-compiler-builder) (.addVar "newData" type-obj) @@ -443,6 +457,7 @@ (defn action->compiler [action] (case (name action) ("view" "delete") cel-view-delete-compiler + "join" cel-join-compiler "link" cel-link-compiler "unlink" cel-unlink-compiler #_else cel-create-update-compiler)) diff --git a/server/src/instant/model/rule.clj b/server/src/instant/model/rule.clj index 33e7684ef5..ebe3627efd 100644 --- a/server/src/instant/model/rule.clj +++ b/server/src/instant/model/rule.clj @@ -309,7 +309,7 @@ (defn system-attribute-validation-errors "Don't allow users to change rules for restricted system namespaces." [etype action] - (when (and (not (#{"$users" "$files" "$default"} etype)) + (when (and (not (#{"$users" "$files" "$default" "$rooms"} etype)) (string/starts-with? etype "$")) [{:message (format "The %s namespace is a reserved internal namespace that does not yet support rules." etype) @@ -364,7 +364,7 @@ [{:message "There was an unexpected error evaluating the rules" :in path}]))) -(defn rule-validation-errors [rules] +(defn entity-rule-validation-errors [rules] (->> (keys rules) (mapcat (fn [etype] (map (fn [action] [etype action]) ["view" "create" "update" "delete"]))) (mapcat (fn [[etype action]] @@ -397,10 +397,50 @@ (keep identity))) +(defn room-validation-errors + "Validates $rooms rules by building a virtual rules map for each room type + and reusing the existing validation functions." + [rules] + (when-let [rooms-rules (get rules "$rooms")] + (let [room-types (keys rooms-rules)] + (->> room-types + (mapcat (fn [room-type] + (let [virtual-rules (-> {} + (assoc "$default" (get rooms-rules "$default")) + (assoc room-type (get rooms-rules room-type)))] + (concat + (bind-validation-errors virtual-rules) + (->> (map (fn [action] [room-type action]) ["join"]) + (mapcat (fn [[etype action]] + (expr-validation-errors + virtual-rules + {:etype etype + :action action + :path [room-type "allow" action]}))) + (keep identity)))))) + ;; Remap error paths to include "$rooms" prefix + (map (fn [error] + (if (:in error) + (update error :in (fn [path] (into ["$rooms"] path))) + error))))))) + +(defn get-room-program! + "Returns a compiled CEL program for the given room type and action. + Returns nil if no $rooms key exists in rules (backwards compat). + Builds a virtual rules map from $rooms so we can reuse get-program!." + [rules room-type action] + (when-let [rooms-code (get-in rules [:code "$rooms"])] + (let [virtual-rules {:code (-> {} + (assoc "$default" (get rooms-code "$default")) + (assoc room-type (get rooms-code room-type)))}] + (get-program! virtual-rules room-type action)))) + (defn validation-errors [rules] - (concat (bind-validation-errors rules) - (rule-validation-errors rules) - (field-validation-errors rules))) + (let [entity-rules (dissoc rules "$rooms")] + (concat (bind-validation-errors entity-rules) + (entity-rule-validation-errors entity-rules) + (field-validation-errors entity-rules) + (room-validation-errors rules)))) (comment (def code {"docs" {"allow" {"view" "lol" diff --git a/server/src/instant/reactive/session.clj b/server/src/instant/reactive/session.clj index 147a8424da..ba39160efe 100644 --- a/server/src/instant/reactive/session.clj +++ b/server/src/instant/reactive/session.clj @@ -9,6 +9,7 @@ (:require [clojure.main :refer [root-cause]] [instant.config :as config] + [instant.db.cel :as cel] [instant.db.datalog :as d] [instant.db.model.attr :as attr-model] [instant.db.permissioned-transaction :as permissioned-tx] @@ -611,16 +612,60 @@ (when (string? s) s)))) +(defn- assert-room-permission! + "Check room join permissions. If no $rooms rules exist, allow (backwards compat). + If $rooms rules exist but client didn't send room-type, deny." + [{:keys [app-id room-type room-id current-user admin?]}] + (when-not admin? + (let [rules (rule-model/get-by-app-id {:app-id app-id}) + has-room-rules? (some? (get-in rules [:code "$rooms"]))] + (when has-room-rules? + (ex/assert-permitted! + :has-room-join-permission? + ["$rooms" room-type "join"] + (and room-type + (let [program (rule-model/get-room-program! rules room-type "join")] + (if program + (let [ctx {:current-user current-user + :app-id app-id + :db {:conn-pool (aurora/conn-pool :read)} + :attrs (attr-model/get-by-app-id app-id) + :datalog-query-fn d/query}] + (cel/eval-program! ctx program {:data {"id" room-id}})) + true)))))))) + (defn- handle-join-room! [store sess-id {:keys [client-event-id data] :as event}] (let [auth (get-auth! store sess-id) app-id (-> auth :app :id) current-user (-> auth :user) - room-id (validate-room-id event)] - (eph/join-room! app-id sess-id current-user room-id data) - (join-room-logger/log-join-room! app-id) - (rs/send-event! store app-id sess-id {:op :join-room-ok - :room-id room-id - :client-event-id client-event-id}))) + room-id (validate-room-id event) + room-type (get event :room-type)] + ;; Note: we catch permission errors here to send :join-room-error instead of + ;; the generic :error op. The client needs :join-room-error specifically to + ;; surface errors in presence state (room.error). + (try + (assert-room-permission! {:app-id app-id + :room-type room-type + :room-id room-id + :current-user current-user + :admin? (:admin? auth)}) + (eph/join-room! app-id sess-id current-user room-id data) + (join-room-logger/log-join-room! app-id) + (rs/send-event! store app-id sess-id {:op :join-room-ok + :room-id room-id + :client-event-id client-event-id}) + (catch Exception e + (let [instant-ex (ex/find-instant-exception e) + err-data (when instant-ex (ex-data instant-ex))] + (if (#{::ex/permission-denied ::ex/permission-evaluation-failed} + (::ex/type err-data)) + (rs/send-event! store app-id sess-id {:op :join-room-error + :room-id room-id + :error {:message (or (::ex/message err-data) + "Permission denied") + :type :permission-denied} + :client-event-id client-event-id}) + (throw e))))))) (defn- handle-leave-room! [store sess-id {:keys [client-event-id] :as event}] (let [auth (get-auth! store sess-id) diff --git a/server/test/instant/model/rule_test.clj b/server/test/instant/model/rule_test.clj index 0faf403438..a7084c53ce 100644 --- a/server/test/instant/model/rule_test.clj +++ b/server/test/instant/model/rule_test.clj @@ -203,5 +203,76 @@ :in ["myetype" "fields" "email"]}] (rule/validation-errors {"myetype" {"fields" {"email" "!10"}}})))) +;; -------- +;; $rooms + +(deftest rooms-valid-rules-pass-validation + (is (= () (rule/validation-errors + {"$rooms" {"chat" {"allow" {"join" "auth.id != null"}}}})))) + +(deftest rooms-invalid-cel-produces-errors + (is (seq (rule/validation-errors + {"$rooms" {"chat" {"allow" {"join" "invalid !!!"}}}})))) + +(deftest rooms-with-bind-works + (is (= () (rule/validation-errors + {"$rooms" {"chat" {"allow" {"join" "isMember"} + "bind" ["isMember" "auth.id != null"]}}})))) + +(deftest rooms-with-odd-bind-elements-produces-error + (is (seq (rule/validation-errors + {"$rooms" {"chat" {"allow" {"join" "true"} + "bind" ["isMember"]}}})))) + +(deftest rooms-default-fallback-passes-validation + (is (= () (rule/validation-errors + {"$rooms" {"$default" {"allow" {"join" "auth.id != null"}}}})))) + +(deftest rooms-does-not-interfere-with-entity-rules + (is (= () (rule/validation-errors + {"$rooms" {"chat" {"allow" {"join" "true"}}} + "docs" {"allow" {"view" "true"}}})))) + +(deftest get-room-program-returns-nil-when-no-rooms-key + (is (nil? (rule/get-room-program! {:code {}} "chat" "join")))) + +(deftest get-room-program-returns-nil-when-no-rooms-rules + (is (nil? (rule/get-room-program! {:code {"docs" {"allow" {"view" "true"}}}} "chat" "join")))) + +(deftest get-room-program-compiles-correct-program + (let [program (rule/get-room-program! + {:code {"$rooms" {"chat" {"allow" {"join" "auth.id != null"}}}}} + "chat" "join")] + (is (some? program)) + (is (= "auth.id != null" (:code program))))) + +(deftest get-room-program-falls-back-to-default + (let [program (rule/get-room-program! + {:code {"$rooms" {"$default" {"allow" {"join" "auth.id != null"}}}}} + "chat" "join")] + (is (some? program)) + (is (= "auth.id != null" (:code program))))) + +(deftest get-room-program-prefers-specific-over-default + (let [program (rule/get-room-program! + {:code {"$rooms" {"chat" {"allow" {"join" "true"}} + "$default" {"allow" {"join" "false"}}}}} + "chat" "join")] + (is (some? program)) + (is (= "true" (:code program))))) + +(deftest get-room-program-returns-nil-when-no-matching-rule + (is (nil? (rule/get-room-program! + {:code {"$rooms" {"chat" {"allow" {"join" "true"}}}}} + "video" "join")))) + +(deftest get-room-program-with-bind + (let [program (rule/get-room-program! + {:code {"$rooms" {"chat" {"allow" {"join" "isMember"} + "bind" ["isMember" "auth.id != null"]}}}} + "chat" "join")] + (is (some? program)) + (is (= "cel.bind(isMember, auth.id != null, isMember)" (:code program))))) + (comment (test/run-tests *ns*)) diff --git a/server/test/instant/reactive/session_test.clj b/server/test/instant/reactive/session_test.clj index 814a20493e..b81bee835a 100644 --- a/server/test/instant/reactive/session_test.clj +++ b/server/test/instant/reactive/session_test.clj @@ -10,6 +10,7 @@ [instant.db.model.attr :as attr-model] [instant.db.transaction :as tx] [instant.fixtures :refer [with-empty-app with-movies-app with-zeneca-app]] + [instant.model.rule :as rule-model] [instant.grouped-queue :as grouped-queue] instant.isn [instant.jdbc.aurora :as aurora] @@ -1061,3 +1062,89 @@ :data d1})] (is (= :error op)) (is (= 400 status)))))) + +;; -------- +;; Room Permissions + +(deftest room-join-succeeds-with-no-room-rules + (testing "backwards compat: no $rooms rules -> all joins succeed" + (with-session + (fn [_store {:keys [socket movies-app-id]}] + (blocking-send-msg :init-ok socket {:op :init :app-id movies-app-id}) + (let [rid (str (UUID/randomUUID))] + (send-msg socket {:op :join-room + :room-type "chat" + :room-id rid}) + (let [msgs (read-msgs 2 socket) + join-ok (ucoll/seek #(= :join-room-ok (:op %)) msgs)] + (is join-ok) + (is (= rid (:room-id join-ok))))))))) + +(deftest room-join-succeeds-when-rule-allows + (testing "rule evaluates to true -> join-room-ok" + (with-session + (fn [_store {:keys [socket movies-app-id]}] + (rule-model/put! {:app-id movies-app-id + :code {"$rooms" {"chat" {"allow" {"join" "true"}}}}}) + (blocking-send-msg :init-ok socket {:op :init + :app-id movies-app-id}) + (let [rid (str (UUID/randomUUID))] + (send-msg socket {:op :join-room + :room-type "chat" + :room-id rid}) + (let [msgs (read-msgs 2 socket) + join-ok (ucoll/seek #(= :join-room-ok (:op %)) msgs)] + (is join-ok) + (is (= rid (:room-id join-ok))))))))) + +(deftest room-join-fails-when-rule-denies + (testing "rule evaluates to false -> join-room-error" + (with-session + (fn [_store {:keys [socket movies-app-id]}] + (rule-model/put! {:app-id movies-app-id + :code {"$rooms" {"chat" {"allow" {"join" "false"}}}}}) + (blocking-send-msg :init-ok socket {:op :init :app-id movies-app-id}) + (let [rid (str (UUID/randomUUID))] + (send-msg socket {:op :join-room + :room-type "chat" + :room-id rid}) + (let [msg (read-msg socket)] + (is (= :join-room-error (:op msg))) + (is (= rid (:room-id msg))))))))) + +(deftest room-join-uses-default-fallback + (testing "$default fallback applies to unlisted room types" + (with-session + (fn [_store {:keys [socket movies-app-id]}] + (rule-model/put! {:app-id movies-app-id + :code {"$rooms" {"$default" {"allow" {"join" "false"}} + "chat" {"allow" {"join" "true"}}}}}) + (blocking-send-msg :init-ok socket {:op :init :app-id movies-app-id}) + ;; chat should succeed + (let [rid (str (UUID/randomUUID))] + (send-msg socket {:op :join-room + :room-type "chat" + :room-id rid}) + (let [msgs (read-msgs 2 socket) + join-ok (ucoll/seek #(= :join-room-ok (:op %)) msgs)] + (is join-ok))) + ;; unlisted type should be denied by $default + (let [rid2 (str (UUID/randomUUID))] + (send-msg socket {:op :join-room + :room-type "video" + :room-id rid2}) + (let [msg (read-msg socket)] + (is (= :join-room-error (:op msg))))))))) + +(deftest room-join-old-client-denied-when-room-rules-exist + (testing "client not sending room-type gets denied when $rooms rules exist" + (with-session + (fn [_store {:keys [socket movies-app-id]}] + (rule-model/put! {:app-id movies-app-id + :code {"$rooms" {"chat" {"allow" {"join" "true"}}}}}) + (blocking-send-msg :init-ok socket {:op :init :app-id movies-app-id}) + (let [rid (str (UUID/randomUUID))] + (send-msg socket {:op :join-room + :room-id rid}) + (let [msg (read-msg socket)] + (is (= :join-room-error (:op msg)))))))))